feat(ticket): 工单协同后端——升级不搬数据、受理到验收、站内通知

- item.kind='ticket' + ticket_ext 1:1,迁移 0003 同时重写 item 状态约束为任务∪工单并集
- 工单状态机与动作权限:受理/开工/提交限办理人,验收限需求人(办理人不能自验)
- 升级时原任务转 blocked,验收通过后自动回流 todo,事件流两侧都留痕
- item_link(父子/相关)与 notification(只发当事人),三张表均开 RLS
- 抽出 serializers.py 复用任务序列化,修复工单响应缺 tags 导致前端白屏
- 评论与 PATCH 放开给工单;conftest 清库前终止残留事务,避免 TRUNCATE 死等锁
- tests/test_tickets.py 11 个用例,全量 57 passed
This commit is contained in:
dadao
2026-10-28 13:20:07 +08:00
parent 3d02fe845a
commit 30caaaf1d9
11 changed files with 1849 additions and 49 deletions
+763
View File
@@ -0,0 +1,763 @@
"""工单域接口(M2):升级、指派、办理、验收、回流、通知、事项关系。
一段流程长什么样:
任务卡住了
-> POST /items/{task_id}/promote {assignee_id} 任务变 blocked,生成一条工单(open)
-> 被指派人 POST /items/{ticket_id}/ticket/accept accepted
-> POST /items/{ticket_id}/ticket/start in_progress
-> POST /items/{ticket_id}/ticket/submit pending_verify(提需求的人收到通知)
-> POST /items/{ticket_id}/ticket/verify {approve} done,原任务自动 blocked -> todo
驳回走同一个入口:verify {approve: false, note: "..."} -> 回到 in_progress,reject_count + 1。
每一步都写 activity;通知只是提醒,状态永远以 item 为准。
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from typing import Annotated, Any
from fastapi import APIRouter, Query
from pydantic import BaseModel, Field
from sqlalchemy import Select, and_, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from ..audit import record as audit_record
from ..deps import MemberDep, SessionDep, TenantCtx
from ..domain.ticket import (
CANCELABLE,
NOW_FIELD,
ensure_can_act,
returned_task_status,
target_status,
)
from ..errors import AppError, ErrorCode
from ..models import Member
from ..models_task import Activity, Item
from ..models_ticket import ItemLink, Notification, TicketExt
from ..schemas import Pagination, encode_cursor, ok
from ..serializers import item_out, tags_of
router = APIRouter(prefix="/workspaces/{ws_id}", tags=["工单"])
notify_router = APIRouter(prefix="/workspaces/{ws_id}/notifications", tags=["通知"])
MAX_REJECT_NOTE = 500
# -- 入参 ---------------------------------------------------------------
class PromoteIn(BaseModel):
assignee_id: uuid.UUID
title: str | None = Field(default=None, max_length=200)
body_md: str | None = Field(default=None, max_length=20000)
due_at: datetime | None = None
priority: int | None = Field(default=None, ge=0, le=3)
version: int | None = Field(default=None, description="原任务的版本,给了就做乐观锁")
class ReassignIn(BaseModel):
assignee_id: uuid.UUID
class VerifyIn(BaseModel):
approve: bool
note: str = Field(default="", max_length=MAX_REJECT_NOTE)
resolution_md: str = Field(
default="", max_length=20000, description="验收通过时写的结论,M3 用它长知识"
)
class ActionIn(BaseModel):
note: str = Field(default="", max_length=MAX_REJECT_NOTE)
version: int | None = None
class LinkIn(BaseModel):
to_item_id: uuid.UUID
link_type: str = "related"
# -- 内部工具 -----------------------------------------------------------
async def _load_item(
session: AsyncSession, ws_id: uuid.UUID, item_id: uuid.UUID, kinds: tuple[str, ...]
) -> Item:
item = await session.get(Item, item_id)
if item is None or item.workspace_id != ws_id or item.kind not in kinds:
# 跨租户 / 不存在 / 类型不符:对外一律"不存在"
raise AppError(ErrorCode.ITEM_NOT_FOUND, "事项不存在")
if item.deleted_at is not None:
raise AppError(ErrorCode.ITEM_NOT_FOUND, "事项不存在")
return item
async def _load_ext(session: AsyncSession, item_id: uuid.UUID) -> TicketExt:
ext = await session.get(TicketExt, item_id)
if ext is None:
raise AppError(ErrorCode.TICKET_NOT_FOUND, "这不是一条工单")
return ext
async def _active_member_id(session: AsyncSession, ws_id: uuid.UUID, user_id: uuid.UUID) -> None:
exists = (
await session.execute(
select(Member.id).where(
Member.workspace_id == ws_id,
Member.user_id == user_id,
Member.status == "active",
)
)
).first()
if exists is None:
raise AppError(ErrorCode.BAD_REQUEST, "被指派的人不在这个工作区里")
async def _log(
session: AsyncSession,
item: Item,
actor_id: uuid.UUID | None,
action: str,
payload: dict | None = None,
) -> None:
session.add(
Activity(
workspace_id=item.workspace_id,
item_id=item.id,
actor_id=actor_id,
action=action,
payload=payload or {},
)
)
async def _notify(
session: AsyncSession,
*,
ws_id: uuid.UUID,
user_id: uuid.UUID,
kind: str,
item_id: uuid.UUID | None,
title: str,
body: str = "",
) -> None:
session.add(
Notification(
workspace_id=ws_id,
user_id=user_id,
kind=kind,
item_id=item_id,
title=title[:120],
body=body[:300],
)
)
def _ticket_ext_out(ext: TicketExt) -> dict[str, Any]:
return {
"requester_id": str(ext.requester_id),
"assignee_id": str(ext.assignee_id),
"parent_item_id": str(ext.parent_item_id) if ext.parent_item_id else None,
"accepted_at": ext.accepted_at,
"submitted_at": ext.submitted_at,
"verified_at": ext.verified_at,
"verified_by": str(ext.verified_by) if ext.verified_by else None,
"reject_count": ext.reject_count,
"resolution_md": ext.resolution_md,
}
async def _item_out(session: AsyncSession, ws_id: uuid.UUID, item: Item) -> dict[str, Any]:
"""单条序列化。标签必须一起带上——前端把 `item.tags` 当数组用,
少这个字段整页会白屏(M2 首版就是这么炸的)。"""
grouped = await tags_of(session, ws_id, [item.id])
return item_out(item, grouped.get(item.id))
async def _items_out(
session: AsyncSession, ws_id: uuid.UUID, items: list[Item]
) -> list[dict[str, Any]]:
grouped = await tags_of(session, ws_id, [i.id for i in items])
return [item_out(i, grouped.get(i.id)) for i in items]
async def _ticket_out(
session: AsyncSession, ws_id: uuid.UUID, ticket: Item, ext: TicketExt
) -> dict:
return ok({**await _item_out(session, ws_id, ticket), "ticket": _ticket_ext_out(ext)})
async def _patch_item(
session: AsyncSession,
item: Item,
values: dict[str, Any],
*,
expect_version: int | None,
) -> None:
"""带版本的 UPDATE:给了 version 就必须匹配,否则 409 让客户端刷新。"""
where = [Item.id == item.id, Item.workspace_id == item.workspace_id]
if expect_version is not None:
where.append(Item.version == expect_version)
result = await session.execute(
update(Item)
.where(*where)
.values(**values, version=Item.version + 1, updated_at=datetime.now(timezone.utc))
)
if result.rowcount == 0:
await session.refresh(item)
raise AppError(
ErrorCode.VERSION_CONFLICT,
"这条工单刚被改过,请刷新后重试",
details={"current_version": item.version},
)
await session.refresh(item)
# -- 升级 ---------------------------------------------------------------
@router.post("/items/{item_id}/promote", summary="把任务升级成工单")
async def promote(item_id: uuid.UUID, body: PromoteIn, ctx: MemberDep, session: SessionDep) -> dict:
task = await _load_item(session, ctx.ws_id, item_id, ("task",))
if task.status in ("done", "canceled"):
raise AppError(
ErrorCode.ITEM_STATUS_INVALID,
f"「{task.status}」的任务不能再升级成工单",
details={"status": task.status},
)
existing = (
await session.execute(
select(TicketExt.item_id).where(
TicketExt.workspace_id == ctx.ws_id, TicketExt.parent_item_id == task.id
)
)
).first()
if existing is not None:
raise AppError(ErrorCode.TICKET_ALREADY_EXISTS, "这条任务已经升级过工单了")
await _active_member_id(session, ctx.ws_id, body.assignee_id)
ticket = Item(
workspace_id=ctx.ws_id,
kind="ticket",
title=(body.title or task.title)[:200],
body_md=body.body_md if body.body_md is not None else task.body_md,
status="open",
priority=body.priority if body.priority is not None else task.priority,
creator_id=ctx.user.id,
assignee_id=body.assignee_id,
due_at=body.due_at or task.due_at,
)
session.add(ticket)
await session.flush()
ext = TicketExt(
item_id=ticket.id,
workspace_id=ctx.ws_id,
requester_id=ctx.user.id,
assignee_id=body.assignee_id,
parent_item_id=task.id,
)
session.add(ext)
await session.flush()
# 原任务确实卡住了,进 blocked;不能静默把用户的待办改成别的状态
await _patch_item(session, task, {"status": "blocked"}, expect_version=body.version)
await _log(session, task, ctx.user.id, "promoted_to_ticket", {"ticket_id": str(ticket.id)})
await _log(session, ticket, ctx.user.id, "ticket_created", {"parent_item_id": str(task.id)})
await _notify(
session,
ws_id=ctx.ws_id,
user_id=body.assignee_id,
kind="ticket_assigned",
item_id=ticket.id,
title=f"你被指派了一条工单:{ticket.title}",
body=f"{ctx.user.nickname or '同事'} 请你处理,完成后提交验收",
)
await audit_record(
session,
workspace_id=ctx.ws_id,
actor_user_id=ctx.user.id,
action="ticket.promote",
target_type="item",
target_id=str(ticket.id),
payload={"parent_item_id": str(task.id)},
)
await session.flush()
task_out, ticket_out = await _items_out(session, ctx.ws_id, [task, ticket])
return ok({"task": task_out, "ticket": {**ticket_out, "ticket": _ticket_ext_out(ext)}})
# -- 办理/验收 ----------------------------------------------------------
async def _advance(
ctx: TenantCtx,
session: AsyncSession,
item_id: uuid.UUID,
*,
action: str,
note: str = "",
approve: bool | None = None,
resolution_md: str = "",
version: int | None = None,
) -> dict:
ticket = await _load_item(session, ctx.ws_id, item_id, ("ticket",))
ext = await _load_ext(session, ticket.id)
target = target_status(action, ticket.status)
ensure_can_act(
action=action,
actor_id=ctx.user.id,
role=ctx.role,
assignee_id=ext.assignee_id,
requester_id=ext.requester_id,
)
now = datetime.now(timezone.utc)
previous = ticket.status
values: dict[str, Any] = {"status": target}
if target == "done":
values["completed_at"] = now
ext.verified_at = now
ext.verified_by = ctx.user.id
if resolution_md:
ext.resolution_md = resolution_md
if target == "in_progress" and action == "reject":
ext.reject_count = ext.reject_count + 1
stamp_field = NOW_FIELD.get(target)
if stamp_field and action != "reject":
setattr(ext, stamp_field, now)
await _patch_item(session, ticket, values, expect_version=version)
payload: dict[str, Any] = {"action": action, "from": previous, "to": target}
if note:
payload["note"] = note
await _log(session, ticket, ctx.user.id, f"ticket_{action}", payload)
# 通知对家:验收找提需求的人,其余找办理人
if action == "submit":
await _notify(
session,
ws_id=ctx.ws_id,
user_id=ext.requester_id,
kind="ticket_submitted",
item_id=ticket.id,
title=f"工单待你验收:{ticket.title}",
body=note or "办理人已提交,请确认结果",
)
elif action == "approve":
await _notify(
session,
ws_id=ctx.ws_id,
user_id=ext.assignee_id,
kind="ticket_approved",
item_id=ticket.id,
title=f"工单已通过验收:{ticket.title}",
body=note or "谢谢,这事结了",
)
elif action == "reject":
await _notify(
session,
ws_id=ctx.ws_id,
user_id=ext.assignee_id,
kind="ticket_rejected",
item_id=ticket.id,
title=f"工单被退回:{ticket.title}",
body=note or "请按反馈再改一版",
)
# 验收通过 -> 原任务闭环回流
returned = returned_task_status(target)
if returned and ext.parent_item_id:
parent = await session.get(Item, ext.parent_item_id)
if parent is not None and parent.workspace_id == ctx.ws_id and parent.status == "blocked":
parent.status = returned
parent.version += 1
parent.updated_at = now
await _log(
session,
parent,
ctx.user.id,
"ticket_done",
{"ticket_id": str(ticket.id), "status": returned},
)
await _notify(
session,
ws_id=ctx.ws_id,
user_id=parent.creator_id,
kind="task_unblocked",
item_id=parent.id,
title=f"任务可以继续了:{parent.title}",
body="它对应的工单已验收通过",
)
await session.flush()
return await _ticket_out(session, ctx.ws_id, ticket, ext)
@router.post("/items/{item_id}/ticket/accept", summary="接受工单")
async def accept(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
return await _advance(ctx, session, item_id, action="accept")
@router.post("/items/{item_id}/ticket/start", summary="开始办理")
async def start(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
return await _advance(ctx, session, item_id, action="start")
@router.post("/items/{item_id}/ticket/submit", summary="提交验收")
async def submit(item_id: uuid.UUID, body: ActionIn, ctx: MemberDep, session: SessionDep) -> dict:
return await _advance(
ctx, session, item_id, action="submit", note=body.note, version=body.version
)
@router.post("/items/{item_id}/ticket/verify", summary="验收:通过或驳回")
async def verify(item_id: uuid.UUID, body: VerifyIn, ctx: MemberDep, session: SessionDep) -> dict:
return await _advance(
ctx,
session,
item_id,
action="approve" if body.approve else "reject",
note=body.note,
approve=body.approve,
resolution_md=body.resolution_md,
)
@router.post("/items/{item_id}/ticket/cancel", summary="取消工单")
async def cancel(item_id: uuid.UUID, body: ActionIn, ctx: MemberDep, session: SessionDep) -> dict:
return await _advance(
ctx, session, item_id, action="cancel", note=body.note, version=body.version
)
@router.post("/items/{item_id}/ticket/reassign", summary="改派给同事")
async def reassign(
item_id: uuid.UUID, body: ReassignIn, ctx: MemberDep, session: SessionDep
) -> dict:
ticket = await _load_item(session, ctx.ws_id, item_id, ("ticket",))
ext = await _load_ext(session, ticket.id)
ensure_can_act(
action="reassign",
actor_id=ctx.user.id,
role=ctx.role,
assignee_id=ext.assignee_id,
requester_id=ext.requester_id,
)
if ticket.status not in CANCELABLE:
raise AppError(
ErrorCode.TICKET_STATUS_INVALID,
f"「{ticket.status}」的工单不能改派",
details={"allowed_from": list(CANCELABLE)},
)
if body.assignee_id == ext.assignee_id:
return await _ticket_out(session, ctx.ws_id, ticket, ext)
await _active_member_id(session, ctx.ws_id, body.assignee_id)
await _patch_item(
session,
ticket,
{"status": "open", "assignee_id": body.assignee_id, "completed_at": None},
expect_version=None,
)
ext.assignee_id = body.assignee_id
ext.accepted_at = None
ext.submitted_at = None
await _log(session, ticket, ctx.user.id, "ticket_reassigned", {"to": str(body.assignee_id)})
await _notify(
session,
ws_id=ctx.ws_id,
user_id=body.assignee_id,
kind="ticket_assigned",
item_id=ticket.id,
title=f"转给你一条工单:{ticket.title}",
body="请先接收,再开始办理",
)
await session.flush()
return await _ticket_out(session, ctx.ws_id, ticket, ext)
# -- 工单列表 -----------------------------------------------------------
@router.get("/tickets", summary="工单列表:我指派的 / 我发起的")
async def list_tickets(
ctx: MemberDep,
session: SessionDep,
role: Annotated[str, Query(description="assigned|requested|all")] = "assigned",
status: Annotated[list[str] | None, Query(description="按状态过滤,可重复")] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[str | None, Query()] = None,
) -> dict:
page = Pagination(limit=limit, cursor=cursor)
limit, parsed_cursor = page.parsed()
stmt: Select = (
select(Item, TicketExt)
.join(TicketExt, TicketExt.item_id == Item.id)
.where(Item.workspace_id == ctx.ws_id, Item.kind == "ticket", Item.deleted_at.is_(None))
)
if role == "assigned":
stmt = stmt.where(TicketExt.assignee_id == ctx.user.id)
elif role == "requested":
stmt = stmt.where(TicketExt.requester_id == ctx.user.id)
elif role != "all":
raise AppError(ErrorCode.BAD_REQUEST, "role 只能是 assigned / requested / all")
if status:
stmt = stmt.where(Item.status.in_(status))
stmt = stmt.order_by(Item.updated_at.desc(), Item.id.desc()).limit(limit + 1)
if parsed_cursor is not None:
stmt = stmt.where(
or_(
Item.updated_at < parsed_cursor.created_at,
and_(Item.updated_at == parsed_cursor.created_at, Item.id < parsed_cursor.id),
)
)
rows = (await session.execute(stmt)).all()
has_more = len(rows) > limit
rows = rows[:limit]
next_cursor = (
encode_cursor(rows[-1][0].updated_at, rows[-1][0].id) if has_more and rows else None
)
tickets = await _items_out(session, ctx.ws_id, [item for item, _ in rows])
return ok(
{
"tickets": [
{**out, "ticket": _ticket_ext_out(ext)}
for out, (_, ext) in zip(tickets, rows, strict=True)
],
"next_cursor": next_cursor,
"has_more": has_more,
}
)
# -- 事项关系(子任务 / 相关)------------------------------------------
@router.get("/items/{item_id}/links", summary="这条事项的子项与相关项")
async def list_links(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
await _load_item(session, ctx.ws_id, item_id, ("task", "ticket"))
# parent 边的方向:from 是 to 的子项。所以"我的子项"= 指向我、且类型为 parent 的 from。
children = (
await session.execute(
select(ItemLink, Item)
.join(Item, Item.id == ItemLink.from_item_id)
.where(
ItemLink.workspace_id == ctx.ws_id,
ItemLink.to_item_id == item_id,
ItemLink.link_type == "parent",
)
)
).all()
parents = (
await session.execute(
select(ItemLink, Item)
.join(Item, Item.id == ItemLink.to_item_id)
.where(
ItemLink.workspace_id == ctx.ws_id,
ItemLink.from_item_id == item_id,
ItemLink.link_type == "parent",
)
)
).all()
related = (
await session.execute(
select(ItemLink, Item)
.join(
Item,
or_(
and_(ItemLink.from_item_id == item_id, Item.id == ItemLink.to_item_id),
and_(ItemLink.to_item_id == item_id, Item.id == ItemLink.from_item_id),
),
)
.where(
ItemLink.workspace_id == ctx.ws_id,
ItemLink.link_type == "related",
or_(ItemLink.from_item_id == item_id, ItemLink.to_item_id == item_id),
)
)
).all()
async def _pack(rows: list[Any]) -> list[dict[str, Any]]:
packed = await _items_out(session, ctx.ws_id, [item for _, item in rows])
return [
{"link_id": str(link.id), "link_type": link.link_type, "item": out}
for (link, _), out in zip(rows, packed, strict=True)
]
return ok(
{
"children": await _pack(children),
"parents": await _pack(parents),
"related": await _pack(related),
}
)
@router.post("/items/{item_id}/links", summary="把另一条事项挂成子项/相关项")
async def create_link(
item_id: uuid.UUID, body: LinkIn, ctx: MemberDep, session: SessionDep
) -> dict:
if body.link_type not in ("parent", "related"):
raise AppError(ErrorCode.BAD_REQUEST, "link_type 只能是 parent / related")
if body.to_item_id == item_id:
raise AppError(ErrorCode.LINK_CONFLICT, "不能把自己挂到自己下面")
await _load_item(session, ctx.ws_id, body.to_item_id, ("task", "ticket"))
await _load_item(session, ctx.ws_id, item_id, ("task", "ticket"))
existing = (
await session.execute(
select(ItemLink).where(
ItemLink.from_item_id == body.to_item_id,
ItemLink.to_item_id == item_id,
ItemLink.link_type == body.link_type,
)
)
).scalar_one_or_none()
if existing is not None:
raise AppError(ErrorCode.LINK_CONFLICT, "这条关系已经存在")
link = ItemLink(
workspace_id=ctx.ws_id,
from_item_id=body.to_item_id,
to_item_id=item_id,
link_type=body.link_type,
)
session.add(link)
await session.flush()
return ok({"id": str(link.id), "link_type": link.link_type})
@router.delete("/items/{item_id}/links/{link_id}", summary="解除关系")
async def delete_link(
item_id: uuid.UUID, link_id: uuid.UUID, ctx: MemberDep, session: SessionDep
) -> dict:
link = await session.get(ItemLink, link_id)
if (
link is None
or link.workspace_id != ctx.ws_id
or item_id
not in (
link.from_item_id,
link.to_item_id,
)
):
raise AppError(ErrorCode.LINK_NOT_FOUND, "关系不存在")
await session.delete(link)
return ok({"deleted": True})
# -- 通知 ---------------------------------------------------------------
def _notification_out(n: Notification, item_title: str | None = None) -> dict[str, Any]:
return {
"id": str(n.id),
"kind": n.kind,
"item_id": str(n.item_id) if n.item_id else None,
"item_title": item_title,
"title": n.title,
"body": n.body,
"read_at": n.read_at,
"created_at": n.created_at,
}
@notify_router.get("", summary="我的通知(默认只看未读)")
async def list_notifications(
ctx: MemberDep,
session: SessionDep,
unread_only: bool = True,
limit: Annotated[int, Query(ge=1, le=100)] = 30,
cursor: Annotated[str | None, Query()] = None,
) -> dict:
page = Pagination(limit=limit, cursor=cursor)
limit, parsed_cursor = page.parsed()
stmt = select(Notification).where(
Notification.workspace_id == ctx.ws_id, Notification.user_id == ctx.user.id
)
if unread_only:
stmt = stmt.where(Notification.read_at.is_(None))
stmt = stmt.order_by(Notification.created_at.desc(), Notification.id.desc()).limit(limit + 1)
if parsed_cursor is not None:
stmt = stmt.where(
or_(
Notification.created_at < parsed_cursor.created_at,
and_(
Notification.created_at == parsed_cursor.created_at,
Notification.id < parsed_cursor.id,
),
)
)
rows = (await session.execute(stmt)).scalars().all()
has_more = len(rows) > limit
rows = list(rows[:limit])
titles: dict[uuid.UUID, str] = {}
item_ids = [n.item_id for n in rows if n.item_id]
if item_ids:
for item_id, title in (
await session.execute(select(Item.id, Item.title).where(Item.id.in_(item_ids)))
).all():
titles[item_id] = title
next_cursor = encode_cursor(rows[-1].created_at, rows[-1].id) if has_more and rows else None
return ok(
{
"notifications": [
_notification_out(n, titles.get(n.item_id) if n.item_id else None) for n in rows
],
"next_cursor": next_cursor,
"has_more": has_more,
}
)
@notify_router.get("/unread-count", summary="未读数(导航角标用)")
async def unread_count(ctx: MemberDep, session: SessionDep) -> dict:
from sqlalchemy import func as sa_func
total = (
await session.execute(
select(sa_func.count(Notification.id)).where(
Notification.workspace_id == ctx.ws_id,
Notification.user_id == ctx.user.id,
Notification.read_at.is_(None),
)
)
).scalar_one()
return ok({"unread": int(total)})
@notify_router.post("/{notification_id}/read", summary="标记已读")
async def mark_read(notification_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
n = await session.get(Notification, notification_id)
if n is None or n.workspace_id != ctx.ws_id or n.user_id != ctx.user.id:
raise AppError(ErrorCode.NOTIFICATION_NOT_FOUND, "通知不存在")
if n.read_at is None:
n.read_at = datetime.now(timezone.utc)
return ok({"read_at": n.read_at})
@notify_router.post("/read-all", summary="全部标记已读")
async def mark_all_read(ctx: MemberDep, session: SessionDep) -> dict:
from sqlalchemy import update as sa_update
result = await session.execute(
sa_update(Notification)
.where(
Notification.workspace_id == ctx.ws_id,
Notification.user_id == ctx.user.id,
Notification.read_at.is_(None),
)
.values(read_at=datetime.now(timezone.utc))
)
return ok({"marked": result.rowcount})
__all__ = ["notify_router", "router"]