""" app/models/user_notification_matrix.py -------------------------------------- Per-account notification overrides (phase51). The global NotificationMatrix routes an event to whole ROLES: "every Customer Director hears about issue_created". That is the wrong grain for customers — each customer organisation states on its enrollment form which notifications each of its people wants, and two directors on two contracts rarely want the same set. This table is the per-account layer on top. One row = one account's explicit answer for one event: enabled=True send it to this account even if the global column is OFF enabled=False do not send it to this account even if the global column is ON NO ROW inherit — whatever the global matrix column says Inheritance is the default and the safe state: an account with no rows behaves exactly as it did before this table existed, so the feature ships without changing routing for anyone. Setting a row back to "inherit" DELETES it rather than storing a copy of the current global value, so a later change to the global matrix still reaches accounts that never expressed an opinion. Scope: consulted for the two customer-side roles only (User.CUSTOMER_ROLES). Staff roles keep using the global matrix alone — an admin who wants fewer emails uses NotificationPreference, which is a different question (how to deliver, not whether to route). """ from app import db class UserNotificationMatrix(db.Model): """One account's override of the global matrix for one event.""" __tablename__ = 'user_notification_matrix' id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True) event_type = db.Column(db.String(50), nullable=False) enabled = db.Column(db.Boolean, nullable=False, default=True) user = db.relationship('User', foreign_keys=[user_id], backref=db.backref('notification_overrides', lazy='dynamic', cascade='all, delete-orphan')) __table_args__ = ( db.UniqueConstraint('user_id', 'event_type', name='uq_user_notif_matrix_user_event'), ) def __repr__(self): return (f'') def overrides_for_user(user_id) -> dict: """Return {event_type: bool} — every override this account has set.""" return { row.event_type: row.enabled for row in UserNotificationMatrix.query.filter_by(user_id=user_id).all() } def override_for(user_id, event_type): """One account's answer for one event: True, False, or None (inherit). Used by notify() to enforce the override on EVERY delivery path, not just matrix broadcasts. Best-effort: any failure returns None (inherit), so a lookup problem can never silently swallow a notification. """ import logging if not user_id or not event_type: return None try: row = UserNotificationMatrix.query.filter_by( user_id=user_id, event_type=event_type).first() return row.enabled if row is not None else None except Exception as exc: logging.getLogger(__name__).error( 'USER MATRIX | single override lookup failed | user=%s event=%s | %s', user_id, event_type, exc, ) return None def overrides_for_event(event_type) -> dict: """Return {user_id: bool} — every account's override for one event. One query per dispatch rather than one per candidate recipient. The table holds only explicitly-set rows (inherit deletes), so it stays small. Best-effort: a failure here must never take down a notification dispatch, so callers get an empty dict (= everyone inherits) if the query fails. """ import logging try: return { row.user_id: row.enabled for row in UserNotificationMatrix.query.filter_by( event_type=event_type).all() } except Exception as exc: logging.getLogger(__name__).error( 'USER MATRIX | override lookup failed | event=%s | error=%s', event_type, exc, ) return {} def set_overrides(user_id, values: dict): """Replace an account's overrides. `values` maps event_type -> True / False / None, where None means inherit (the row is deleted). Events absent from `values` are left untouched, so a caller can update one event without resending the whole matrix. Does NOT commit — the caller owns the transaction (same contract as notify()). Returns the number of rows added, updated or deleted. """ existing = { row.event_type: row for row in UserNotificationMatrix.query.filter_by(user_id=user_id).all() } changed = 0 for event_type, wanted in values.items(): row = existing.get(event_type) if wanted is None: if row is not None: db.session.delete(row) changed += 1 continue wanted = bool(wanted) if row is None: db.session.add(UserNotificationMatrix( user_id=user_id, event_type=event_type, enabled=wanted)) changed += 1 elif row.enabled != wanted: row.enabled = wanted changed += 1 return changed