1
0
forked from Yara724/api

Merge pull request 'Fix 404 for invalid email of actors, added APIs for client settings' (#70) from s.yahyaee/yara724-api:main into main

Reviewed-on: Yara724/api#70
This commit is contained in:
2026-05-16 15:47:59 +03:30
8 changed files with 484 additions and 47 deletions

View File

@@ -31,7 +31,6 @@ import {
LegalRegisterDto, LegalRegisterDto,
} from "src/auth/dto/actor/register.actor.dto"; } from "src/auth/dto/actor/register.actor.dto";
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard"; import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
import { ClientKey } from "src/decorators/clientKey.decorator";
import { Roles } from "src/decorators/roles.decorator"; import { Roles } from "src/decorators/roles.decorator";
import { CurrentUser } from "src/decorators/user.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() @ApiAcceptedResponse()
async login(@Body() body, @Req() req, @ClientKey() client) { async login(@Req() req: { user: Record<string, unknown> }) {
return await this.actorAuthService.loginActors(req.user); return req.user;
} }
@Post("forget-password") @Post("forget-password")

View File

@@ -97,9 +97,64 @@ export class ActorAuthService {
return res; return res;
} }
async validateActor(username: string, pass: string, role): Promise<any> { /** 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 raw as RoleEnum;
}
parseActorLoginUsername(body: Record<string, unknown>): 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<any> {
const user = await this.dynamicDbController(role, username); const user = await this.dynamicDbController(role, username);
if (user) { if (!user) {
throw new NotFoundException("Actor account not found");
}
if (user.role !== role) { if (user.role !== role) {
throw new UnauthorizedException("user not assigned to this role"); throw new UnauthorizedException("user not assigned to this role");
} }
@@ -107,38 +162,30 @@ export class ActorAuthService {
throw new UnauthorizedException( throw new UnauthorizedException(
"password is incorrect or access Denied", "password is incorrect or access Denied",
); );
} else { }
return user; return user;
} }
/**
* Authenticates an actor from a login request body (role, username/email, password).
*/
async loginFromCredentials(body: Record<string, unknown>) {
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");
} }
return null; const actor = await this.validateActor(username, password, role);
return this.issueActorTokens(actor);
} }
/** @deprecated Prefer {@link loginFromCredentials}. Kept for internal callers. */
async loginActors(user: any) { async loginActors(user: any) {
let foundedUser = await this.dynamicDbController(user.role, user.username); if (user?.access_token && user?.sub) {
if (foundedUser) { return user;
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");
} }
return this.loginFromCredentials(user as Record<string, unknown>);
} }
async registerActors( async registerActors(

View File

@@ -23,10 +23,12 @@ export class LocalActorAuthGuard extends AuthGuard("actor") {
const path = request.url; const path = request.url;
if (!token) { if (!token) {
if (path === "/actor/login") { if (this.isActorLoginRequest(request)) {
const loginData = await this.actorAuthService.loginActors(request.body); const loginData = await this.actorAuthService.loginFromCredentials(
request.body ?? {},
);
request.user = loginData; request.user = loginData;
request.identity = request; request.identity = loginData;
return true; return true;
} else { } else {
throw new UnauthorizedException("Token not found"); throw new UnauthorizedException("Token not found");
@@ -59,6 +61,17 @@ export class LocalActorAuthGuard extends AuthGuard("actor") {
return true; 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 { private extractTokenFromHeader(request: Request): string | undefined {
//@ts-ignore //@ts-ignore
const [type, token] = request.headers.authorization?.split(" ") ?? []; const [type, token] = request.headers.authorization?.split(" ") ?? [];

View File

@@ -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<ClientSettingsPanelResponseDto> {
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<ClientSettingsPanelResponseDto> {
if (!insurer?.clientKey) {
throw new UnauthorizedException("Insurer client key is missing.");
}
return this.clientService.updatePanelSettings(insurer.clientKey, body);
}
}

View File

@@ -1,6 +1,7 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { MongooseModule } from "@nestjs/mongoose"; import { MongooseModule } from "@nestjs/mongoose";
import { ClientController } from "./client.controller"; import { ClientController } from "./client.controller";
import { ClientPanelController } from "./client-panel.controller";
import { ClientService } from "./client.service"; import { ClientService } from "./client.service";
import { BranchDbService } from "./entities/db-service/branch.db.service"; import { BranchDbService } from "./entities/db-service/branch.db.service";
import { ClientDbService } from "./entities/db-service/client.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], providers: [ClientService, ClientDbService, BranchDbService],
exports: [ClientService, ClientDbService, BranchDbService], exports: [ClientService, ClientDbService, BranchDbService],
}) })

View File

@@ -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 { Types } from "mongoose";
import { import {
ClientDto, ClientDto,
ClientDtoRs, ClientDtoRs,
ClientLists, ClientLists,
} from "src/client/dto/create-client.dto"; } 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"; import { ClientDbService } from "./entities/db-service/client.db.service";
/** /**
@@ -42,6 +53,18 @@ export const DEFAULT_MEDIA_LIMITS: Record<MediaKind, MediaLimits> = {
voice: { minBytes: 5 * 1024, maxBytes: 8 * 1024 * 1024 }, 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<MediaKind, number> = {
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() @Injectable()
export class ClientService { export class ClientService {
constructor(private readonly clientDbService: ClientDbService) {} constructor(private readonly clientDbService: ClientDbService) {}
@@ -155,4 +178,138 @@ export class ClientService {
} }
return DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS; 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<ClientSettingsPanelResponseDto> {
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<ClientSettingsPanelResponseDto> {
const client = await this.loadClientOrThrow(clientKey);
const $set: Record<string, unknown> = {};
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);
}
} }

View File

@@ -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<MediaKind, MediaKindSettingsViewDto>;
}

View File

@@ -1,6 +1,6 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { InjectModel } from "@nestjs/mongoose"; import { InjectModel } from "@nestjs/mongoose";
import { FilterQuery, Model } from "mongoose"; import { FilterQuery, Model, UpdateQuery } from "mongoose";
import { ClientModel, ClientDocument } from "../schema/client.schema"; import { ClientModel, ClientDocument } from "../schema/client.schema";
@Injectable() @Injectable()
@@ -25,4 +25,14 @@ export class ClientDbService {
async findAll(): Promise<ClientModel[]> { async findAll(): Promise<ClientModel[]> {
return await this.clientModel.find(); return await this.clientModel.find();
} }
async findByIdAndUpdate(
id: string,
update: UpdateQuery<ClientModel>,
): Promise<ClientModel | null> {
return this.clientModel
.findByIdAndUpdate(id, update, { new: true })
.lean()
.exec();
}
} }