Validation for prices and objection

This commit is contained in:
SepehrYahyaee
2026-05-20 11:08:47 +03:30
parent 511d478064
commit e4dfe7c572
18 changed files with 467 additions and 60 deletions

View File

@@ -0,0 +1,31 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from "@nestjs/common";
import { Observable } from "rxjs";
import { normalizeUnicodeDigitsDeep } from "src/utils/unicode-digits";
/**
* Normalizes Unicode decimal digits in JSON request bodies to ASCII before
* validation and handlers run (Persian/Arabic-Indic → 0-9).
*/
@Injectable()
export class UnicodeDigitsNormalizeInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const req = context.switchToHttp().getRequest();
const contentType = String(req.headers?.["content-type"] ?? "");
if (
req.body &&
typeof req.body === "object" &&
!Buffer.isBuffer(req.body) &&
!contentType.includes("multipart/form-data")
) {
req.body = normalizeUnicodeDigitsDeep(req.body);
}
return next.handle();
}
}

View File

@@ -0,0 +1,24 @@
import {
ValidationArguments,
ValidatorConstraint,
ValidatorConstraintInterface,
} from "class-validator";
@ValidatorConstraint({ name: "hasObjectionEntries", async: false })
export class HasObjectionEntriesConstraint
implements ValidatorConstraintInterface
{
validate(_value: unknown, args: ValidationArguments): boolean {
const o = args.object as {
objectionParts?: unknown[];
newParts?: unknown[];
};
const partsLen = Array.isArray(o.objectionParts) ? o.objectionParts.length : 0;
const newLen = Array.isArray(o.newParts) ? o.newParts.length : 0;
return partsLen > 0 || newLen > 0;
}
defaultMessage(): string {
return "Provide at least one entry in objectionParts and/or newParts.";
}
}

View File

@@ -0,0 +1,37 @@
import {
registerDecorator,
ValidationArguments,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
} from "class-validator";
import { normalizeMoneyAmountString } from "src/utils/unicode-digits";
@ValidatorConstraint({ name: "isMoneyAmountString", async: false })
export class IsMoneyAmountStringConstraint
implements ValidatorConstraintInterface
{
validate(value: unknown): boolean {
if (value == null || value === "") return true;
if (typeof value !== "string") return false;
const n = normalizeMoneyAmountString(value);
if (!n) return false;
return /^\d+(\.\d+)?$/.test(n);
}
defaultMessage(): string {
return "Must be a non-negative amount (digits only, optional decimal).";
}
}
export function IsMoneyAmountString(validationOptions?: ValidationOptions) {
return (object: object, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [],
validator: IsMoneyAmountStringConstraint,
});
};
}

View File

@@ -0,0 +1,29 @@
import {
ValidationArguments,
ValidatorConstraint,
ValidatorConstraintInterface,
} from "class-validator";
@ValidatorConstraint({ name: "objectionPartHasContent", async: false })
export class ObjectionPartHasContentConstraint
implements ValidatorConstraintInterface
{
validate(_value: unknown, args: ValidationArguments): boolean {
const o = args.object as {
reason?: string;
partPrice?: string;
partSalary?: string;
};
const hasReason =
typeof o.reason === "string" && o.reason.trim().length >= 3;
const hasPrice =
o.partPrice != null && String(o.partPrice).trim() !== "";
const hasSalary =
o.partSalary != null && String(o.partSalary).trim() !== "";
return hasReason || hasPrice || hasSalary;
}
defaultMessage(): string {
return "Each objection line needs a reason (min 3 characters) and/or proposed partPrice / partSalary.";
}
}

View File

@@ -0,0 +1,66 @@
import {
registerDecorator,
ValidationArguments,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
} from "class-validator";
import {
REPAIR_LINE_AMOUNT_TOMAN,
} from "src/constants/repair-amount-limits";
import { parseMoneyAmountToman } from "src/utils/unicode-digits";
export type RepairLineAmountTomanOptions = {
/** When true, `0` is valid (e.g. expert `price` / `salary` split where one side is zero). */
allowZero?: boolean;
};
@ValidatorConstraint({ name: "isRepairLineAmountToman", async: false })
export class IsRepairLineAmountTomanConstraint
implements ValidatorConstraintInterface
{
validate(value: unknown, args: ValidationArguments): boolean {
const [minToman, maxToman, allowZero] = args.constraints as [
number,
number,
boolean | undefined,
];
if (value == null || value === "") return true;
const amount = parseMoneyAmountToman(value);
if (amount == null) return false;
if (allowZero && amount === 0) return true;
return amount >= minToman && amount <= maxToman;
}
defaultMessage(args: ValidationArguments): string {
const [minToman, maxToman, allowZero] = args.constraints as [
number,
number,
boolean | undefined,
];
const zeroHint = allowZero ? " Use 0 where one side of price/salary is unused." : "";
return (
`Amount must be in Toman between ${minToman.toLocaleString("en-US")} and ${maxToman.toLocaleString("en-US")}.${zeroHint}`
);
}
}
/** Optional repair-line amount in Toman (digits only after unicode normalize). */
export function IsRepairLineAmountToman(
options?: ValidationOptions & RepairLineAmountTomanOptions,
) {
const { allowZero, ...validationOptions } = options ?? {};
return (object: object, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [
REPAIR_LINE_AMOUNT_TOMAN.MIN,
REPAIR_LINE_AMOUNT_TOMAN.MAX,
allowZero === true,
],
validator: IsRepairLineAmountTomanConstraint,
});
};
}