Address review findings on the title-clamp: TITLE_MAX_LEN now reads the length straight from Notification.title so the clamp can't drift from the column a migration would change (single source of truth). Add a db-lane test that actually commits a >255-char title into Postgres (end-to-end proof the truncation is gone, not just the clamp logic) and one that asserts the constant tracks the column width.
69 lines
2.7 KiB
Python
69 lines
2.7 KiB
Python
"""Helpers for the durable, server-backed per-user notification inbox (phase 1).
|
|
|
|
The inbox COEXISTS with the client-side transient "bell" (localStorage toasts): these rows
|
|
survive reloads/devices and are produced server-side. First producer is the maintenance
|
|
job. Unread rows never auto-expire; read rows are trimmed past a soft per-user cap so the
|
|
table can't grow without bound for a heavy user.
|
|
"""
|
|
from sqlalchemy import delete, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.models import Notification
|
|
|
|
# Keep at most this many READ notifications per user; older read ones are pruned. Unread
|
|
# rows are never pruned (the user hasn't seen them yet).
|
|
READ_SOFT_CAP = 200
|
|
|
|
# Hard limit of the Notification.title column, read straight from the model so the clamp
|
|
# can never drift from the actual column width (a later migration is the single source of
|
|
# truth). Titles are only an English fallback — the client renders the translated message
|
|
# from `data` — so clamping an over-long one (e.g. a FB/IG reel whose "title" is the whole
|
|
# caption, hundreds of chars) is harmless to the UI and prevents a StringDataRightTruncation
|
|
# that would abort the caller's whole transaction (the batched download_gc run in particular).
|
|
TITLE_MAX_LEN = Notification.__table__.c.title.type.length
|
|
|
|
|
|
def create_notification(
|
|
db: Session,
|
|
user_id: int,
|
|
type: str,
|
|
title: str,
|
|
body: str | None = None,
|
|
data: dict | None = None,
|
|
*,
|
|
commit: bool = True,
|
|
) -> Notification:
|
|
"""Insert one notification for a user. Callers that batch many inserts in a single
|
|
transaction can pass commit=False and commit once themselves."""
|
|
if title is not None and len(title) > TITLE_MAX_LEN:
|
|
title = title[: TITLE_MAX_LEN - 1] + "…" # clamp to the column limit, add an ellipsis
|
|
notif = Notification(
|
|
user_id=user_id, type=type, title=title, body=body, data=data
|
|
)
|
|
db.add(notif)
|
|
if commit:
|
|
db.commit()
|
|
db.refresh(notif)
|
|
return notif
|
|
|
|
|
|
def trim_read(db: Session, user_id: int, cap: int = READ_SOFT_CAP) -> int:
|
|
"""Delete a user's oldest READ notifications beyond `cap`. Returns the number removed.
|
|
Idempotent and cheap; safe to call after marking things read."""
|
|
ids = (
|
|
db.execute(
|
|
select(Notification.id)
|
|
.where(Notification.user_id == user_id, Notification.read.is_(True))
|
|
.order_by(Notification.created_at.desc())
|
|
.offset(cap)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
if not ids:
|
|
return 0
|
|
# One bulk DELETE rather than a get+delete per row (the ids are already in hand).
|
|
db.execute(delete(Notification).where(Notification.id.in_(ids)))
|
|
db.commit()
|
|
return len(ids)
|