feat: add account-scoped in-app reminder center

This commit is contained in:
leefer
2026-07-23 00:06:33 +08:00
parent 5ae2791dd3
commit a2a5db8f6a
8 changed files with 608 additions and 0 deletions
+124
View File
@@ -277,6 +277,26 @@ class ReviewDatabase:
CREATE INDEX IF NOT EXISTS idx_strategy_tracks_user_run
ON strategy_tracks(user_id, run_id DESC, id);
CREATE TABLE IF NOT EXISTS alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
kind TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
available_date TEXT NOT NULL,
code TEXT NOT NULL DEFAULT '',
dedupe_key TEXT NOT NULL,
is_read INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
read_at TEXT,
UNIQUE(user_id, dedupe_key),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_alerts_user_due
ON alerts(user_id, available_date, is_read, id DESC);
"""
)
user_columns = {
@@ -1426,6 +1446,110 @@ class ReviewDatabase:
for ts_code, selection_date in unique_targets
}
def save_alert(
self,
user_id: int,
kind: str,
title: str,
content: str,
available_date: str,
code: str,
dedupe_key: str,
) -> int:
now = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
connection.execute(
"""
INSERT INTO alerts
(user_id, kind, title, content, available_date, code, dedupe_key,
is_read, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
ON CONFLICT(user_id, dedupe_key) DO UPDATE SET
title=excluded.title, content=excluded.content,
available_date=excluded.available_date, updated_at=excluded.updated_at
""",
(
int(user_id), kind, title, content, available_date, code,
dedupe_key, now, now,
),
)
row = connection.execute(
"SELECT id FROM alerts WHERE user_id = ? AND dedupe_key = ?",
(int(user_id), dedupe_key),
).fetchone()
return int(row["id"])
def list_alerts(
self, user_id: int, as_of: str, unread_only: bool = False, limit: int = 100
) -> list[dict[str, Any]]:
with self.connect() as connection:
if unread_only:
rows = connection.execute(
"""
SELECT id, kind, title, content, available_date, code, is_read,
created_at, updated_at, read_at
FROM alerts
WHERE user_id = ? AND available_date <= ? AND is_read = 0
ORDER BY available_date DESC, id DESC LIMIT ?
""",
(int(user_id), as_of, max(1, min(300, int(limit)))),
).fetchall()
else:
rows = connection.execute(
"""
SELECT id, kind, title, content, available_date, code, is_read,
created_at, updated_at, read_at
FROM alerts WHERE user_id = ?
ORDER BY CASE WHEN available_date > ? THEN 0 ELSE 1 END,
is_read, available_date, id DESC LIMIT ?
""",
(int(user_id), as_of, max(1, min(300, int(limit)))),
).fetchall()
return [{**dict(row), "is_read": bool(row["is_read"])} for row in rows]
def count_unread_alerts(self, user_id: int, as_of: str) -> int:
with self.connect() as connection:
row = connection.execute(
"""
SELECT COUNT(*) AS total FROM alerts
WHERE user_id = ? AND available_date <= ? AND is_read = 0
""",
(int(user_id), as_of),
).fetchone()
return int(row["total"] if row else 0)
def mark_alert_read(self, user_id: int, alert_id: int) -> bool:
now = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
cursor = connection.execute(
"""
UPDATE alerts SET is_read = 1, read_at = ?, updated_at = ?
WHERE id = ? AND user_id = ?
""",
(now, now, int(alert_id), int(user_id)),
)
return cursor.rowcount > 0
def mark_all_alerts_read(self, user_id: int, as_of: str) -> int:
now = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
cursor = connection.execute(
"""
UPDATE alerts SET is_read = 1, read_at = ?, updated_at = ?
WHERE user_id = ? AND available_date <= ? AND is_read = 0
""",
(now, now, int(user_id), as_of),
)
return int(cursor.rowcount)
def delete_alert(self, user_id: int, alert_id: int) -> bool:
with self.connect() as connection:
cursor = connection.execute(
"DELETE FROM alerts WHERE id = ? AND user_id = ?",
(int(alert_id), int(user_id)),
)
return cursor.rowcount > 0
def start_sync(self, trade_date: str, source: str) -> int:
started_at = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection: