Rundeleaderboard: ny GET /rounds/{id}/leaderboard-backend er live (rangering, thru-tall, brutto+netto til par, håndterer 1 til 15+ deltakere). V0-prompten for selve visningen ligger i FEATURE_BACKLOG.md, klar til å limes inn i v0.app — send meg zip-en når du har den, så kobler jeg den på (foreslått rute /my-rounds/[id]/leaderboard, lenket fra rundesiden).
Begge deler scratch-verifisert (39/39 sjekker, inkl. en uavhengig kryssjekk av netto-beregningen mot handicap_engine direkte), rullet ut mot ekte teecup_db/containere, teeoff.no upåvirket.
99 lines
3.2 KiB
Python
99 lines
3.2 KiB
Python
"""
|
|
In-app varslingssenter (FEATURE_BACKLOG.md "Varsler"-runden, 2026-07-25).
|
|
|
|
Eid av BRUKER (mottaker), ikke organisasjon -- plain_connection(), samme
|
|
mønster som personlig profil/HCP-historikk/venner. `create_notification()`
|
|
er den eneste skrivevegen inn -- kalt fra andre routere (i dag kun
|
|
friends.py) ved konkrete hendelser, ikke noe generisk event-system.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Literal
|
|
|
|
from fastapi import APIRouter, Depends
|
|
from pydantic import BaseModel
|
|
|
|
from ..auth import CurrentUser, get_current_user
|
|
from ..db import plain_connection
|
|
|
|
router = APIRouter(tags=["notifications"])
|
|
|
|
NotificationType = Literal["friend", "tournament", "round", "result"]
|
|
|
|
|
|
async def create_notification(conn, *, user_id: str, type: NotificationType, message: str, link_path: str) -> None:
|
|
"""Kalles fra andre routere sin egen `plain_connection()`/transaksjon --
|
|
tar en allerede-åpen `conn`, åpner ikke en egen."""
|
|
await conn.execute(
|
|
"INSERT INTO notification (user_id, type, message, link_path) VALUES ($1, $2, $3, $4)",
|
|
user_id,
|
|
type,
|
|
message,
|
|
link_path,
|
|
)
|
|
|
|
|
|
class NotificationOut(BaseModel):
|
|
id: str
|
|
type: str
|
|
message: str
|
|
link_path: str
|
|
created_at: str
|
|
read_at: str | None
|
|
|
|
|
|
@router.get("/notifications", response_model=list[NotificationOut])
|
|
async def list_notifications(user: CurrentUser = Depends(get_current_user)) -> list[NotificationOut]:
|
|
async with plain_connection() as conn:
|
|
rows = await conn.fetch(
|
|
"""
|
|
SELECT id::text AS id, type, message, link_path, created_at, read_at
|
|
FROM notification WHERE user_id = $1
|
|
ORDER BY created_at DESC
|
|
LIMIT 100
|
|
""",
|
|
user.user_id,
|
|
)
|
|
return [
|
|
NotificationOut(
|
|
id=r["id"],
|
|
type=r["type"],
|
|
message=r["message"],
|
|
link_path=r["link_path"],
|
|
created_at=r["created_at"].isoformat(),
|
|
read_at=r["read_at"].isoformat() if r["read_at"] else None,
|
|
)
|
|
for r in rows
|
|
]
|
|
|
|
|
|
@router.get("/notifications/unread-count")
|
|
async def unread_count(user: CurrentUser = Depends(get_current_user)) -> dict[str, int]:
|
|
async with plain_connection() as conn:
|
|
count = await conn.fetchval(
|
|
"SELECT COUNT(*) FROM notification WHERE user_id = $1 AND read_at IS NULL",
|
|
user.user_id,
|
|
)
|
|
return {"count": count}
|
|
|
|
|
|
@router.post("/notifications/{notification_id}/read")
|
|
async def mark_read(notification_id: str, user: CurrentUser = Depends(get_current_user)) -> dict[str, bool]:
|
|
async with plain_connection() as conn:
|
|
await conn.execute(
|
|
"UPDATE notification SET read_at = now() WHERE id = $1 AND user_id = $2 AND read_at IS NULL",
|
|
notification_id,
|
|
user.user_id,
|
|
)
|
|
return {"ok": True}
|
|
|
|
|
|
@router.post("/notifications/read-all")
|
|
async def mark_all_read(user: CurrentUser = Depends(get_current_user)) -> dict[str, bool]:
|
|
async with plain_connection() as conn:
|
|
await conn.execute(
|
|
"UPDATE notification SET read_at = now() WHERE user_id = $1 AND read_at IS NULL",
|
|
user.user_id,
|
|
)
|
|
return {"ok": True}
|