"""CRUD + read service for policy notices (制度中心 / 制度公告). Two public reads, both available to any signed-in user: * ``list_banner`` — the curated header-ribbon subset (``pin_to_banner AND is_published``). * ``list_library`` — the employee-facing searchable library (``is_published``), with optional free-text ``q`` filtering. Every mutation: * Validates input (schema validators trim / cap; service enforces the ``pin_to_banner ⟹ is_published`` invariant). * Updates ``display_order`` on create so new entries land at the end. * Logs to ``audit_logs`` with action ``platform.policy_notice.``. """ from __future__ import annotations import uuid from datetime import UTC, datetime from sqlalchemy import asc, select from sqlalchemy.ext.asyncio import AsyncSession from app.audit.service import AuditService from app.core.exceptions import AppException, NotFoundError from app.domains.policy_notices.models import PlatformPolicyNotice from app.domains.policy_notices.schemas import ( PolicyNoticeCreate, PolicyNoticeUpdate, ) # The library is a small curated set (company policies — tens, not # thousands), so we fetch the published rows and match the query in # Python. This keeps search correct + identical across MySQL / TiDB # without fighting JSON-column LIKE portability. Revisit with a SQL # index only if the set grows past a few hundred rows. _LIBRARY_SOFT_CAP = 500 def _matches(notice: PlatformPolicyNotice, tokens: list[str]) -> bool: """True when EVERY query token appears (case-insensitive substring) in the entry's searchable text: both titles, both summaries, and the joined keyword tags. AND semantics narrow as the user types more.""" haystack = " ".join( part.lower() for part in ( notice.title_zh, notice.title_en, notice.summary_zh or "", notice.summary_en or "", " ".join(notice.keywords or []), ) ) return all(tok in haystack for tok in tokens) class PolicyNoticesService: def __init__(self, db: AsyncSession): self.db = db self.audit = AuditService(db) # ── Public reads ──────────────────────────────────────────── async def list_banner(self) -> list[PlatformPolicyNotice]: """Header-ribbon subset: published AND pinned, ordered by ``display_order`` ASC with a stable ``created_at`` tiebreak.""" rows = (await self.db.execute( select(PlatformPolicyNotice) .where( PlatformPolicyNotice.is_published.is_(True), PlatformPolicyNotice.pin_to_banner.is_(True), ) .order_by( asc(PlatformPolicyNotice.display_order), asc(PlatformPolicyNotice.created_at), ) )).scalars().all() return list(rows) async def list_library( self, q: str | None = None, ) -> list[PlatformPolicyNotice]: """Employee 制度中心: all published entries, optionally filtered by a free-text query (AND over whitespace-split tokens, matched against title / summary / keywords in either language).""" rows = (await self.db.execute( select(PlatformPolicyNotice) .where(PlatformPolicyNotice.is_published.is_(True)) .order_by( asc(PlatformPolicyNotice.display_order), asc(PlatformPolicyNotice.created_at), ) .limit(_LIBRARY_SOFT_CAP) )).scalars().all() tokens = [t for t in (q or "").lower().split() if t] if not tokens: return list(rows) return [r for r in rows if _matches(r, tokens)] # ── Admin read ────────────────────────────────────────────── async def list_all(self) -> list[PlatformPolicyNotice]: """All entries (published + unpublished) for the admin management page, ordered the same way the public reads are.""" rows = (await self.db.execute( select(PlatformPolicyNotice).order_by( asc(PlatformPolicyNotice.display_order), asc(PlatformPolicyNotice.created_at), ) )).scalars().all() return list(rows) # ── Admin mutations ──────────────────────────────────────── async def create( self, body: PolicyNoticeCreate, *, actor_id: uuid.UUID, trace_id: str | uuid.UUID, ) -> PlatformPolicyNotice: # Place the new entry at the end so the existing order isn't # disturbed. ``MAX(display_order) + 1`` over an empty table is # NULL → coerce to -1 so the new row lands at 0. max_order = (await self.db.execute( select(PlatformPolicyNotice.display_order) .order_by(PlatformPolicyNotice.display_order.desc()) .limit(1) )).scalar() new_order = (max_order if max_order is not None else -1) + 1 # Invariant: a notice can't rotate in the banner unless it's # published in the library. pin = body.pin_to_banner and body.is_published now = datetime.now(UTC) row = PlatformPolicyNotice( id=uuid.uuid4(), title_zh=body.title_zh.strip(), title_en=body.title_en.strip(), summary_zh=(body.summary_zh or "").strip() or None, summary_en=(body.summary_en or "").strip() or None, keywords=body.keywords, feishu_url=body.feishu_url.strip(), is_published=body.is_published, pin_to_banner=pin, display_order=new_order, created_at=now, updated_at=now, created_by_person_id=actor_id, updated_by_person_id=actor_id, ) self.db.add(row) await self.db.flush() await self.audit.log( trace_id=trace_id, actor_type="user", actor_user_id=actor_id, action="platform.policy_notice.create", status="success", resource_type="platform_policy_notice", resource_id=str(row.id), detail=self._snapshot(row), ) return row async def update( self, notice_id: uuid.UUID, body: PolicyNoticeUpdate, *, actor_id: uuid.UUID, trace_id: str | uuid.UUID, ) -> PlatformPolicyNotice: row = await self.db.get(PlatformPolicyNotice, notice_id) if row is None: raise NotFoundError("policy notice", str(notice_id)) before = self._snapshot(row) changed = False if body.title_zh is not None: cleaned = body.title_zh.strip() if not cleaned: raise AppException( code="POLICY_NOTICE_TITLE_EMPTY", message="title_zh cannot be empty after trimming", status_code=400, ) if cleaned != row.title_zh: row.title_zh = cleaned changed = True if body.title_en is not None: cleaned = body.title_en.strip() if not cleaned: raise AppException( code="POLICY_NOTICE_TITLE_EMPTY", message="title_en cannot be empty after trimming", status_code=400, ) if cleaned != row.title_en: row.title_en = cleaned changed = True if body.summary_zh is not None: new_val = body.summary_zh.strip() or None if new_val != row.summary_zh: row.summary_zh = new_val changed = True if body.summary_en is not None: new_val = body.summary_en.strip() or None if new_val != row.summary_en: row.summary_en = new_val changed = True if body.keywords is not None and body.keywords != (row.keywords or []): row.keywords = body.keywords changed = True if body.feishu_url is not None: cleaned = body.feishu_url.strip() if cleaned != row.feishu_url: row.feishu_url = cleaned changed = True if body.is_published is not None and body.is_published != row.is_published: row.is_published = body.is_published changed = True if body.pin_to_banner is not None and body.pin_to_banner != row.pin_to_banner: row.pin_to_banner = body.pin_to_banner changed = True # Re-assert the invariant against the FINAL state: unpublishing an # entry (in this same request or a prior one) auto-unpins it. if not row.is_published and row.pin_to_banner: row.pin_to_banner = False changed = True if not changed: # No-op update — return unchanged. Don't spam the audit log # with empty diffs, but DON'T raise; the admin UI may save # without changes after a network retry. return row row.updated_at = datetime.now(UTC) row.updated_by_person_id = actor_id await self.db.flush() await self.audit.log( trace_id=trace_id, actor_type="user", actor_user_id=actor_id, action="platform.policy_notice.update", status="success", resource_type="platform_policy_notice", resource_id=str(row.id), detail={"before": before, "after": self._snapshot(row)}, ) return row async def delete( self, notice_id: uuid.UUID, *, actor_id: uuid.UUID, trace_id: str | uuid.UUID, ) -> None: row = await self.db.get(PlatformPolicyNotice, notice_id) if row is None: raise NotFoundError("policy notice", str(notice_id)) snapshot = self._snapshot(row) snapshot["display_order"] = row.display_order await self.db.delete(row) await self.db.flush() await self.audit.log( trace_id=trace_id, actor_type="user", actor_user_id=actor_id, action="platform.policy_notice.delete", status="success", resource_type="platform_policy_notice", resource_id=str(notice_id), detail=snapshot, ) async def reorder( self, ordered_ids: list[uuid.UUID], *, actor_id: uuid.UUID, trace_id: str | uuid.UUID, ) -> list[PlatformPolicyNotice]: """Apply a new display order. Ids in ``ordered_ids`` get positions 0..n-1; rows not in the list are pushed to the end in their existing relative sequence so a stale client (that missed a concurrent insert) doesn't accidentally clobber the new row's order. """ if not ordered_ids: raise AppException( code="POLICY_NOTICE_REORDER_EMPTY", message="ordered_ids must not be empty", status_code=400, ) # Reject duplicates — ambiguous intent and would leave one of the # rows at the wrong position depending on dict iteration. if len(set(ordered_ids)) != len(ordered_ids): raise AppException( code="POLICY_NOTICE_REORDER_DUPLICATE", message="ordered_ids must be unique", status_code=400, ) # Lock the rows for the read-modify-write so two admins reordering # concurrently can't lost-update each other's display_order. existing = (await self.db.execute( select(PlatformPolicyNotice).with_for_update() )).scalars().all() by_id = {r.id: r for r in existing} # Verify every id in the list refers to a real row. Silently # skipping unknown ids would mask client/server desync bugs. unknown = [i for i in ordered_ids if i not in by_id] if unknown: raise AppException( code="POLICY_NOTICE_REORDER_UNKNOWN_ID", message=f"unknown notice ids in reorder: {unknown}", status_code=400, ) # Apply positions to the supplied subset. for idx, notice_id in enumerate(ordered_ids): by_id[notice_id].display_order = idx # Push any rows the client didn't include to the end, in their # existing relative order. tail_start = len(ordered_ids) leftover = sorted( (r for r in existing if r.id not in set(ordered_ids)), key=lambda r: (r.display_order, r.created_at), ) for offset, row in enumerate(leftover): row.display_order = tail_start + offset now = datetime.now(UTC) for r in existing: r.updated_at = now r.updated_by_person_id = actor_id await self.db.flush() await self.audit.log( trace_id=trace_id, actor_type="user", actor_user_id=actor_id, action="platform.policy_notice.reorder", status="success", resource_type="platform_policy_notice", resource_id=None, detail={"ordered_ids": [str(i) for i in ordered_ids]}, ) return await self.list_all() @staticmethod def _snapshot(row: PlatformPolicyNotice) -> dict: return { "title_zh": row.title_zh, "title_en": row.title_en, "summary_zh": row.summary_zh, "summary_en": row.summary_en, "keywords": list(row.keywords or []), "feishu_url": row.feishu_url, "is_published": row.is_published, "pin_to_banner": row.pin_to_banner, }