"""Phase 7.0 Loyalty validators.""" from __future__ import annotations import re from datetime import date from shared.exceptions import AppError from app.models.types import ( CampaignStatus, MemberStatus, PointAccountStatus, ProgramStatus, RewardStatus, RewardType, TierStatus, ) EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") PHONE_RE = re.compile(r"^[\d\s\+\-\(\)]{5,30}$") CODE_RE = re.compile(r"^[A-Za-z0-9_\-]{2,50}$") def validate_non_empty(value: str | None, *, field: str) -> str: if value is None or not str(value).strip(): raise AppError( f"{field} الزامی است", status_code=422, error_code="validation_error", details={"field": field}, ) return str(value).strip() def validate_code(code: str | None, *, field: str = "code") -> str: cleaned = validate_non_empty(code, field=field).upper() if not CODE_RE.match(cleaned): raise AppError( f"فرمت {field} نامعتبر است", status_code=422, error_code="invalid_code", details={"field": field}, ) return cleaned def validate_email(email: str | None, *, required: bool = False) -> str | None: if email is None or not email.strip(): if required: raise AppError( "ایمیل الزامی است", status_code=422, error_code="email_required", ) return None cleaned = email.strip().lower() if not EMAIL_RE.match(cleaned): raise AppError( "فرمت ایمیل نامعتبر است", status_code=422, error_code="invalid_email", ) return cleaned def validate_phone(phone: str | None, *, field: str = "phone") -> str | None: if phone is None or not phone.strip(): return None cleaned = phone.strip() if not PHONE_RE.match(cleaned): raise AppError( f"فرمت {field} نامعتبر است", status_code=422, error_code="invalid_phone", details={"field": field}, ) return cleaned def validate_currency_code(code: str) -> str: cleaned = code.strip().upper() if len(cleaned) != 3: raise AppError( "کد ارز باید ۳ حرفی باشد", status_code=422, error_code="invalid_currency_code", ) return cleaned def validate_non_negative_int(value: int | None, *, field: str) -> int: resolved = 0 if value is None else int(value) if resolved < 0: raise AppError( f"{field} نمی‌تواند منفی باشد", status_code=422, error_code="invalid_amount", details={"field": field}, ) return resolved def validate_program_status(status: ProgramStatus | str) -> ProgramStatus: if isinstance(status, ProgramStatus): return status try: return ProgramStatus(status) except ValueError as exc: raise AppError( "وضعیت برنامه وفاداری نامعتبر است", status_code=422, error_code="invalid_program_status", ) from exc def validate_member_status(status: MemberStatus | str) -> MemberStatus: if isinstance(status, MemberStatus): return status try: return MemberStatus(status) except ValueError as exc: raise AppError( "وضعیت عضو نامعتبر است", status_code=422, error_code="invalid_member_status", ) from exc def validate_tier_status(status: TierStatus | str) -> TierStatus: if isinstance(status, TierStatus): return status try: return TierStatus(status) except ValueError as exc: raise AppError( "وضعیت سطح عضویت نامعتبر است", status_code=422, error_code="invalid_tier_status", ) from exc def validate_point_account_status( status: PointAccountStatus | str, ) -> PointAccountStatus: if isinstance(status, PointAccountStatus): return status try: return PointAccountStatus(status) except ValueError as exc: raise AppError( "وضعیت حساب امتیاز نامعتبر است", status_code=422, error_code="invalid_point_account_status", ) from exc def validate_reward_type(reward_type: RewardType | str) -> RewardType: if isinstance(reward_type, RewardType): return reward_type try: return RewardType(reward_type) except ValueError as exc: raise AppError( "نوع پاداش نامعتبر است", status_code=422, error_code="invalid_reward_type", ) from exc def validate_reward_status(status: RewardStatus | str) -> RewardStatus: if isinstance(status, RewardStatus): return status try: return RewardStatus(status) except ValueError as exc: raise AppError( "وضعیت پاداش نامعتبر است", status_code=422, error_code="invalid_reward_status", ) from exc def validate_campaign_status(status: CampaignStatus | str) -> CampaignStatus: if isinstance(status, CampaignStatus): return status try: return CampaignStatus(status) except ValueError as exc: raise AppError( "وضعیت کمپین نامعتبر است", status_code=422, error_code="invalid_campaign_status", ) from exc def validate_campaign_dates( starts_on: date | None, ends_on: date | None ) -> tuple[date | None, date | None]: if starts_on and ends_on and ends_on < starts_on: raise AppError( "تاریخ پایان کمپین نمی‌تواند قبل از تاریخ شروع باشد", status_code=422, error_code="invalid_campaign_dates", ) return starts_on, ends_on def ensure_optimistic_version( entity_version: int, expected: int | None, *, required: bool = True ) -> None: if expected is None: if required: raise AppError( "فیلد version برای به‌روزرسانی الزامی است", status_code=422, error_code="version_required", ) return if entity_version != expected: raise AppError( "نسخه رکورد تغییر کرده است؛ دوباره تلاش کنید", status_code=409, error_code="version_conflict", details={"expected": expected, "actual": entity_version}, ) def release_unique_token(value: str, entity_id) -> str: """Free unique business keys on soft-delete so codes can be reused.""" suffix = f"__del__{entity_id.hex[:8]}" if value.endswith(suffix): return value # Keep within typical 50-char code columns base = value[: max(0, 50 - len(suffix))] return f"{base}{suffix}" def forbid_direct_balance_mutation(payload: dict) -> None: forbidden = {"balance", "points_balance", "available_balance", "ledger_balance"} present = forbidden.intersection(payload.keys()) if present: raise AppError( "تغییر مستقیم موجودی امتیاز ممنوع است؛ فقط از طریق ledger", status_code=422, error_code="direct_balance_forbidden", details={"fields": sorted(present)}, )