Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions backend/src/apis/app_api/admin/announcements/routes.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
"""Admin API routes for feature announcements.

Authoring, scheduling, and lifecycle for the notices users see. There is no
user-facing counterpart yet — PR-2 adds ``GET /announcements`` and the ack
endpoint. Until then this surface ships dark: admins can author drafts and
nothing renders anywhere.
Authoring, scheduling, and lifecycle for the notices users see, plus the
reach numbers for one. The user-facing counterpart is ``GET /announcements``
and its ack endpoint in ``apis/app_api/announcements/``.

See ``docs/specs/feature-announcements.md`` §6.
"""
Expand All @@ -18,6 +17,7 @@
AnnouncementState,
AnnouncementListResponse,
AnnouncementResponse,
AnnouncementStatsResponse,
AnnouncementUpdate,
)
from apis.shared.announcements.service import get_announcements_service
Expand Down Expand Up @@ -202,3 +202,31 @@ async def delete_announcement(
deleted = await service.delete_announcement(announcement_id)
if not deleted:
raise _not_found(announcement_id)


@router.get(
"/{announcement_id}/stats",
response_model=AnnouncementStatsResponse,
summary="Reach for one announcement",
)
async def get_announcement_stats(
announcement_id: str,
admin_user: User = Depends(require_announcements_admin),
) -> AnnouncementStatsResponse:
"""Funnel counts for the announcement's **current** revision.

``seen``/``dismissed``/``acknowledged`` are cumulative, not disjoint — a
user who acknowledged is counted in all three, because the stored rank
only ever rises through them (§D2).

Every number is approximate and the UI must say so (§11): the counters are
incremented on a second write after the ack lands, and ``targeted`` is a
denominator that moves as people join and roles change. ``targeted`` is
null when the audience is role-scoped rather than everyone — that means
"not estimated", not zero.
"""
service = get_announcements_service()
stats = await service.get_stats(announcement_id)
if stats is None:
raise _not_found(announcement_id)
return stats
85 changes: 85 additions & 0 deletions backend/src/apis/shared/announcements/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@
ANNOUNCEMENT_SK_PREFIX = "ANNOUNCEMENT#"
ACK_SK_PREFIX = "ACK#"

#: ``targetRoles`` entry meaning "everyone" (§D9). A display filter, never a grant.
TARGET_EVERYONE = "*"

TITLE_MAX_LENGTH = 140
BODY_MAX_BYTES = 16 * 1024

Expand All @@ -58,6 +61,29 @@
ACK_TTL_AFTER_EXPIRY = timedelta(days=90)
ACK_TTL_OPEN_ENDED = timedelta(days=730)

#: Ack counters live as **top-level** attributes on the announcement item,
#: one per (revision, action) — ``ackCountsR1Seen`` and friends.
#:
#: Top-level rather than a nested ``ackCounts`` map for one reason:
#: DynamoDB's ``ADD`` only works on top-level attributes, and it creates the
#: attribute (treating a missing one as 0) in the same atomic write. A nested
#: map needs ``SET path = if_not_exists(path, :zero) + :one``, which raises
#: ValidationException until the parent map exists — so every announcement
#: authored before this shipped would need an init-then-retry path around
#: every ack. One atomic write with no fallback beats a tidier shape with a
#: repair branch on the hot path.
#:
#: Keyed by revision because "Show again" (§D4) is a deliberate re-broadcast:
#: rolling its acks into the previous revision's totals would silently inflate
#: them and make the numbers lie about the version people actually saw.
ACK_COUNT_ATTR_PREFIX = "ackCounts"


def ack_count_attr(revision: int, action: str) -> str:
"""Attribute name holding the count of users who reached ``action``."""
return f"{ACK_COUNT_ATTR_PREFIX}R{int(revision)}{action.capitalize()}"


_LOUD_SURFACES = frozenset({"banner", "modal"})


Expand Down Expand Up @@ -144,6 +170,20 @@ class Announcement:
cta_url: Optional[str] = None
revision: int = 1
created_by: Optional[str] = None
#: Ack funnel counters, carried through read → write.
#:
#: **Not domain state — a projection that must survive.** Every admin
#: mutation (``update_announcement``, ``set_state``, ``bump_revision``)
#: is a full ``put_item`` of this dataclass, so any attribute the model
#: does not know about is destroyed by it. Without this field, publishing
#: or archiving an announcement — or hitting "Show again" — would silently
#: zero every stat the feature exists to report.
#:
#: The read-modify-write does mean an ack landing in the same instant as
#: an admin edit can lose its increment. That is the documented cost of
#: approximate O(1) counters (§9); admin writes are rare, and the ack
#: itself is never at risk because it is a different item.
ack_counts: Dict[str, int] = field(default_factory=dict)

def to_dynamo_item(self) -> Dict[str, Any]:
item: Dict[str, Any] = {
Expand Down Expand Up @@ -173,6 +213,9 @@ def to_dynamo_item(self) -> Dict[str, Any]:
item["ctaUrl"] = self.cta_url
if self.created_by:
item["createdBy"] = self.created_by
# Carried forward verbatim so a full-item put cannot destroy them.
for attr, value in (self.ack_counts or {}).items():
item[attr] = int(value)
return item

@classmethod
Expand Down Expand Up @@ -205,6 +248,11 @@ def from_dynamo_item(cls, item: Dict[str, Any]) -> "Announcement":
created_at=created_at,
updated_at=updated_at,
created_by=item.get("createdBy"),
ack_counts={
key: int(value)
for key, value in item.items()
if key.startswith(ACK_COUNT_ATTR_PREFIX)
},
)

def ack_ttl(self, action: str) -> Optional[int]:
Expand Down Expand Up @@ -422,6 +470,43 @@ class AnnouncementListResponse(BaseModel):
total: int


class AnnouncementStatsResponse(BaseModel):
"""Reach for one announcement, at its **current** revision.

The three counts are a **funnel, not a partition**: a user who
acknowledged also counts as dismissed and as seen, because the stored rank
only ever rises through them (§D2). So ``seen >= dismissed >=
acknowledged`` always holds, and "how many only ever saw it" is
``seen - dismissed``. Reading them as disjoint buckets would understate
every stage.

Everything here is approximate by construction and must be labelled that
way in the UI (§11):

- the counts are incremented on a **second** write after the ack itself
lands, so a failure between the two under-counts by one. That is the
documented trade for O(1) stats with no GSI and no scan.
- ``targeted`` is a denominator that moves as people join and roles
change. **Do not build compliance reporting on it.**
- **Nothing is backfilled.** The counters are incremented by the ack write
path, so acks recorded before this shipped are invisible here — an
existing environment starts every announcement at zero on deploy day
even where people have already read and dismissed it. The ack rows
themselves are intact; only the tallies begin at the deploy. There is no
cheap repair for this (counting the existing rows is the scan the design
exists to avoid), so read early numbers as "reach since stats shipped".
"""

announcement_id: str
revision: int
seen: int
dismissed: int
acknowledged: int
#: Active users this announcement is aimed at, or None when the audience
#: cannot be counted — see ``AnnouncementsService.get_stats``.
targeted: Optional[int] = None


# =============================================================================
# User-facing response models
#
Expand Down
112 changes: 110 additions & 2 deletions backend/src/apis/shared/announcements/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import logging
import os
import uuid
from typing import List, Optional
from typing import Dict, List, Optional

import boto3
from botocore.exceptions import ClientError
Expand All @@ -23,12 +23,14 @@

from .models import (
ACK_SK_PREFIX,
ACTION_RANKS,
ANNOUNCEMENT_SK_PREFIX,
ANNOUNCEMENTS_PK,
Announcement,
AnnouncementAck,
AnnouncementCreate,
AnnouncementUpdate,
ack_count_attr,
action_rank,
validate_announcement_invariants,
)
Expand Down Expand Up @@ -318,7 +320,7 @@ async def record_ack(
expression_values[":ttl"] = int(ttl)

try:
self._table.update_item(
response = self._table.update_item(
Key={
"PK": AnnouncementAck.partition_key(user_id),
"SK": AnnouncementAck.sort_key(announcement_id, revision),
Expand All @@ -329,6 +331,11 @@ async def record_ack(
),
ExpressionAttributeNames=names,
ExpressionAttributeValues=expression_values,
# The rank this user held *before* this write. Absent when the
# item is new, which reads as 0. It is what makes the counters
# count users rather than clicks: without it a `seen` followed
# by a `dismissed` would add two to the seen total.
ReturnValues="UPDATED_OLD",
)
except ClientError as e:
if e.response.get("Error", {}).get("Code") == "ConditionalCheckFailedException":
Expand All @@ -344,8 +351,109 @@ async def record_ack(
return False
logger.error("Error recording announcement ack", exc_info=True)
raise

previous_rank = int(response.get("Attributes", {}).get("actionRank", 0))
self._increment_ack_counts(
announcement_id=announcement_id,
revision=revision,
from_rank=previous_rank,
to_rank=rank,
)
return True

def _increment_ack_counts(
self,
*,
announcement_id: str,
revision: int,
from_rank: int,
to_rank: int,
) -> None:
"""Bump the funnel counters for every rank this write crossed.

A user landing straight on ``acknowledged`` crosses all three, so all
three rise; one already at ``dismissed`` crosses only the third. That
is what keeps ``seen >= dismissed >= acknowledged`` true without ever
reading the counters back.

**Best-effort on purpose.** This is a second write after the ack has
already been durably recorded, and the ack is the record that matters
(§D2/§D3). A failure here is logged and swallowed: an under-counted
stat is a worse dashboard, while raising would turn a successful
acknowledgement into a 500 and lose the user's click. The spec asks
for exactly this trade — approximate, O(1), no GSI, no scan.
"""
crossed = [
action
for action, action_value in ACTION_RANKS.items()
if from_rank < action_value <= to_rank
]
if not crossed:
return

names = {}
values = {":one": 1}
clauses = []
for index, action in enumerate(crossed):
alias = f"#c{index}"
names[alias] = ack_count_attr(revision, action)
clauses.append(f"{alias} :one")

try:
# ADD, not SET: it creates a missing attribute as 0 in the same
# atomic write, so announcements authored before stats shipped
# need no backfill and no init-then-retry branch.
self._table.update_item(
Key={
"PK": ANNOUNCEMENTS_PK,
"SK": f"{ANNOUNCEMENT_SK_PREFIX}{announcement_id}",
},
UpdateExpression="ADD " + ", ".join(clauses),
ExpressionAttributeNames=names,
ExpressionAttributeValues=values,
)
except ClientError:
logger.warning(
"Ack recorded but its stats counters were not incremented: "
"announcement=%s revision=%s crossed=%s",
announcement_id,
revision,
crossed,
exc_info=True,
)

async def get_ack_counts(
self, announcement_id: str, revision: int
) -> Dict[str, int]:
"""Funnel counts for one revision, zero-filled.

Reads the announcement item itself — the counters live on it, which is
the whole point of the design: no GSI on ``announcementId`` and no
scan of the ack partitions.
"""
zero = {action: 0 for action in ACTION_RANKS}
if not self._enabled:
return zero

try:
response = self._table.get_item(
Key={
"PK": ANNOUNCEMENTS_PK,
"SK": f"{ANNOUNCEMENT_SK_PREFIX}{announcement_id}",
}
)
except ClientError:
logger.error("Error reading announcement ack counts", exc_info=True)
raise

item = response.get("Item")
if not item:
return zero
return {
action: int(item.get(ack_count_attr(revision, action), 0))
for action in ACTION_RANKS
}

async def list_acks(self, user_id: str) -> List[AnnouncementAck]:
"""Every ack this user has ever written, across revisions."""
if not self._enabled:
Expand Down
Loading