"""Campaign application tracking — Phase 7.4 Campaign Engine. Independent aggregate (no SQLAlchemy relationships), same as other Loyalty aggregates. Points are always the ledger's responsibility; this row only records that a campaign granted N points to a member at a given rule version. """ from __future__ import annotations import uuid from datetime import datetime from sqlalchemy import Index, Integer, String, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.types import JSON from app.core.database import Base from app.models.base import TenantMixin, TimestampMixin, UUIDPrimaryKeyMixin from app.models.types import CampaignApplicationStatus, GUID class CampaignApplication( Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin, ): """Record of a campaign's point grant applied to a member's point account.""" __tablename__ = "campaign_applications" campaign_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) program_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) member_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) point_account_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) rule_version: Mapped[int] = mapped_column(Integer, nullable=False) points_granted: Mapped[int] = mapped_column(Integer, default=0, nullable=False) ledger_entry_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) status: Mapped[CampaignApplicationStatus] = mapped_column( default=CampaignApplicationStatus.APPLIED, nullable=False ) idempotency_key: Mapped[str | None] = mapped_column(String(100), nullable=True) actor_user_id: Mapped[str | None] = mapped_column(String(100), nullable=True) metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) __table_args__ = ( UniqueConstraint( "tenant_id", "idempotency_key", name="uq_campaign_applications_tenant_idempotency", ), Index( "ix_campaign_applications_campaign_member", "tenant_id", "campaign_id", "member_id", ), )