diff --git a/features.py b/features.py
index cb5ae57..25dd75e 100644
--- a/features.py
+++ b/features.py
@@ -1,18 +1,15 @@
# features.py
import json
-import mimetypes
import re
-import secrets
import time
from datetime import timedelta
from pathlib import Path
from typing import Optional
from flask import abort, jsonify, send_from_directory
-
import markdown
-from sanitizer import sanitize_html_fragment
+from sanitizer import sanitize_html_fragment
IMAGE_MIME_PREFIX = "image/"
AUDIO_MIME_PREFIX = "audio/"
@@ -33,9 +30,12 @@ THEME_MODES = {"dark", "light"}
PAGE_VISIBILITIES = {"team", "public"}
PAGE_TYPES = {"project", "document", "update", "resource"}
TEAM_MEMBER_ROLES = {"owner", "admin", "member"}
-
+PROFILE_VISIBILITIES = {"public", "members", "private"}
+LAST_ACTIVE_VISIBILITIES = {"everyone", "members", "nobody"}
+PRESENCE_MODES = {"online", "silent", "focus", "dnd"}
REACTION_EMOJI_RE = re.compile(r"^.{1,16}$")
HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
+PAGE_LINK_RE = re.compile(r"\]\((?:/teams/[^/]+/pages/)?([a-z0-9-]{1,48})\)", re.IGNORECASE)
def valid_hex_color(value: object) -> bool:
@@ -65,16 +65,27 @@ def render_markdown_to_safe_html(text: str) -> str:
def safe_profile_bio_html(html_module, bio: str, bio_mode: str) -> str:
mode = bio_mode if bio_mode in PROFILE_BIO_MODES else "txt"
-
if mode == "html":
return sanitize_html_fragment(bio or "")
-
if mode == "md":
return render_markdown_to_safe_html(bio or "")
-
return "
" + html_module.escape(bio or "").replace("\n", "
") + "
"
+def extract_page_reference_slugs(markdown_text: str) -> list[str]:
+ text = str(markdown_text or "")
+ matches = PAGE_LINK_RE.findall(text)
+ seen = set()
+ slugs = []
+ for slug in matches:
+ normalized = slug.lower().strip()
+ if not normalized or normalized in seen:
+ continue
+ seen.add(normalized)
+ slugs.append(normalized)
+ return slugs
+
+
def attachment_kind_from_name_and_mime(
original_name: Optional[str],
mime_type: Optional[str],
@@ -82,7 +93,6 @@ def attachment_kind_from_name_and_mime(
mime_type = str(mime_type or "").lower()
name = str(original_name or "")
ext = name.rsplit(".", 1)[-1].lower() if "." in name else ""
-
if mime_type.startswith(IMAGE_MIME_PREFIX) or ext in IMAGE_EXTENSIONS:
return "image"
if mime_type.startswith(AUDIO_MIME_PREFIX) or ext in AUDIO_EXTENSIONS:
@@ -90,7 +100,224 @@ def attachment_kind_from_name_and_mime(
if mime_type.startswith(VIDEO_MIME_PREFIX) or ext in VIDEO_EXTENSIONS:
return "video"
return "file"
-
+
+
+def load_page_children(get_db, page_id: int) -> list[dict]:
+ with get_db() as db:
+ rows = db.execute(
+ """
+ SELECT
+ id,
+ slug,
+ title,
+ updated_at
+ FROM pages
+ WHERE parent_page_id = ?
+ AND is_active = 1
+ ORDER BY title COLLATE NOCASE, id ASC
+ """,
+ (page_id,),
+ ).fetchall()
+ return [dict(row) for row in rows]
+
+
+def load_page_backlinks(get_db, page_id: int, page_slug: str, team_id: int) -> list[dict]:
+ pattern_1 = f"%]({page_slug})%"
+ pattern_2 = f"%/pages/{page_slug})%"
+ with get_db() as db:
+ rows = db.execute(
+ """
+ SELECT
+ id,
+ slug,
+ title,
+ updated_at
+ FROM pages
+ WHERE team_id = ?
+ AND is_active = 1
+ AND id != ?
+ AND (body LIKE ? OR body LIKE ?)
+ ORDER BY updated_at DESC, id DESC
+ """,
+ (team_id, page_id, pattern_1, pattern_2),
+ ).fetchall()
+ return [dict(row) for row in rows]
+
+
+def load_page_comments(get_db, page_id: int) -> list[dict]:
+ with get_db() as db:
+ rows = db.execute(
+ """
+ SELECT
+ pc.id,
+ pc.page_id,
+ pc.user_id,
+ pc.username,
+ pc.body,
+ pc.created_at,
+ pc.updated_at,
+ u.role AS user_role,
+ us.name_color AS user_name_color
+ FROM page_comments AS pc
+ JOIN users AS u
+ ON u.id = pc.user_id
+ LEFT JOIN user_settings AS us
+ ON us.user_id = u.id
+ WHERE pc.page_id = ?
+ AND pc.deleted_at IS NULL
+ ORDER BY pc.created_at ASC, pc.id ASC
+ """,
+ (page_id,),
+ ).fetchall()
+ return [
+ {
+ "id": row["id"],
+ "page_id": row["page_id"],
+ "user_id": row["user_id"],
+ "username": row["username"],
+ "body": row["body"],
+ "created_at": row["created_at"],
+ "updated_at": row["updated_at"],
+ "user_role": row["user_role"],
+ "user_name_color": resolve_name_color(row["user_role"], row["user_name_color"]),
+ }
+ for row in rows
+ ]
+
+
+def next_page_version_number(get_db, page_id: int) -> int:
+ with get_db() as db:
+ row = db.execute(
+ """
+ SELECT COALESCE(MAX(version_number), 0) AS value
+ FROM page_versions
+ WHERE page_id = ?
+ """,
+ (page_id,),
+ ).fetchone()
+ return int(row["value"] or 0) + 1
+
+
+def snapshot_page_version(
+ get_db,
+ page_id: int,
+ title: str,
+ body: str,
+ visibility: str,
+ page_type: str,
+ channel_id: Optional[int],
+ parent_page_id: Optional[int],
+ created_by_user_id: int,
+ created_at: str,
+) -> int:
+ version_number = next_page_version_number(get_db, page_id)
+ with get_db() as db:
+ db.execute("BEGIN IMMEDIATE")
+ cursor = db.execute(
+ """
+ INSERT INTO page_versions (
+ page_id,
+ version_number,
+ title,
+ body,
+ visibility,
+ page_type,
+ channel_id,
+ parent_page_id,
+ created_by_user_id,
+ created_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ page_id,
+ version_number,
+ title,
+ body,
+ visibility,
+ page_type,
+ channel_id,
+ parent_page_id,
+ created_by_user_id,
+ created_at,
+ ),
+ )
+ db.commit()
+ return cursor.lastrowid
+
+
+def load_page_versions(get_db, page_id: int) -> list[dict]:
+ with get_db() as db:
+ rows = db.execute(
+ """
+ SELECT
+ pv.id,
+ pv.page_id,
+ pv.version_number,
+ pv.title,
+ pv.visibility,
+ pv.page_type,
+ pv.channel_id,
+ pv.parent_page_id,
+ pv.created_by_user_id,
+ pv.created_at,
+ u.username AS created_by_username
+ FROM page_versions AS pv
+ JOIN users AS u
+ ON u.id = pv.created_by_user_id
+ WHERE pv.page_id = ?
+ ORDER BY pv.version_number DESC
+ """,
+ (page_id,),
+ ).fetchall()
+ return [dict(row) for row in rows]
+
+
+def load_page_version_detail(get_db, version_id: int):
+ with get_db() as db:
+ row = db.execute(
+ """
+ SELECT
+ pv.id,
+ pv.page_id,
+ pv.version_number,
+ pv.title,
+ pv.body,
+ pv.visibility,
+ pv.page_type,
+ pv.channel_id,
+ pv.parent_page_id,
+ pv.created_by_user_id,
+ pv.created_at,
+ u.username AS created_by_username
+ FROM page_versions AS pv
+ JOIN users AS u
+ ON u.id = pv.created_by_user_id
+ WHERE pv.id = ?
+ """,
+ (version_id,),
+ ).fetchone()
+ return row
+
+
+def user_can_see_last_active(settings_row, viewer: Optional[dict], target_user_id: int, is_team_member_func=None) -> bool:
+ visibility = (settings_row["last_active_visibility"] if settings_row and "last_active_visibility" in settings_row.keys() else "everyone") or "everyone"
+ if visibility == "nobody":
+ return False
+ if visibility == "everyone":
+ return True
+ if visibility == "members":
+ if not viewer:
+ return False
+ if viewer["id"] == target_user_id:
+ return True
+ if viewer["role"] in {"moderator", "admin"}:
+ return True
+ if is_team_member_func:
+ return True
+ return False
+ return False
+
+
def page_payload(app_module, page, user: Optional[dict]) -> dict:
can_read = bool(
page["visibility"] == "public"
@@ -98,13 +325,11 @@ def page_payload(app_module, page, user: Optional[dict]) -> dict:
user
and (
user["role"] in {"moderator", "admin"}
- or app_module.is_team_member(page["team_id"], user["id"])
+ or app_module["is_team_member"](page["team_id"], user["id"])
)
)
)
-
- rendered_html = app_module.render_markdown_to_safe_html(page["body"] or "")
-
+ rendered_html = render_markdown_to_safe_html(page["body"] or "")
return {
"id": page["id"],
"team_id": page["team_id"],
@@ -113,6 +338,9 @@ def page_payload(app_module, page, user: Optional[dict]) -> dict:
"channel_id": page["channel_id"],
"channel_slug": page["channel_slug"],
"channel_name": page["channel_name"],
+ "parent_page_id": page["parent_page_id"] if "parent_page_id" in page.keys() else None,
+ "parent_page_slug": page["parent_page_slug"] if "parent_page_slug" in page.keys() else None,
+ "parent_page_title": page["parent_page_title"] if "parent_page_title" in page.keys() else None,
"slug": page["slug"],
"title": page["title"],
"body": page["body"],
@@ -128,20 +356,28 @@ def page_payload(app_module, page, user: Optional[dict]) -> dict:
user
and (
user["role"] in {"moderator", "admin"}
- or app_module.is_team_admin(page["team_id"], user)
+ or app_module["is_team_admin"](page["team_id"], user)
)
),
+ "children": load_page_children(app_module["get_db"], page["id"]),
+ "backlinks": load_page_backlinks(
+ app_module["get_db"],
+ page["id"],
+ page["slug"],
+ page["team_id"],
+ ),
+ "references": extract_page_reference_slugs(page["body"] or ""),
+ "comments": load_page_comments(app_module["get_db"], page["id"]),
}
+
def attachment_payload_from_row(row) -> Optional[dict]:
if not row or not row["id"]:
return None
-
kind = attachment_kind_from_name_and_mime(
row["original_name"],
row["mime_type"],
)
-
return {
"id": row["id"],
"original_name": row["original_name"],
@@ -155,12 +391,10 @@ def attachment_payload_from_row(row) -> Optional[dict]:
def attachment_payload_legacy_from_message_select_row(row) -> Optional[dict]:
if not row["attachment_id"]:
return None
-
kind = attachment_kind_from_name_and_mime(
row["attachment_original_name"],
row["attachment_mime_type"],
)
-
return {
"id": row["attachment_id"],
"original_name": row["attachment_original_name"],
@@ -188,7 +422,6 @@ def attachment_list_for_message(get_db, message_id: int) -> list[dict]:
""",
(message_id,),
).fetchall()
-
return [attachment_payload_from_row(row) for row in rows]
@@ -200,7 +433,6 @@ def cleanup_orphaned_attachments(
app_logger,
) -> None:
cutoff = (utc_now() - timedelta(hours=orphan_ttl_hours)).isoformat()
-
with get_db() as db:
orphaned = db.execute(
"""
@@ -222,7 +454,6 @@ def cleanup_orphaned_attachments(
""",
(cutoff,),
).fetchall()
-
for attachment in orphaned:
db.execute(
"""
@@ -231,7 +462,6 @@ def cleanup_orphaned_attachments(
""",
(attachment["id"],),
)
-
db.commit()
for attachment in orphaned:
@@ -252,12 +482,9 @@ def maybe_cleanup_orphaned_attachments(
) -> None:
now = time.time()
last = state.get("last_attachment_cleanup", 0.0)
-
if now - last < cleanup_interval_seconds:
return
-
state["last_attachment_cleanup"] = now
-
try:
cleanup_func()
except Exception:
@@ -271,23 +498,19 @@ def resolve_owned_attachment_ids(
) -> tuple[Optional[list[int]], Optional[tuple]]:
if attachment_ids is None:
return [], None
-
if not isinstance(attachment_ids, list):
return None, (jsonify({"error": "attachment_ids must be an array."}), 400)
parsed = []
seen = set()
-
for item in attachment_ids[:10]:
try:
attachment_id = int(item)
except (TypeError, ValueError):
return None, (jsonify({"error": "Invalid attachment."}), 400)
-
if attachment_id in seen:
continue
seen.add(attachment_id)
-
with get_db() as db:
row = db.execute(
"""
@@ -297,18 +520,14 @@ def resolve_owned_attachment_ids(
""",
(attachment_id,),
).fetchone()
-
if not row:
return None, (jsonify({"error": "Attachment not found."}), 404)
-
if row["uploader_user_id"] != user_id:
return None, (
jsonify({"error": "You can only attach files you uploaded."}),
403,
)
-
parsed.append(attachment_id)
-
return parsed, None
@@ -319,7 +538,6 @@ def resolve_owned_attachment(
) -> tuple[Optional[int], Optional[tuple]]:
if attachment_id is None:
return None, None
-
try:
parsed_id = int(attachment_id)
except (TypeError, ValueError):
@@ -336,16 +554,13 @@ def resolve_owned_attachment(
""",
(parsed_id,),
).fetchone()
-
if not attachment:
return None, (jsonify({"error": "Attachment not found."}), 404)
-
if attachment["uploader_user_id"] != user_id:
return None, (
jsonify({"error": "You can only attach files you uploaded."}),
403,
)
-
return parsed_id, None
@@ -359,7 +574,6 @@ def can_download_attachment(
) -> bool:
if not user:
return False
-
with get_db() as db:
room_rows = db.execute(
"""
@@ -381,7 +595,6 @@ def can_download_attachment(
""",
(attachment_id,),
).fetchall()
-
channel_rows = db.execute(
"""
SELECT DISTINCT
@@ -394,7 +607,6 @@ def can_download_attachment(
""",
(attachment_id,),
).fetchall()
-
dm_rows = db.execute(
"""
SELECT DISTINCT dc.id
@@ -413,15 +625,12 @@ def can_download_attachment(
if dm_rows:
return True
-
if any(can_access_room(room, user) for room in room_rows):
return True
-
for item in channel_rows:
channel = get_channel_by_id(item["channel_id"])
if channel and can_access_channel(channel, user):
return True
-
return False
@@ -447,7 +656,6 @@ def send_attachment_file(
""",
(attachment_id,),
).fetchone()
-
if not attachment:
abort(404)
@@ -493,7 +701,6 @@ def reaction_summary_for_message(get_db, message_id: int, user: Optional[dict])
""",
(user["id"] if user else -1, message_id),
).fetchall()
-
return [
{
"emoji": row["emoji"],
@@ -532,6 +739,7 @@ def richer_message_payload(
).fetchone()["count"]
seen_by_user = False
+ is_bookmarked = False
if current_user_id:
seen_by_user = bool(
db.execute(
@@ -544,10 +752,29 @@ def richer_message_payload(
(row["id"], current_user_id),
).fetchone()
)
+ is_bookmarked = bool(
+ db.execute(
+ """
+ SELECT 1
+ FROM message_bookmarks
+ WHERE message_id = ?
+ AND user_id = ?
+ """,
+ (row["id"], current_user_id),
+ ).fetchone()
+ )
+
+ pin_row = db.execute(
+ """
+ SELECT pinned_at, pinned_by_user_id
+ FROM message_pins
+ WHERE message_id = ?
+ """,
+ (row["id"],),
+ ).fetchone()
primary_attachment = attachment_payload_legacy_from_message_select_row(row)
attachments = attachment_list_for_message(get_db, row["id"])
-
if primary_attachment and not any(item["id"] == primary_attachment["id"] for item in attachments):
attachments.insert(0, primary_attachment)
@@ -576,6 +803,10 @@ def richer_message_payload(
"seen_by_current_user": seen_by_user,
"reactions": reaction_summary_for_message(get_db, row["id"], user),
"is_dm": bool(("direct_conversation_id" in row.keys()) and row["direct_conversation_id"]),
+ "is_pinned": bool(pin_row),
+ "pinned_at": pin_row["pinned_at"] if pin_row else None,
+ "pinned_by_user_id": pin_row["pinned_by_user_id"] if pin_row else None,
+ "is_bookmarked": is_bookmarked,
}
@@ -590,7 +821,6 @@ def feed_message_payload(
user,
user["id"] if user else None,
)
-
payload.update(
{
"room_slug": row["room_slug"] if "room_slug" in row.keys() else None,
@@ -618,19 +848,42 @@ def profile_payload(
viewer: Optional[dict],
) -> dict:
is_self = bool(viewer and viewer["id"] == user_row["id"])
- status_payload = user_payload_func(user_row)
-
+ status_payload = user_payload_func(user_row, settings_row)
bio = profile_row["bio"] or ""
bio_mode = profile_row["bio_mode"] or "txt"
explicit_name_color = settings_row["name_color"] if settings_row else None
resolved = resolve_name_color(user_row["role"], explicit_name_color)
+ profile_visibility = settings_row["profile_visibility"] if settings_row else "public"
+ if profile_visibility not in PROFILE_VISIBILITIES:
+ profile_visibility = "public"
+
+ last_active_visibility = settings_row["last_active_visibility"] if settings_row else "everyone"
+ if last_active_visibility not in LAST_ACTIVE_VISIBILITIES:
+ last_active_visibility = "everyone"
+
+ presence_mode = settings_row["presence_mode"] if settings_row else "online"
+ if presence_mode not in PRESENCE_MODES:
+ presence_mode = "online"
+
+ last_active_at = user_row["last_login_at"] if "last_login_at" in user_row.keys() else None
+
+ can_show_last_active = False
+ if last_active_visibility == "everyone":
+ can_show_last_active = True
+ elif last_active_visibility == "members":
+ can_show_last_active = bool(viewer and (viewer["id"] == user_row["id"] or viewer["role"] in {"moderator", "admin"}))
+ elif last_active_visibility == "nobody":
+ can_show_last_active = bool(viewer and viewer["id"] == user_row["id"])
+
return {
"id": user_row["id"],
"username": user_row["username"],
"role": user_row["role"],
"status": status_payload["status"],
+ "presence_mode": presence_mode,
"created_at": user_row["created_at"],
+ "last_active_at": last_active_at if can_show_last_active else None,
"display_name": profile_row["display_name"] if profile_row["display_name"] else user_row["username"],
"badge_text": (profile_row["badge_text"] or "").strip() if "badge_text" in profile_row.keys() else "",
"bio": bio,
@@ -638,10 +891,17 @@ def profile_payload(
"bio_html": safe_profile_bio_html(html_module, bio, bio_mode),
"website": profile_row["website"] or "",
"location": profile_row["location"] or "",
+ "status_message": profile_row["status_message"] or "",
+ "banner_attachment_id": profile_row["banner_attachment_id"],
+ "banner_image_url": f"/api/attachments/{profile_row['banner_attachment_id']}/download" if profile_row["banner_attachment_id"] else "",
"updated_at": profile_row["updated_at"],
"is_self": is_self,
"name_color": explicit_name_color,
"resolved_name_color": resolved,
+ "profile_background_color": settings_row["profile_background_color"] if settings_row else None,
+ "profile_banner_color": settings_row["profile_banner_color"] if settings_row else None,
+ "profile_visibility": profile_visibility,
+ "last_active_visibility": last_active_visibility,
}
@@ -657,6 +917,8 @@ def list_dms_for_user(get_db, user_id: int) -> list[dict]:
other.role AS other_role,
us.name_color AS other_name_color,
up.display_name AS other_display_name,
+ up.status_message AS other_status_message,
+ us.presence_mode AS other_presence_mode,
(
SELECT m.text
FROM messages AS m
@@ -709,6 +971,8 @@ def list_dms_for_user(get_db, user_id: int) -> list[dict]:
"role": row["other_role"],
"name_color": row["other_name_color"],
"resolved_name_color": resolve_name_color(row["other_role"], row["other_name_color"]),
+ "status_message": row["other_status_message"] or "",
+ "presence_mode": row["other_presence_mode"] or "online",
},
"last_message_text": row["last_message_text"],
"last_message_created_at": row["last_message_created_at"],
@@ -720,7 +984,6 @@ def list_dms_for_user(get_db, user_id: int) -> list[dict]:
def find_or_create_dm(get_db, db_write, now_iso, user_a_id: int, user_b_id: int) -> int:
low_id, high_id = sorted([int(user_a_id), int(user_b_id)])
-
with get_db() as db:
existing = db.execute(
"""
@@ -735,7 +998,6 @@ def find_or_create_dm(get_db, db_write, now_iso, user_a_id: int, user_b_id: int)
""",
(low_id, high_id, f"{low_id}:{high_id}"),
).fetchone()
-
if existing:
return existing["id"]
@@ -750,7 +1012,6 @@ def find_or_create_dm(get_db, db_write, now_iso, user_a_id: int, user_b_id: int)
""",
(f"{low_id}:{high_id}",),
).fetchone()
-
if existing:
db.commit()
return existing["id"]
@@ -765,7 +1026,6 @@ def find_or_create_dm(get_db, db_write, now_iso, user_a_id: int, user_b_id: int)
(f"{low_id}:{high_id}", now_iso()),
)
dm_id = cursor.lastrowid
-
db.execute(
"""
INSERT INTO direct_conversation_members (
@@ -817,7 +1077,9 @@ def get_dm_other_user(get_db, dm_id: int, user_id: int):
u.username,
u.role,
up.display_name,
- us.name_color
+ up.status_message,
+ us.name_color,
+ us.presence_mode
FROM direct_conversation_members AS dcm
JOIN users AS u
ON u.id = dcm.user_id
@@ -853,6 +1115,8 @@ def dm_payload(get_db, dm_id: int, user_id: int) -> dict:
"role": other["role"],
"name_color": other["name_color"],
"resolved_name_color": resolve_name_color(other["role"], other["name_color"]),
+ "status_message": other["status_message"] or "",
+ "presence_mode": other["presence_mode"] or "online",
},
}
@@ -1071,3 +1335,89 @@ def call_payload(get_db, call_id: int) -> Optional[dict]:
"created_at": row["created_at"],
"ended_at": row["ended_at"],
}
+
+
+def dispatch_scheduled_messages(get_db, now_iso_func, app_logger) -> None:
+ now_value = now_iso_func()
+ with get_db() as db:
+ rows = db.execute(
+ """
+ SELECT
+ id,
+ room_id,
+ channel_id,
+ direct_conversation_id,
+ user_id,
+ username,
+ text,
+ attachment_ids_json
+ FROM scheduled_messages
+ WHERE sent_at IS NULL
+ AND cancelled_at IS NULL
+ AND deliver_at <= ?
+ ORDER BY deliver_at ASC, id ASC
+ LIMIT 100
+ """,
+ (now_value,),
+ ).fetchall()
+
+ if not rows:
+ return
+
+ db.execute("BEGIN IMMEDIATE")
+ for row in rows:
+ try:
+ attachment_ids = json.loads(row["attachment_ids_json"] or "[]")
+ except json.JSONDecodeError:
+ attachment_ids = []
+
+ attachment_id = attachment_ids[0] if attachment_ids else None
+
+ cursor = db.execute(
+ """
+ INSERT INTO messages (
+ room_id,
+ channel_id,
+ direct_conversation_id,
+ user_id,
+ username,
+ text,
+ attachment_id,
+ parent_message_id,
+ created_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?)
+ """,
+ (
+ row["room_id"],
+ row["channel_id"],
+ row["direct_conversation_id"],
+ row["user_id"],
+ row["username"],
+ row["text"],
+ attachment_id,
+ now_value,
+ ),
+ )
+ message_id = cursor.lastrowid
+
+ for position, extra_attachment_id in enumerate(attachment_ids):
+ db.execute(
+ """
+ INSERT OR IGNORE INTO message_attachments (
+ message_id,
+ attachment_id,
+ position
+ ) VALUES (?, ?, ?)
+ """,
+ (message_id, extra_attachment_id, position),
+ )
+
+ db.execute(
+ """
+ UPDATE scheduled_messages
+ SET sent_at = ?
+ WHERE id = ?
+ """,
+ (now_value, row["id"]),
+ )
+ db.commit()
\ No newline at end of file