forked from Yara724/api
365 lines
11 KiB
TypeScript
365 lines
11 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 = 5;
|
|
|
|
/**
|
|
* 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"
|
|
| "chassisNumber"
|
|
| "carPlate";
|
|
|
|
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_MIN_BYTES = 10; // 10 Byte
|
|
export const DEFAULT_MEDIA_MAX_BYTES = 100 * 1024 * 1024; // 100MB
|
|
|
|
export const DEFAULT_MEDIA_LIMITS: Record<MediaKind, MediaLimits> = {
|
|
video: {
|
|
minBytes: DEFAULT_MEDIA_MIN_BYTES,
|
|
maxBytes: DEFAULT_MEDIA_MAX_BYTES,
|
|
},
|
|
image: {
|
|
minBytes: DEFAULT_MEDIA_MIN_BYTES,
|
|
maxBytes: DEFAULT_MEDIA_MAX_BYTES,
|
|
},
|
|
voice: {
|
|
minBytes: DEFAULT_MEDIA_MIN_BYTES,
|
|
maxBytes: DEFAULT_MEDIA_MAX_BYTES,
|
|
},
|
|
carPlate: {
|
|
minBytes: DEFAULT_MEDIA_MIN_BYTES,
|
|
maxBytes: DEFAULT_MEDIA_MAX_BYTES,
|
|
},
|
|
chassisNumber: {
|
|
minBytes: DEFAULT_MEDIA_MIN_BYTES,
|
|
maxBytes: DEFAULT_MEDIA_MAX_BYTES,
|
|
},
|
|
};
|
|
|
|
/** Highest multer `fileSize` used on any route for each kind (policy cannot exceed this). */
|
|
export const MEDIA_ROUTE_MAX_BYTES: Record<MediaKind, number> = {
|
|
video: DEFAULT_MEDIA_MAX_BYTES,
|
|
image: DEFAULT_MEDIA_MAX_BYTES,
|
|
voice: DEFAULT_MEDIA_MAX_BYTES,
|
|
carPlate: DEFAULT_MEDIA_MAX_BYTES,
|
|
chassisNumber: DEFAULT_MEDIA_MAX_BYTES,
|
|
};
|
|
|
|
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:
|
|
typeof client.clientName === "string"
|
|
? client.clientName
|
|
: client.clientName?.persian,
|
|
|
|
english:
|
|
typeof client.clientName === "string"
|
|
? null
|
|
: (client.clientName?.english ?? null),
|
|
},
|
|
|
|
property: {
|
|
smsApiKey: client.property?.smsApiKey ?? null,
|
|
},
|
|
|
|
useExpertMode: client.useExpertMode ?? null,
|
|
});
|
|
|
|
if (!newClient) {
|
|
throw new GoneException("database not connected");
|
|
}
|
|
|
|
return new ClientDtoRs(newClient);
|
|
} catch (er) {
|
|
console.error("ADD CLIENT ERROR:", er);
|
|
throw er;
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|