feat(desktop): implement phase 2 native capabilities
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled

This commit is contained in:
Cheng Zhou
2026-06-30 21:21:55 +08:00
parent 7c721d4e5c
commit 628ff8828b
64 changed files with 3516 additions and 222 deletions
@@ -0,0 +1,194 @@
from __future__ import annotations
import uuid
from datetime import datetime, timedelta, timezone
from sqlalchemy import and_, exists, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.desktop_notification import (
DesktopNotificationDelivery,
DesktopNotificationSubscription,
)
from app.models.distribution import Distribution, DistributionStatus, DistributionTargetType
from app.models.document import Document
from app.models.document_version import DocumentVersion
from app.models.study import Study
from app.models.study_member import StudyMember
from app.schemas.notification import NotificationItem
CLAIM_LEASE = timedelta(minutes=5)
async def get_subscription(db: AsyncSession, user_id: uuid.UUID) -> DesktopNotificationSubscription | None:
return await db.get(DesktopNotificationSubscription, user_id)
async def set_subscription(
db: AsyncSession,
user_id: uuid.UUID,
enabled: bool,
) -> DesktopNotificationSubscription:
subscription = await get_subscription(db, user_id)
now = datetime.now(timezone.utc)
if subscription is None:
subscription = DesktopNotificationSubscription(
user_id=user_id,
enabled=enabled,
enabled_at=now if enabled else None,
)
db.add(subscription)
elif subscription.enabled != enabled:
subscription.enabled = enabled
subscription.enabled_at = now if enabled else None
await db.commit()
await db.refresh(subscription)
return subscription
def _eligible_query(user_id: uuid.UUID, enabled_at: datetime, lease_cutoff: datetime):
role_target_exists = exists(
select(StudyMember.id).where(
StudyMember.study_id == Document.trial_id,
StudyMember.user_id == user_id,
StudyMember.is_active.is_(True),
StudyMember.role_in_study == Distribution.target_id,
)
)
target_matches = or_(
and_(
Distribution.target_type == DistributionTargetType.USER,
Distribution.target_id == str(user_id),
),
and_(
Distribution.target_type == DistributionTargetType.ROLE,
role_target_exists,
),
)
return (
select(
Distribution,
Document,
DocumentVersion,
Study,
DesktopNotificationDelivery,
)
.join(Document, Distribution.document_id == Document.id)
.join(DocumentVersion, Distribution.version_id == DocumentVersion.id)
.join(Study, Document.trial_id == Study.id)
.outerjoin(
DesktopNotificationDelivery,
and_(
DesktopNotificationDelivery.distribution_id == Distribution.id,
DesktopNotificationDelivery.user_id == user_id,
),
)
.where(
Distribution.status == DistributionStatus.ACTIVE,
Distribution.created_at >= enabled_at,
target_matches,
or_(
DesktopNotificationDelivery.id.is_(None),
and_(
DesktopNotificationDelivery.delivered_at.is_(None),
or_(
DesktopNotificationDelivery.claimed_at.is_(None),
DesktopNotificationDelivery.claimed_at < lease_cutoff,
),
),
),
)
.order_by(Distribution.created_at.asc())
.with_for_update(of=Distribution, skip_locked=True)
)
async def claim_notifications(
db: AsyncSession,
user_id: uuid.UUID,
limit: int,
) -> tuple[uuid.UUID | None, datetime | None, list[NotificationItem]]:
subscription = await get_subscription(db, user_id)
if not subscription or not subscription.enabled or not subscription.enabled_at:
return None, None, []
now = datetime.now(timezone.utc)
token = uuid.uuid4()
rows = (
await db.execute(
_eligible_query(user_id, subscription.enabled_at, now - CLAIM_LEASE).limit(max(1, min(limit, 50)))
)
).all()
items: list[NotificationItem] = []
for distribution, document, version, study, delivery in rows:
if delivery is None:
delivery = DesktopNotificationDelivery(
user_id=user_id,
distribution_id=distribution.id,
)
db.add(delivery)
delivery.claim_token = token
delivery.claimed_at = now
items.append(
NotificationItem(
id=distribution.id,
document_id=document.id,
version_id=version.id,
document_title=document.title,
document_no=document.doc_no,
version_no=version.version_no,
change_summary=version.change_summary,
effective_at=version.effective_at,
created_at=distribution.created_at,
study_id=study.id,
study_name=study.name,
delivered_at=delivery.delivered_at,
read_at=delivery.read_at,
)
)
await db.commit()
if not items:
return None, None, []
return token, now + CLAIM_LEASE, items
async def acknowledge_notifications(
db: AsyncSession,
user_id: uuid.UUID,
claim_token: uuid.UUID,
delivered_ids: list[uuid.UUID],
) -> None:
if delivered_ids:
await db.execute(
update(DesktopNotificationDelivery)
.where(
DesktopNotificationDelivery.user_id == user_id,
DesktopNotificationDelivery.claim_token == claim_token,
DesktopNotificationDelivery.distribution_id.in_(delivered_ids),
)
.values(delivered_at=datetime.now(timezone.utc))
)
await db.commit()
async def mark_notification_read(
db: AsyncSession,
user_id: uuid.UUID,
distribution_id: uuid.UUID,
) -> None:
delivery = (
await db.execute(
select(DesktopNotificationDelivery).where(
DesktopNotificationDelivery.user_id == user_id,
DesktopNotificationDelivery.distribution_id == distribution_id,
)
)
).scalar_one_or_none()
if delivery is None:
delivery = DesktopNotificationDelivery(
user_id=user_id,
distribution_id=distribution_id,
)
db.add(delivery)
delivery.read_at = datetime.now(timezone.utc)
await db.commit()
+18 -1
View File
@@ -10,7 +10,7 @@ from typing import Iterable
import aiofiles
from fastapi import HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from sqlalchemy import delete as sa_delete, or_, select, update as sa_update
from sqlalchemy import and_, delete as sa_delete, or_, select, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_cra_site_scope
@@ -30,6 +30,8 @@ from app.models.audit_log import AuditLog
from app.models.distribution import Distribution, DistributionStatus, DistributionTargetType
from app.models.document import Document, DocumentScopeType, DocumentStatus
from app.models.document_version import DocumentVersion, DocumentVersionStatus
from app.models.desktop_notification import DesktopNotificationDelivery
from app.models.study import Study
from app.schemas.acknowledgement import AcknowledgementCreate
from app.schemas.distribution import DistributionCreate, DistributionRead, DistributionStats
from app.schemas.document import DocumentCreate, DocumentDetail, DocumentSummary, DocumentUpdate
@@ -778,9 +780,20 @@ async def list_distribution_notifications(
DocumentVersion.version_no,
DocumentVersion.change_summary,
DocumentVersion.effective_at,
Study.name.label("study_name"),
DesktopNotificationDelivery.delivered_at,
DesktopNotificationDelivery.read_at,
)
.join(DocumentVersion, Distribution.version_id == DocumentVersion.id)
.join(Document, Distribution.document_id == Document.id)
.join(Study, Document.trial_id == Study.id)
.outerjoin(
DesktopNotificationDelivery,
and_(
DesktopNotificationDelivery.distribution_id == Distribution.id,
DesktopNotificationDelivery.user_id == current_user.id,
),
)
.where(
Document.trial_id == study_id,
Distribution.status == DistributionStatus.ACTIVE,
@@ -804,6 +817,10 @@ async def list_distribution_notifications(
change_summary=row.change_summary,
effective_at=row.effective_at,
created_at=row.created_at,
study_id=study_id,
study_name=row.study_name,
delivered_at=row.delivered_at,
read_at=row.read_at,
)
)
return items
@@ -76,6 +76,11 @@ class SecurityAccessLogWriter:
elapsed_ms=entry["elapsed_ms"],
client_ip=entry.get("client_ip"),
user_agent=entry.get("user_agent"),
client_type=entry.get("client_type"),
client_version=entry.get("client_version"),
client_platform=entry.get("client_platform"),
build_channel=entry.get("build_channel"),
build_commit=entry.get("build_commit"),
auth_status=entry["auth_status"],
user_identifier=entry.get("user_identifier"),
created_at=entry.get("created_at", datetime.now(timezone.utc)),