Files
yara724api/src/client/client.service.ts

316 lines
10 KiB
TypeScript

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";
/**
* System-wide default applied when a client document has no
* `settings.carBodyAccidentMaxAgeDays` value yet. Keep it conservative so the
* gate is enforced even for older / unconfigured clients.
*/
export const DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS = 7;
/**
* Media kinds we enforce upload-size bounds for. Each kind has its own
* default window (see {@link DEFAULT_MEDIA_LIMITS}) and an optional
* per-client override under `settings.media.<kind>`.
*/
export type MediaKind = "video" | "image" | "voice";
export interface MediaLimits {
/** Inclusive lower bound. `0` allows any size from zero up. */
minBytes: number;
/** Inclusive upper bound. */
maxBytes: number;
}
/**
* System defaults applied when a client (or kind) has no explicit override.
*
* Important: each upload route also has a `multer.limits.fileSize` hard
* ceiling — those route ceilings are the absolute maximum the API can
* receive. Per-client `maxBytes` cannot legitimately go above its route's
* multer ceiling, so the defaults below are kept at-or-below those.
*/
export const DEFAULT_MEDIA_LIMITS: Record<MediaKind, MediaLimits> = {
video: { minBytes: 256 * 1024, maxBytes: 20 * 1024 * 1024 },
image: { 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()
export class ClientService {
constructor(private readonly clientDbService: ClientDbService) {}
async addClient(client: ClientDto): Promise<ClientDtoRs> {
try {
const newClient = await this.clientDbService.create({
clientCode: client.clientCode,
clientName: {
persian: client.clientName.persian,
english: client.clientName.english || null,
},
property: {
smsApiKey: client.property.smsApiKey || null,
},
useExpertMode: client.useExpertMode || null,
});
if (newClient) return new ClientDtoRs(newClient);
else throw new GoneException("database not connected");
} catch (er) {
throw new BadGatewayException(er.errors);
}
}
findOne(filter) {
return this.clientDbService.findOne(filter);
}
async findClientWithPersianName(filter: string) {
return await this.clientDbService.find({ "clientName.persian": filter });
}
async findClientWithCompanyCode(companyCode: number) {
return await this.clientDbService.find({ clientCode: companyCode });
}
async getClients(): Promise<ClientDtoRs[]> {
const clients = await this.clientDbService.findAll();
const show = clients.map((c) => new ClientDtoRs(c));
return show;
}
async getClientList(): Promise<ClientLists[]> {
const client = await this.clientDbService.findAll();
const list = client.map((element) => new ClientLists(element));
return list;
}
/**
* Resolve the per-client byte bounds for a media kind. Missing bounds fall
* back to {@link DEFAULT_MEDIA_LIMITS}. If no clientId is supplied (or it
* doesn't resolve to a client document) the defaults are returned.
*
* The returned object is always fully populated — callers can compare
* directly against `file.size`.
*/
async getMediaLimits(
clientId: string | Types.ObjectId | null | undefined,
kind: MediaKind,
): Promise<MediaLimits> {
const defaults = DEFAULT_MEDIA_LIMITS[kind];
if (!clientId) return { ...defaults };
const idString = String(clientId);
if (!Types.ObjectId.isValid(idString)) return { ...defaults };
const client = await this.clientDbService.findOne({
_id: new Types.ObjectId(idString),
});
const override = client?.settings?.media?.[kind];
const minBytes =
typeof override?.minBytes === "number" &&
Number.isFinite(override.minBytes) &&
override.minBytes >= 0
? override.minBytes
: defaults.minBytes;
const maxBytes =
typeof override?.maxBytes === "number" &&
Number.isFinite(override.maxBytes) &&
override.maxBytes > 0
? override.maxBytes
: defaults.maxBytes;
return { minBytes, maxBytes };
}
/**
* Resolve the per-client CAR_BODY accident-age window (in days).
*
* Falls back to {@link DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS} when:
* - no client id is supplied,
* - the id is malformed,
* - the client document is missing,
* - the client has no `settings.carBodyAccidentMaxAgeDays` configured,
* - or the configured value is not a positive finite number.
*/
async getCarBodyAccidentMaxAgeDays(
clientId?: string | Types.ObjectId | null,
): Promise<number> {
if (!clientId) return DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS;
const idString = String(clientId);
if (!Types.ObjectId.isValid(idString)) {
return DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS;
}
const client = await this.clientDbService.findOne({
_id: new Types.ObjectId(idString),
});
const configured = client?.settings?.carBodyAccidentMaxAgeDays;
if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) {
return configured;
}
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);
}
}