Files
kevin-asprec 132290957c feat(phase-5): on-prem sync poll hardening + stale job reclaim
Harden the Hub-side polling protocol for on-prem TapTrack agents:
- sync/poll: accept job_failed_ids (retry/fail on-prem delivery failures)
- sync/poll: deduct credits + write ledger when on-prem reports sent jobs
- sync/poll: return feature_flags (tier-based) + suspended flag in config
- sync/poll: skip job dispatch for suspended/expired schools
- sms_jobs: add delivered_via (pull|push) + processing_started_at columns
- tasks/sms: new sms.reclaim_stale_jobs task resets processing→pending if on-prem
  goes offline (jobs stuck >5 min), enabling Celery push fallback
- tasks/sms: tag Celery-sent jobs as delivered_via='push'
- worker: schedule reclaim_stale_jobs every 5 minutes
- migration: 001_phase5 adds delivered_via + processing_started_at to sms_jobs
2026-03-16 12:30:37 +08:00

53 lines
2.9 KiB
Python

import uuid
import enum
from datetime import datetime, timezone
from sqlalchemy import String, Boolean, DateTime, Enum as SAEnum, ForeignKey, Text, Numeric, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class SmsJobStatus(str, enum.Enum):
pending = "pending"
processing = "processing"
sent = "sent"
failed = "failed"
cancelled = "cancelled"
class SmsCreditTx(str, enum.Enum):
topup = "topup"
deduct = "deduct"
refund = "refund"
adjustment = "adjustment"
class SmsJob(Base):
__tablename__ = "sms_jobs"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
school_id: Mapped[str] = mapped_column(String(36), ForeignKey("schools.id", ondelete="CASCADE"), nullable=False, index=True)
recipient_phone: Mapped[str] = mapped_column(String(20), nullable=False)
message: Mapped[str] = mapped_column(Text, nullable=False)
sender_name: Mapped[str] = mapped_column(String(11), nullable=False)
status: Mapped[SmsJobStatus] = mapped_column(SAEnum(SmsJobStatus), default=SmsJobStatus.pending, nullable=False, index=True)
trigger: Mapped[str | None] = mapped_column(String(50), nullable=True) # "absent", "late", "manual"
semaphore_message_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
retry_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
delivered_via: Mapped[str | None] = mapped_column(String(10), nullable=True) # "pull" | "push"
processing_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
school: Mapped["School"] = relationship("School", back_populates="sms_jobs")
class SmsCreditLedger(Base):
__tablename__ = "sms_credit_ledger"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
school_id: Mapped[str] = mapped_column(String(36), ForeignKey("schools.id", ondelete="CASCADE"), nullable=False, index=True)
tx_type: Mapped[SmsCreditTx] = mapped_column(SAEnum(SmsCreditTx), nullable=False)
amount: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
balance_after: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
reference_id: Mapped[str | None] = mapped_column(String(36), nullable=True) # invoice_id or sms_job_id
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
created_by: Mapped[str | None] = mapped_column(String(36), nullable=True) # hub_user_id