"""FlowDeck — Notification service (v4.9.0 collaboration). Creates in-app notifications (mentions, comments, page changes) and triggers email delivery via :mod:`app.services.mailer` when the target user has opted in. """ from __future__ import annotations import json import re from app.db import get_conn from app.services import mailer def create_notification( user_id: int, actor_id: int | None, ntype: str, title: str, message: str, resource_type: str = "page", resource_id: int = 0, url: str = "", conn=None, commit: bool = True, ) -> int | None: """Insert a notification row. Returns the new id (or None if skipped). ``conn`` may be supplied to join an existing transaction (caller controls commit). Otherwise a dedicated connection is opened and committed. """ if not user_id: return None if conn is not None: cur = conn.execute( """INSERT INTO notifications (user_id, actor_id, ntype, title, message, resource_type, resource_id, url) VALUES (?,?,?,?,?,?,?,?)""", (user_id, actor_id, ntype, title, message, resource_type, resource_id, url), ) if commit: conn.commit() return cur.lastrowid with get_conn() as conn: cur = conn.execute( """INSERT INTO notifications (user_id, actor_id, ntype, title, message, resource_type, resource_id, url) VALUES (?,?,?,?,?,?,?,?)""", (user_id, actor_id, ntype, title, message, resource_type, resource_id, url), ) conn.commit() return cur.lastrowid _MENTION_RE = re.compile(r"(?:^|\s)@([\w\-\.]+)") def extract_mentions(text: str) -> list[str]: """Return the set of @login handles mentioned in ``text`` (lowercase).""" return list(dict.fromkeys(m.lower() for m in _MENTION_RE.findall(text or ""))) def process_mentions( text: str, actor_id: int, ntype: str, title: str, message: str, resource_type: str = "page", resource_id: int = 0, url: str = "", conn=None, ) -> list[int]: """Create notifications for every user @-mentioned in ``text``. Returns the list of mentioned user ids that were notified. """ handles = extract_mentions(text) if not handles: return [] notified = [] if conn is not None: _do_mentions(conn, handles, actor_id, ntype, title, message, resource_type, resource_id, url, notified) return notified with get_conn() as conn: _do_mentions(conn, handles, actor_id, ntype, title, message, resource_type, resource_id, url, notified) conn.commit() return notified def _do_mentions(conn, handles, actor_id, ntype, title, message, resource_type, resource_id, url, notified): placeholders = ",".join("?" * len(handles)) rows = conn.execute( f"SELECT id, login, email FROM users WHERE lower(login) IN ({placeholders})", handles, ).fetchall() for row in rows: if row["id"] == actor_id: continue create_notification( row["id"], actor_id, ntype, title, message, resource_type, resource_id, url, conn=conn, commit=False, ) notified.append(row["id"]) mailer.notify_user( row["id"], subject=title, body_text=message, cta_url=url or "", ) def get_user_prefs(user_id: int, conn=None) -> dict: if conn is not None: row = conn.execute( "SELECT notification_prefs FROM users WHERE id=?", (user_id,) ).fetchone() else: with get_conn() as conn: row = conn.execute( "SELECT notification_prefs FROM users WHERE id=?", (user_id,) ).fetchone() if not row or not row["notification_prefs"]: return {"comments": True, "mentions": True, "reminders": True, "assignments": True} try: prefs = json.loads(row["notification_prefs"]) except (TypeError, json.JSONDecodeError): prefs = {} return {"comments": bool(prefs.get("comments", True)), "mentions": bool(prefs.get("mentions", True)), "reminders": bool(prefs.get("reminders", True)), "assignments": bool(prefs.get("assignments", True))} def set_user_prefs(user_id: int, prefs: dict, conn=None) -> dict: if conn is not None: conn.execute( "UPDATE users SET notification_prefs=? WHERE id=?", (json.dumps(prefs), user_id), ) conn.commit() else: with get_conn() as conn: conn.execute( "UPDATE users SET notification_prefs=? WHERE id=?", (json.dumps(prefs), user_id), ) conn.commit() return prefs def _person_ids(value) -> list[int]: """Extract user ids from a stored ``person`` property value.""" ids: list[int] = [] if isinstance(value, dict): value = [value] if isinstance(value, list): for person in value: if isinstance(person, dict) and person.get("id"): try: ids.append(int(person["id"])) except (TypeError, ValueError): pass return ids def notify_assignment(collection_id: int, page_id: int, page_title: str, old_props: dict, new_props: dict, actor_id: int | None, conn=None) -> list[int]: """Notify users newly assigned via a ``person`` property. Compares old vs new property values keyed by property id; users present in the new value but not the old one get an in-app + (opt-in) email notification. Returns the notified user ids. """ def _run(c): props = c.execute( "SELECT id, name FROM collection_properties " "WHERE collection_id=? AND prop_type='person'", (collection_id,), ).fetchall() newly: list[int] = [] for p in props: key = str(p["id"]) before = set(_person_ids((old_props or {}).get(key))) after = _person_ids((new_props or {}).get(key)) for uid in after: if uid not in before and uid != actor_id and uid not in newly: newly.append(uid) for uid in newly: create_notification( uid, actor_id, "assignment", title="Assigned to you", message=page_title or "Untitled", resource_type="db_page", resource_id=page_id, url=f"/collections/{collection_id}", conn=c, commit=False, ) mailer.notify_user( uid, subject="[FlowDeck] Assigned to you", body_text=page_title or "Untitled", cta_url=f"/collections/{collection_id}", prefs_key="assignments", ) return newly if conn is not None: return _run(conn) with get_conn() as c: result = _run(c) c.commit() return result