Offline kommentar-/bildeposting i rundefeeden (ADR-069)
Utvider den eksisterende offline-skrivekøen (ADR-028, hittil kun
hull-scoreføring) til også å dekke rundefeedens kommentarer/bilder.
offline-queue.ts fikk et isMultipart-flagg (Blob-verdier lagres nativt
i IndexedDB, flushQueue bygger FormData ved synk). round-messages.tsx
fikk sin egen isolerte kø (matchId scoped til "{roundId}:messages",
ikke bare roundId) og et optimistisk "Venter på synk"-kort.
Migrasjon 073: round_message.client_message_id (nullbar uuid) + delvis
unik indeks -- POST /messages er ulikt hull-score-PATCH IKKE naturlig
idempotent, så en avbrutt synk kunne duplisert en kommentar uten dette.
Klienten sender en selvgenerert id ved både første forsøk og et evt.
køet gjenforsøk; serveren deduplikerer på den.
Se ARCHITECTURE_DECISIONS.md (ADR-069) og CHANGELOG.md (punkt 85) for
full begrunnelse og verifiseringslogg (inkl. et reload-mens-offline-
scenario som beviser fravær av duplikater).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
4286f9eee3
commit
dd8a8c133d
7 changed files with 494 additions and 65 deletions
25
073_round_message_client_id.sql
Normal file
25
073_round_message_client_id.sql
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- TeeCup — Idempotens for kommentar-posting via offline-kø (ADR-028-
|
||||||
|
-- tillegget "kommentarer/bilder offline", 2026-08-14, migrasjon 073).
|
||||||
|
--
|
||||||
|
-- Hull-score-køen (ADR-028) er trygg å synke på nytt fordi PATCH +
|
||||||
|
-- expected_version konvergerer uansett hvor mange ganger den gjentas.
|
||||||
|
-- POST /rounds/{id}/messages er IKKE naturlig idempotent -- hvert kall
|
||||||
|
-- oppretter en ny rad. En avbrutt synk (nettleseren lukkes midt i et
|
||||||
|
-- flush-kall, eller samme kø flushes fra to faner) kunne dermed skrevet
|
||||||
|
-- samme kommentar to ganger. Løsning: klienten sender en selvgenerert
|
||||||
|
-- `client_message_id` (crypto.randomUUID()); et gjentatt kall med samme
|
||||||
|
-- id returnerer den allerede opprettede meldingen i stedet for å
|
||||||
|
-- opprette en duplikat (se app/routers/round_messages.py::
|
||||||
|
-- post_round_message).
|
||||||
|
-- =====================================================================
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
ALTER TABLE round_message ADD COLUMN client_message_id uuid;
|
||||||
|
|
||||||
|
-- Delvis unik indeks (kun når feltet faktisk er satt) -- eldre/andre
|
||||||
|
-- kallere som ikke sender client_message_id er upåvirket, samme
|
||||||
|
-- "None = ikke i bruk"-konvensjon som resten av appen.
|
||||||
|
CREATE UNIQUE INDEX round_message_client_id_idx
|
||||||
|
ON round_message (round_id, client_message_id)
|
||||||
|
WHERE client_message_id IS NOT NULL;
|
||||||
|
|
@ -6818,6 +6818,98 @@ alle bygget OG rullet ut.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ADR-069: Offline-utvidelse -- kommentar-/bildeposting i rundefeeden (ADR-028-tillegg) — 2026-08-14
|
||||||
|
|
||||||
|
Foranledning: bruker spurte om installasjonsbanneret sin påstand
|
||||||
|
"virker delvis uten nett" faktisk stemte. Undersøkelse bekreftet at den
|
||||||
|
GJORDE det (ekte IndexedDB-skrivekø for hull-scoreføring, service
|
||||||
|
worker-cache for allerede besøkte sider) -- men bruker ønsket å gjøre
|
||||||
|
"delvis" til mer fullstendig. Presentert tre konkrete utvidelsesretninger
|
||||||
|
(AskUserQuestion); bruker valgte "Utvid offline-skriving til mer enn
|
||||||
|
scoreføring (kommentarer/bilder)" -- IKKE de to andre alternativene
|
||||||
|
(proaktiv full-runde-precaching ved åpning, eller et generelt app-skall
|
||||||
|
for en aldri-besøkt kaldstart).
|
||||||
|
|
||||||
|
**Datamodell (migrasjon 073) -- idempotens, IKKE en ny funksjon i seg
|
||||||
|
selv.** Hull-score-køen (ADR-028) er trygg å gjenta fordi PATCH +
|
||||||
|
`expected_version` konvergerer uansett hvor mange ganger den sendes.
|
||||||
|
`POST /rounds/{id}/messages` er IKKE naturlig idempotent -- hvert kall
|
||||||
|
oppretter en ny rad. En avbrutt synk (nettleseren lukkes midt i et
|
||||||
|
flush-kall, eller samme kø flushes fra to faner) kunne dermed skrevet
|
||||||
|
samme kommentar to ganger. Løsning: `round_message` fikk en nullbar
|
||||||
|
`client_message_id uuid`-kolonne + en delvis unik indeks
|
||||||
|
`(round_id, client_message_id) WHERE client_message_id IS NOT NULL`.
|
||||||
|
Klienten genererer denne (`crypto.randomUUID()`) FØR første forsøk
|
||||||
|
(både det direkte online-forsøket og et evt. køet gjenforsøk sender
|
||||||
|
samme id) -- et gjentatt kall med samme id returnerer den allerede
|
||||||
|
opprettede meldingen (`post_round_message`, round_messages.py) i stedet
|
||||||
|
for å opprette en duplikat. 3 nye backend-tester (repetert id → samme
|
||||||
|
melding, ulike id-er → separate meldinger, manglende id → uendret
|
||||||
|
oppførsel for bakoverkompatible kallere) -- full backend-suite 58/58.
|
||||||
|
|
||||||
|
**offline-queue.ts utvidet til multipart, ikke bare JSON.** Køen
|
||||||
|
(opprinnelig kun `Content-Type: application/json`) fikk et nytt
|
||||||
|
`isMultipart`-flagg på `QueueEntry`. Når satt, er `body` et FLATT
|
||||||
|
objekt av string/Blob-felt i stedet for en JSON-serialiserbar verdi --
|
||||||
|
IndexedDB sin structured-clone-algoritme lagrer `Blob`/`File`-verdier
|
||||||
|
NATIVT, ingen egen blob-lagringsstruktur trengs. `flushQueue` bygger da
|
||||||
|
`FormData` i stedet for å JSON-serialisere. Uendret oppførsel for
|
||||||
|
eksisterende (hull-score-)oppføringer uten flagget.
|
||||||
|
|
||||||
|
**Frontend (round-messages.tsx) -- SAMME mønster som round-detail.tsx
|
||||||
|
sin hull-score-kø, men et EGET, isolert kø-navnerom.** `matchId:
|
||||||
|
\`${roundId}:messages\`` (ikke bare `roundId`) -- forhindrer at denne
|
||||||
|
komponentens kø noensinne blandes med round-detail.tsx sin egen
|
||||||
|
hull-score-kø i samme IndexedDB-database, selv om begge er montert på
|
||||||
|
samme side samtidig. Egne `isOnline`/`pendingCount`/`syncingMessages`-
|
||||||
|
tilstander, egne online/offline-lyttere, egen mount-tids-sjekk for
|
||||||
|
gjenværende kø fra en tidligere økt -- fullstendig frikoblet fra
|
||||||
|
round-detail.tsx sin tilsvarende logikk. Et lokalt "optimistisk kort"
|
||||||
|
(egen `PendingRoundMessage`-type, ALDRI blandet inn i den ekte
|
||||||
|
`messages`-listen) vises øverst med "Venter på synk" + klokke-ikon
|
||||||
|
inntil en vellykket synk trigger en full `load()` som erstatter det med
|
||||||
|
serverens fasit (fanger bl.a. opp tagger forkastet server-side).
|
||||||
|
Bilde-forhåndsvisningen bruker sin EGEN, dedikerte `URL.createObjectURL`
|
||||||
|
(ikke composerens `imagePreview`, som revokes med det samme av
|
||||||
|
`clearSelectedImage()`).
|
||||||
|
|
||||||
|
**Bevisst UTENFOR omfang:** kommentar-på-kommentar (`round_message_
|
||||||
|
comment`, `PostEngagement`-tråden) fikk IKKE samme idempotens/kø-støtte
|
||||||
|
-- kun rundefeedens hovedinnlegg (tekst+bilde), som var det brukeren
|
||||||
|
faktisk pekte på. Å gjenopprette et optimistisk kort etter en FULL
|
||||||
|
nettleser-lukking+gjenåpning mens offline er bevisst IKKE forsøkt
|
||||||
|
(bilde-forhåndsvisningens object-URL overlever uansett ikke en reload)
|
||||||
|
-- en køet skriving flushes likevel korrekt automatisk når nettet er
|
||||||
|
tilbake, den vises bare ikke optimistisk i mellomtiden. Denne
|
||||||
|
avgrensningen er testet eksplisitt (se under) og fungerer som forventet.
|
||||||
|
|
||||||
|
**Verifisert:** `python3 -m py_compile` + full
|
||||||
|
`./scripts/run_backend_tests.sh` (58/58, migrasjon 073 inkludert).
|
||||||
|
`tsc --noEmit` rent + 45/45 vitest. Egen scratch-database + scratch
|
||||||
|
`teecup_api`-container (port 18002, live-mountet kode) + lokal `next
|
||||||
|
dev` (port 13002), ekte nettverk-frakoblet-emulering (Chrome DevTools
|
||||||
|
MCP sin `networkConditions: "Offline"`, IKKE bare CDP-nivå -- bekreftet
|
||||||
|
at appens egen `navigator.onLine`-avledede tilstand faktisk reagerte):
|
||||||
|
(1) tekst-only kommentar postet offline → optimistisk kort med
|
||||||
|
"Venter på synk" → satt online igjen → automatisk synk, banner
|
||||||
|
forsvant, EKTE melding med reaksjoner/slett-knapp dukket opp, bekreftet
|
||||||
|
NØYAKTIG 1 rad i databasen (ingen duplikat); (2) samme med bilde
|
||||||
|
(ekte opplastet PNG via Chrome DevTools sin `upload_file`) -- optimistisk
|
||||||
|
bilde-forhåndsvisning vist offline, ekte AVIF-konvertert MinIO-bilde
|
||||||
|
etter synk; (3) reload-mens-offline-scenario -- postet en tredje
|
||||||
|
kommentar offline, lastet siden PÅ NYTT mens fortsatt offline (simulerer
|
||||||
|
at appen lukkes/gjenåpnes), satt online igjen -- den køede skrivingen
|
||||||
|
fra FØR reload ble automatisk funnet og synket ved mount, bekreftet
|
||||||
|
NØYAKTIG 3 rader totalt (ingen duplikat på tvers av reload). Lys+mørk
|
||||||
|
bekreftet. Scratch-stacken (database, rolle, container, MinIO-
|
||||||
|
scratch-bucket) fullstendig revet ned etterpå -- ekte `teecup_db`/
|
||||||
|
`teecup_api`/`teecup_frontend` urørt gjennom hele verifiseringen.
|
||||||
|
|
||||||
|
**IKKE rullet ut ennå** -- migrasjon 073 venter på eksplisitt
|
||||||
|
bekreftelse fra bruker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Utviklingsplan (rekkefølge)
|
## Utviklingsplan (rekkefølge)
|
||||||
|
|
||||||
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
||||||
|
|
|
||||||
45
CHANGELOG.md
45
CHANGELOG.md
|
|
@ -11235,3 +11235,48 @@ Neste steg:
|
||||||
containere startet rent (ruller også ut de tre UI-rettelsene).
|
containere startet rent (ruller også ut de tre UI-rettelsene).
|
||||||
Dette var siste del av den tredelte Flaggturnering-utvidelsen --
|
Dette var siste del av den tredelte Flaggturnering-utvidelsen --
|
||||||
Del A/B/C alle bygget OG rullet ut.
|
Del A/B/C alle bygget OG rullet ut.
|
||||||
|
|
||||||
|
85. **Offline kommentar-/bildeposting i rundefeeden (ADR-069) —
|
||||||
|
2026-08-14.** Bruker spurte om installasjonsbannerets "virker
|
||||||
|
delvis uten nett"-påstand faktisk stemte -- undersøkelse bekreftet
|
||||||
|
at den gjorde det, men bruker ønsket å tette det største gjenværende
|
||||||
|
gapet: kun hull-scoreføring hadde offline-støtte, kommentarer/bilder
|
||||||
|
krevde fortsatt nett. Valgte dette fremfor to andre foreslåtte
|
||||||
|
retninger (proaktiv full-runde-precaching, generelt app-skall for
|
||||||
|
aldri-besøkt kaldstart).
|
||||||
|
|
||||||
|
Migrasjon 073: `round_message.client_message_id` (nullbar uuid) +
|
||||||
|
delvis unik indeks -- IKKE en ny funksjon i seg selv, men
|
||||||
|
idempotens. Ulikt hull-score-køens PATCH+expected_version (naturlig
|
||||||
|
trygg å gjenta), er `POST /messages` IKKE idempotent -- en avbrutt
|
||||||
|
synk kunne skrevet samme kommentar to ganger uten dette. Klienten
|
||||||
|
sender en `crypto.randomUUID()`-generert id ved både første forsøk
|
||||||
|
OG et evt. køet gjenforsøk; et gjentatt kall med samme id returnerer
|
||||||
|
den allerede opprettede meldingen fremfor å duplisere. 3 nye
|
||||||
|
backend-tester -- full suite 58/58.
|
||||||
|
|
||||||
|
`offline-queue.ts` utvidet med et `isMultipart`-flagg -- `body` blir
|
||||||
|
da et flatt string/Blob-objekt (IndexedDB lagrer Blob/File nativt)
|
||||||
|
i stedet for JSON, og `flushQueue` bygger `FormData` ved synk.
|
||||||
|
`round-messages.tsx` fikk sin EGEN, isolerte kø (`matchId:
|
||||||
|
\`${roundId}:messages\``, ikke bare `roundId`) -- blandes aldri med
|
||||||
|
round-detail.tsx sin hull-score-kø selv om begge er montert
|
||||||
|
samtidig. Optimistisk "Venter på synk"-kort med egen bilde-object-
|
||||||
|
URL (ikke composerens, som revokes med det samme).
|
||||||
|
|
||||||
|
**Verifisert:** full `./scripts/run_backend_tests.sh` (58/58,
|
||||||
|
migrasjon 073 inkludert). `tsc --noEmit` rent + 45/45 vitest. Egen
|
||||||
|
scratch-database + scratch `teecup_api`-container (port 18002,
|
||||||
|
live-mountet kode) + lokal `next dev` (port 13002), ekte
|
||||||
|
nettverk-frakoblet-emulering: tekst-only kommentar offline → synk →
|
||||||
|
nøyaktig 1 rad i databasen (ingen duplikat); samme med ekte
|
||||||
|
opplastet bilde (AVIF-konvertert av MinIO etter synk); reload-mens-
|
||||||
|
offline-scenario (postet offline, lastet siden på nytt MENS
|
||||||
|
fortsatt offline, satt online) -- den køede skrivingen ble funnet
|
||||||
|
og synket automatisk ved mount, nøyaktig 3 rader totalt (ingen
|
||||||
|
duplikat på tvers av en reload). Lys+mørk bekreftet. Scratch-stacken
|
||||||
|
fullstendig revet ned -- ekte `teecup_db`/`teecup_api`/
|
||||||
|
`teecup_frontend` urørt.
|
||||||
|
|
||||||
|
**IKKE rullet ut ennå** -- migrasjon 073 venter på eksplisitt
|
||||||
|
bekreftelse fra bruker.
|
||||||
|
|
|
||||||
|
|
@ -318,6 +318,12 @@ async def post_round_message(
|
||||||
# siden dette endepunktet er multipart (bilde-opplasting), ikke en ren
|
# siden dette endepunktet er multipart (bilde-opplasting), ikke en ren
|
||||||
# JSON-body som CommentIn under kan bruke direkte.
|
# JSON-body som CommentIn under kan bruke direkte.
|
||||||
tags: str | None = Form(default=None),
|
tags: str | None = Form(default=None),
|
||||||
|
# Idempotens for offline-kø-synk (ADR-028-tillegget, migrasjon 073,
|
||||||
|
# 2026-08-14) -- klient-generert (crypto.randomUUID()), valgfri for
|
||||||
|
# bakoverkompatible kallere. Et gjentatt kall med SAMME id (f.eks. en
|
||||||
|
# avbrutt synk som prøves på nytt) returnerer den allerede opprettede
|
||||||
|
# meldingen fremfor å opprette en duplikat -- se migrasjonens header.
|
||||||
|
client_message_id: str | None = Form(default=None),
|
||||||
user: CurrentUser = Depends(get_current_user),
|
user: CurrentUser = Depends(get_current_user),
|
||||||
) -> RoundMessageOut:
|
) -> RoundMessageOut:
|
||||||
if not body and image is None:
|
if not body and image is None:
|
||||||
|
|
@ -333,6 +339,20 @@ async def post_round_message(
|
||||||
# ingen snevrere gate i tillegg (ADR-044 Beslutning A).
|
# ingen snevrere gate i tillegg (ADR-044 Beslutning A).
|
||||||
await _get_viewable_round_or_404(conn, round_id, user.user_id)
|
await _get_viewable_round_or_404(conn, round_id, user.user_id)
|
||||||
|
|
||||||
|
if client_message_id is not None:
|
||||||
|
existing = await conn.fetchrow(
|
||||||
|
f"SELECT {_ROUND_MESSAGE_COLUMNS} FROM round_message WHERE round_id = $1 AND client_message_id = $2",
|
||||||
|
round_id, client_message_id,
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
reactions = await _round_message_reactions(conn, [existing["id"]], user.user_id)
|
||||||
|
comment_counts = await _round_message_comment_counts(conn, [existing["id"]])
|
||||||
|
tags_by_id = await _tags_for_messages(conn, "round_message_tag", "round_message_id", [existing["id"]])
|
||||||
|
return _row_to_round_message(
|
||||||
|
existing, reactions.get(existing["id"]), comment_counts.get(existing["id"], 0),
|
||||||
|
tags_by_id.get(existing["id"]),
|
||||||
|
)
|
||||||
|
|
||||||
image_key = None
|
image_key = None
|
||||||
if raw is not None:
|
if raw is not None:
|
||||||
try:
|
try:
|
||||||
|
|
@ -345,8 +365,9 @@ async def post_round_message(
|
||||||
|
|
||||||
row = await conn.fetchrow(
|
row = await conn.fetchrow(
|
||||||
f"""
|
f"""
|
||||||
INSERT INTO round_message (round_id, author_user_id, author_display_name, body, image_key)
|
INSERT INTO round_message
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
(round_id, author_user_id, author_display_name, body, image_key, client_message_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
RETURNING {_ROUND_MESSAGE_COLUMNS}
|
RETURNING {_ROUND_MESSAGE_COLUMNS}
|
||||||
""",
|
""",
|
||||||
round_id,
|
round_id,
|
||||||
|
|
@ -354,6 +375,7 @@ async def post_round_message(
|
||||||
display_name,
|
display_name,
|
||||||
body,
|
body,
|
||||||
image_key,
|
image_key,
|
||||||
|
client_message_id,
|
||||||
)
|
)
|
||||||
await _insert_tags_and_notify(
|
await _insert_tags_and_notify(
|
||||||
conn, table="round_message_tag", fk_column="round_message_id", message_id=row["id"],
|
conn, table="round_message_tag", fk_column="round_message_id", message_id=row["id"],
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
import { Camera, ImagePlus, Send, Trash2, X } from "lucide-react"
|
import { Camera, Clock, ImagePlus, RefreshCw, Send, Trash2, WifiOff, X } from "lucide-react"
|
||||||
import { MentionTextarea, TaggedText } from "./mention-input"
|
import { MentionTextarea, TaggedText } from "./mention-input"
|
||||||
import type { Tag } from "@/lib/mentions"
|
import type { Tag } from "@/lib/mentions"
|
||||||
import { PostEngagement, type ReactionSummary } from "./post-engagement"
|
import { PostEngagement, type ReactionSummary } from "./post-engagement"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue"
|
||||||
|
|
||||||
/** Rå meldingsform fra backend. */
|
/** Rå meldingsform fra backend. */
|
||||||
export type ApiRoundMessage = {
|
export type ApiRoundMessage = {
|
||||||
|
|
@ -20,6 +22,13 @@ export type ApiRoundMessage = {
|
||||||
tags: Tag[]
|
tags: Tag[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Et lokalt, ikke-synkronisert innlegg (offline-kø-utvidelse, 2026-08-14,
|
||||||
|
// se ADR-028-tillegget). `id` er en midlertidig klient-id (aldri sendt til
|
||||||
|
// serveren) -- brukes kun som React-key inntil `load()` erstatter hele
|
||||||
|
// listen med serverens fasit etter vellykket synk. `image_url` er da en
|
||||||
|
// lokal object-URL (blob-forhåndsvisning), ikke en ekte MinIO-URL.
|
||||||
|
type PendingRoundMessage = ApiRoundMessage & { _clientMessageId: string }
|
||||||
|
|
||||||
export type RoundMessagesProps = {
|
export type RoundMessagesProps = {
|
||||||
roundId: string
|
roundId: string
|
||||||
currentUserId: string
|
currentUserId: string
|
||||||
|
|
@ -108,34 +117,112 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
|
||||||
const [postError, setPostError] = useState<string | null>(null)
|
const [postError, setPostError] = useState<string | null>(null)
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Offline kommentar-/bildeposting (ADR-028-tillegget, 2026-08-14) --
|
||||||
|
// egen kø-navnerom (`${roundId}:messages`, ikke bare roundId) slik at
|
||||||
|
// denne komponentens kø ALDRI blandes med round-detail.tsx sin
|
||||||
|
// hull-score-kø, selv om begge deler samme IndexedDB-database. De to
|
||||||
|
// køene flushes og telles helt uavhengig av hverandre.
|
||||||
|
const messagesMatchId = `${roundId}:messages`
|
||||||
|
const [isOnline, setIsOnline] = useState(true)
|
||||||
|
const [pendingCount, setPendingCount] = useState(0)
|
||||||
|
const [syncingMessages, setSyncingMessages] = useState(false)
|
||||||
|
// Ikke-synkroniserte innlegg, vist FØRST i listen inntil `load()` (kalt
|
||||||
|
// etter en vellykket synk) erstatter dem med serverens fasit. Kan IKKE
|
||||||
|
// legges inn i `messages` direkte -- de har ingen ekte server-id ennå.
|
||||||
|
const [pendingMessages, setPendingMessages] = useState<PendingRoundMessage[]>([])
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
const cameraInputRef = useRef<HTMLInputElement | null>(null)
|
const cameraInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
// Hent meldinger ved mount, og på nytt hver gang refreshKey endres
|
const load = useCallback(async () => {
|
||||||
// (WS-signal fra round-detail.tsx).
|
|
||||||
useEffect(() => {
|
|
||||||
const controller = new AbortController()
|
|
||||||
async function load() {
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setLoadError(null)
|
setLoadError(null)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/rounds/${roundId}/messages`, {
|
const res = await fetch(`/rounds/${roundId}/messages`, { credentials: "include" })
|
||||||
credentials: "include",
|
|
||||||
signal: controller.signal,
|
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error(`Status ${res.status}`)
|
if (!res.ok) throw new Error(`Status ${res.status}`)
|
||||||
const data: ApiRoundMessage[] = await res.json()
|
const data: ApiRoundMessage[] = await res.json()
|
||||||
setMessages(data)
|
setMessages(data)
|
||||||
} catch (err) {
|
} catch {
|
||||||
if ((err as Error).name === "AbortError") return
|
|
||||||
setLoadError("Kunne ikke laste kommentarene. Prøv å laste siden på nytt.")
|
setLoadError("Kunne ikke laste kommentarene. Prøv å laste siden på nytt.")
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
}, [roundId])
|
||||||
|
|
||||||
|
// Hent meldinger ved mount, og på nytt hver gang refreshKey endres
|
||||||
|
// (WS-signal fra round-detail.tsx).
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
}, [load, refreshKey])
|
||||||
|
|
||||||
|
const flushPendingMessages = useCallback(async () => {
|
||||||
|
if (syncingMessages) return
|
||||||
|
setSyncingMessages(true)
|
||||||
|
try {
|
||||||
|
const outcomes = await flushQueue(messagesMatchId)
|
||||||
|
if (outcomes.length === 0) return
|
||||||
|
for (const o of outcomes) {
|
||||||
|
const sentBody = o.entry.body as Record<string, unknown>
|
||||||
|
const clientMessageId = sentBody.client_message_id as string | undefined
|
||||||
|
setPendingMessages((prev) => {
|
||||||
|
const match = prev.find((m) => m._clientMessageId === clientMessageId)
|
||||||
|
if (match?.image_url) URL.revokeObjectURL(match.image_url)
|
||||||
|
return prev.filter((m) => m._clientMessageId !== clientMessageId)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
load()
|
const failed = outcomes.filter((o) => !o.ok)
|
||||||
return () => controller.abort()
|
if (failed.length > 0) {
|
||||||
}, [roundId, refreshKey])
|
setPostError(
|
||||||
|
`${failed.length} ${failed.length === 1 ? "kommentar" : "kommentarer"} kunne ikke synkroniseres: ${failed[0].message}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
setPendingCount(await queueCount(messagesMatchId))
|
||||||
|
// Serverens fasit -- fanger opp bl.a. tagger som ble forkastet
|
||||||
|
// server-side (f.eks. mot noen som ikke lenger er medspiller/venn).
|
||||||
|
await load()
|
||||||
|
} finally {
|
||||||
|
setSyncingMessages(false)
|
||||||
|
}
|
||||||
|
}, [messagesMatchId, syncingMessages, load])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsOnline(navigator.onLine)
|
||||||
|
function handleOnline() {
|
||||||
|
setIsOnline(true)
|
||||||
|
void flushPendingMessages()
|
||||||
|
}
|
||||||
|
function handleOffline() {
|
||||||
|
setIsOnline(false)
|
||||||
|
}
|
||||||
|
window.addEventListener("online", handleOnline)
|
||||||
|
window.addEventListener("offline", handleOffline)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("online", handleOnline)
|
||||||
|
window.removeEventListener("offline", handleOffline)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [roundId])
|
||||||
|
|
||||||
|
// Køede skrivinger fra en tidligere økt (f.eks. nettleseren ble lukket
|
||||||
|
// mens offline) -- samme engangs-sjekk-ved-mount-mønster som
|
||||||
|
// round-detail.tsx. MERK: en optimistisk visning av DISSE gjenopprettes
|
||||||
|
// bevisst IKKE (bilde-forhåndsvisningens object-URL overlever uansett
|
||||||
|
// ikke en reload) -- de dukker opp i selve listen først når `load()`
|
||||||
|
// kjører etter vellykket synk, en akseptert forenkling.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
queueCount(messagesMatchId)
|
||||||
|
.then((count) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setPendingCount(count)
|
||||||
|
if (count > 0 && navigator.onLine) void flushPendingMessages()
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [roundId])
|
||||||
|
|
||||||
// Rydd opp objekt-URL-en for bildeforhåndsvisning.
|
// Rydd opp objekt-URL-en for bildeforhåndsvisning.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -173,24 +260,72 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
|
||||||
const trimmedBody = body.trim()
|
const trimmedBody = body.trim()
|
||||||
const canPost = (trimmedBody.length > 0 || imageFile != null) && !posting
|
const canPost = (trimmedBody.length > 0 || imageFile != null) && !posting
|
||||||
|
|
||||||
|
// Lagrer skrivingen i offline-køen og speiler den optimistisk øverst i
|
||||||
|
// listen (ADR-028-tillegget, 2026-08-14). `client_message_id` sendes med
|
||||||
|
// BÅDE her og i det direkte (online) forsøket under -- serveren
|
||||||
|
// dedupliserer på den ved en evt. gjentatt synk (migrasjon 073).
|
||||||
|
async function queueMessageOffline(fields: Record<string, string | Blob>, clientMessageId: string) {
|
||||||
|
await enqueueWrite({
|
||||||
|
url: `/rounds/${roundId}/messages`,
|
||||||
|
method: "POST",
|
||||||
|
body: fields,
|
||||||
|
matchId: messagesMatchId,
|
||||||
|
isMultipart: true,
|
||||||
|
})
|
||||||
|
// Egen, LOKAL object-URL for den optimistiske visningen -- må IKKE
|
||||||
|
// gjenbruke composerens `imagePreview` (den revokes av
|
||||||
|
// clearSelectedImage() rett under, som ville ugyldiggjort et delt bilde).
|
||||||
|
const pendingImageUrl = imageFile ? URL.createObjectURL(imageFile) : null
|
||||||
|
setPendingMessages((prev) => [
|
||||||
|
{
|
||||||
|
id: `pending-${clientMessageId}`,
|
||||||
|
round_id: roundId,
|
||||||
|
author_user_id: currentUserId,
|
||||||
|
author_display_name: "Du",
|
||||||
|
body: trimmedBody || null,
|
||||||
|
image_url: pendingImageUrl,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
reactions: [],
|
||||||
|
comment_count: 0,
|
||||||
|
tags,
|
||||||
|
_clientMessageId: clientMessageId,
|
||||||
|
},
|
||||||
|
...prev,
|
||||||
|
])
|
||||||
|
setPendingCount((c) => c + 1)
|
||||||
|
setBody("")
|
||||||
|
setTags([])
|
||||||
|
clearSelectedImage()
|
||||||
|
}
|
||||||
|
|
||||||
async function handlePost(e: React.FormEvent) {
|
async function handlePost(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!canPost) return
|
if (!canPost) return
|
||||||
|
|
||||||
setPosting(true)
|
setPosting(true)
|
||||||
setPostError(null)
|
setPostError(null)
|
||||||
|
const clientMessageId = crypto.randomUUID()
|
||||||
|
const fields: Record<string, string | Blob> = { client_message_id: clientMessageId }
|
||||||
|
if (trimmedBody.length > 0) fields.body = trimmedBody
|
||||||
|
if (imageFile) fields.image = imageFile
|
||||||
|
if (tags.length > 0) fields.tags = JSON.stringify(tags)
|
||||||
|
|
||||||
|
if (!navigator.onLine) {
|
||||||
|
await queueMessageOffline(fields, clientMessageId)
|
||||||
|
setPosting(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
if (trimmedBody.length > 0) formData.append("body", trimmedBody)
|
for (const [k, v] of Object.entries(fields)) formData.append(k, v)
|
||||||
if (imageFile) formData.append("image", imageFile)
|
|
||||||
if (tags.length > 0) formData.append("tags", JSON.stringify(tags))
|
|
||||||
|
|
||||||
const res = await fetch(`/rounds/${roundId}/messages`, {
|
const res = await fetch(`/rounds/${roundId}/messages`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
body: formData,
|
body: formData,
|
||||||
})
|
})
|
||||||
if (res.status !== 201) {
|
if (!res.ok) {
|
||||||
setPostError(await readErrorMessage(res, "Kunne ikke poste kommentaren. Prøv igjen."))
|
setPostError(await readErrorMessage(res, "Kunne ikke poste kommentaren. Prøv igjen."))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -203,7 +338,9 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
|
||||||
setTags([])
|
setTags([])
|
||||||
clearSelectedImage()
|
clearSelectedImage()
|
||||||
} catch {
|
} catch {
|
||||||
setPostError("Kunne ikke poste kommentaren. Sjekk tilkoblingen og prøv igjen.")
|
// Ekte nettverksfeil (ikke bare et avvist svar) -- køordne i stedet
|
||||||
|
// for å tape kommentaren, samme prinsipp som hull-scoreføring (ADR-028).
|
||||||
|
await queueMessageOffline(fields, clientMessageId)
|
||||||
} finally {
|
} finally {
|
||||||
setPosting(false)
|
setPosting(false)
|
||||||
}
|
}
|
||||||
|
|
@ -344,7 +481,35 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Meldingsliste */}
|
{/* Offline kommentar-/bildeposting (ADR-028-tillegget, 2026-08-14) --
|
||||||
|
samme banner-mønster som round-detail.tsx sin hull-scoreføring. */}
|
||||||
|
{!isOnline && (
|
||||||
|
<div className="flex items-center gap-2 rounded-2xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200">
|
||||||
|
<WifiOff aria-hidden="true" className="size-4 shrink-0" />
|
||||||
|
<span>Du er offline. Kommentaren lagres lokalt og postes automatisk når du er tilbake på nett.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<div className="flex items-center justify-between gap-2 rounded-2xl border border-border bg-card px-4 py-3 text-sm font-medium text-foreground">
|
||||||
|
<span>
|
||||||
|
{pendingCount} {pendingCount === 1 ? "kommentar venter" : "kommentarer venter"} på synkronisering.
|
||||||
|
</span>
|
||||||
|
{isOnline && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void flushPendingMessages()}
|
||||||
|
disabled={syncingMessages}
|
||||||
|
className="inline-flex shrink-0 items-center gap-1 font-semibold text-primary disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw aria-hidden="true" className={cn("size-3.5", syncingMessages && "animate-spin")} />
|
||||||
|
Synkroniser nå
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Meldingsliste -- ikke-synkroniserte innlegg ligger ALLTID først,
|
||||||
|
uavhengig av created_at (de er per definisjon de ferskeste). */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex justify-center py-12" aria-live="polite" aria-busy="true">
|
<div className="flex justify-center py-12" aria-live="polite" aria-busy="true">
|
||||||
<span
|
<span
|
||||||
|
|
@ -357,14 +522,15 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
|
||||||
<p role="alert" className="rounded-2xl border border-border bg-card p-5 text-base text-destructive">
|
<p role="alert" className="rounded-2xl border border-border bg-card p-5 text-base text-destructive">
|
||||||
{loadError}
|
{loadError}
|
||||||
</p>
|
</p>
|
||||||
) : messages.length === 0 ? (
|
) : pendingMessages.length === 0 && messages.length === 0 ? (
|
||||||
<p className="rounded-2xl border border-border bg-card p-5 text-base text-muted-foreground">
|
<p className="rounded-2xl border border-border bg-card p-5 text-base text-muted-foreground">
|
||||||
Ingen kommentarer ennå — vær den første til å dele et bilde eller en kommentar fra runden.
|
Ingen kommentarer ennå — vær den første til å dele et bilde eller en kommentar fra runden.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card">
|
<ul className="divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card">
|
||||||
{messages.map((m) => {
|
{[...pendingMessages, ...messages].map((m) => {
|
||||||
const canDelete = m.author_user_id === currentUserId || roundOwnerUserId === currentUserId
|
const isPending = "_clientMessageId" in m
|
||||||
|
const canDelete = !isPending && (m.author_user_id === currentUserId || roundOwnerUserId === currentUserId)
|
||||||
const isDeleting = deletingId === m.id
|
const isDeleting = deletingId === m.id
|
||||||
const { label, title } = formatRelativeTime(m.created_at)
|
const { label, title } = formatRelativeTime(m.created_at)
|
||||||
return (
|
return (
|
||||||
|
|
@ -374,6 +540,12 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
|
||||||
<span className="truncate text-base font-semibold text-foreground">
|
<span className="truncate text-base font-semibold text-foreground">
|
||||||
{m.author_display_name}
|
{m.author_display_name}
|
||||||
</span>
|
</span>
|
||||||
|
{isPending ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-sm font-medium text-muted-foreground">
|
||||||
|
<Clock aria-hidden="true" className="size-3.5 shrink-0" />
|
||||||
|
Venter på synk
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
<time
|
<time
|
||||||
dateTime={m.created_at}
|
dateTime={m.created_at}
|
||||||
title={title}
|
title={title}
|
||||||
|
|
@ -381,6 +553,7 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</time>
|
</time>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canDelete && (
|
{canDelete && (
|
||||||
|
|
@ -419,6 +592,7 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!isPending && (
|
||||||
<PostEngagement
|
<PostEngagement
|
||||||
apiBase={`/rounds/${roundId}/messages/${m.id}`}
|
apiBase={`/rounds/${roundId}/messages/${m.id}`}
|
||||||
roundId={roundId}
|
roundId={roundId}
|
||||||
|
|
@ -427,6 +601,7 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
|
||||||
initialCommentCount={m.comment_count}
|
initialCommentCount={m.comment_count}
|
||||||
canModerate={roundOwnerUserId === currentUserId}
|
canModerate={roundOwnerUserId === currentUserId}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
// Offline skrive-kø for scoreregistrering (ADR-028). Ren IndexedDB, ingen
|
// Offline skrive-kø, opprinnelig for scoreregistrering (ADR-028), utvidet
|
||||||
// avhengighet til service workeren -- gir umiddelbar, presis
|
// 2026-08-14 til også å dekke kommentar-/bildeposting i rundefeeden (se
|
||||||
// UI-tilbakemelding ("lagret lokalt, venter på synk") direkte fra
|
// round-messages.tsx). Ren IndexedDB, ingen avhengighet til service
|
||||||
// scorekort-komponenten, som er enklere og mer testbart enn å prøve å gjøre
|
// workeren -- gir umiddelbar, presis UI-tilbakemelding ("lagret lokalt,
|
||||||
// det samme inni en SW fetch-handler. Bevisst IKKE Background Sync API:
|
// venter på synk") direkte fra kalleren, som er enklere og mer testbart
|
||||||
// iOS Safari støtter den ikke i det hele tatt, og en stor andel av
|
// enn å prøve å gjøre det samme inni en SW fetch-handler. Bevisst IKKE
|
||||||
// klubb-/vennegjeng-brukerne er trolig på iPhone -- et enkelt
|
// Background Sync API: iOS Safari støtter den ikke i det hele tatt, og en
|
||||||
// window.online-lytter-mønster (se session-scorecard.tsx) fungerer overalt.
|
// stor andel av klubb-/vennegjeng-brukerne er trolig på iPhone -- et
|
||||||
|
// enkelt window.online-lytter-mønster (se session-scorecard.tsx) fungerer
|
||||||
|
// overalt.
|
||||||
|
|
||||||
const DB_NAME = "teecup-offline"
|
const DB_NAME = "teecup-offline"
|
||||||
const DB_VERSION = 1
|
const DB_VERSION = 1
|
||||||
|
|
@ -26,6 +28,13 @@ export type QueueEntry = {
|
||||||
// kjedingen i flushQueue under blandet sammen versjonsnummer mellom
|
// kjedingen i flushQueue under blandet sammen versjonsnummer mellom
|
||||||
// urelaterte rader (se ADR-060).
|
// urelaterte rader (se ADR-060).
|
||||||
resourceKey?: string
|
resourceKey?: string
|
||||||
|
// Multipart-skrivinger (kommentar+bilde til rundefeeden, 2026-08-14) --
|
||||||
|
// `body` er da et FLATT objekt av string/Blob-felt i stedet for en
|
||||||
|
// JSON-serialiserbar verdi. IndexedDB sin structured-clone-algoritme
|
||||||
|
// lagrer Blob-verdier (og dermed File, som arver fra Blob) NATIVT --
|
||||||
|
// ingen egen blob-lagringsstruktur trengs. Uendret oppførsel
|
||||||
|
// (JSON.stringify + Content-Type: application/json) når feltet mangler.
|
||||||
|
isMultipart?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDb(): Promise<IDBDatabase> {
|
function openDb(): Promise<IDBDatabase> {
|
||||||
|
|
@ -112,6 +121,7 @@ export async function flushQueue(matchId: string): Promise<FlushOutcome[]> {
|
||||||
const key = entry.resourceKey ?? entry.url
|
const key = entry.resourceKey ?? entry.url
|
||||||
let body: unknown = entry.body
|
let body: unknown = entry.body
|
||||||
if (
|
if (
|
||||||
|
!entry.isMultipart &&
|
||||||
body !== null &&
|
body !== null &&
|
||||||
typeof body === "object" &&
|
typeof body === "object" &&
|
||||||
"expected_version" in body &&
|
"expected_version" in body &&
|
||||||
|
|
@ -121,12 +131,21 @@ export async function flushQueue(matchId: string): Promise<FlushOutcome[]> {
|
||||||
}
|
}
|
||||||
let res: Response
|
let res: Response
|
||||||
try {
|
try {
|
||||||
|
if (entry.isMultipart) {
|
||||||
|
const formData = new FormData()
|
||||||
|
for (const [k, v] of Object.entries(body as Record<string, string | Blob | undefined | null>)) {
|
||||||
|
if (v === undefined || v === null) continue
|
||||||
|
formData.append(k, v)
|
||||||
|
}
|
||||||
|
res = await fetch(entry.url, { method: entry.method, credentials: "include", body: formData })
|
||||||
|
} else {
|
||||||
res = await fetch(entry.url, {
|
res = await fetch(entry.url, {
|
||||||
method: entry.method,
|
method: entry.method,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
break // fortsatt offline -- stopp, resten prøves igjen senere
|
break // fortsatt offline -- stopp, resten prøves igjen senere
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ async def test_post_message_with_valid_tag_creates_tag_and_notification(pool):
|
||||||
body = "God runde med @Venn i dag"
|
body = "God runde med @Venn i dag"
|
||||||
tag_json = f'[{{"user_id": "{friend_id}", "start_index": 14, "end_index": 18}}]'
|
tag_json = f'[{{"user_id": "{friend_id}", "start_index": 14, "end_index": 18}}]'
|
||||||
|
|
||||||
out = await post_round_message(round_id, body=body, image=None, tags=tag_json, user=CurrentUser(user_id=owner_id))
|
out = await post_round_message(round_id, body=body, image=None, tags=tag_json, client_message_id=None, user=CurrentUser(user_id=owner_id))
|
||||||
|
|
||||||
assert len(out.tags) == 1
|
assert len(out.tags) == 1
|
||||||
assert out.tags[0].user_id == friend_id
|
assert out.tags[0].user_id == friend_id
|
||||||
|
|
@ -89,7 +89,7 @@ async def test_post_message_with_stranger_tag_is_silently_dropped(pool):
|
||||||
body = "Hyggelig runde"
|
body = "Hyggelig runde"
|
||||||
tag_json = f'[{{"user_id": "{stranger_id}", "start_index": 0, "end_index": 9}}]'
|
tag_json = f'[{{"user_id": "{stranger_id}", "start_index": 0, "end_index": 9}}]'
|
||||||
|
|
||||||
out = await post_round_message(round_id, body=body, image=None, tags=tag_json, user=CurrentUser(user_id=owner_id))
|
out = await post_round_message(round_id, body=body, image=None, tags=tag_json, client_message_id=None, user=CurrentUser(user_id=owner_id))
|
||||||
|
|
||||||
assert out.body == body
|
assert out.body == body
|
||||||
assert out.tags == []
|
assert out.tags == []
|
||||||
|
|
@ -103,13 +103,13 @@ async def test_post_message_with_out_of_bounds_tag_is_dropped(pool):
|
||||||
body = "Kort"
|
body = "Kort"
|
||||||
tag_json = f'[{{"user_id": "{friend_id}", "start_index": 0, "end_index": 999}}]'
|
tag_json = f'[{{"user_id": "{friend_id}", "start_index": 0, "end_index": 999}}]'
|
||||||
|
|
||||||
out = await post_round_message(round_id, body=body, image=None, tags=tag_json, user=CurrentUser(user_id=owner_id))
|
out = await post_round_message(round_id, body=body, image=None, tags=tag_json, client_message_id=None, user=CurrentUser(user_id=owner_id))
|
||||||
assert out.tags == []
|
assert out.tags == []
|
||||||
|
|
||||||
|
|
||||||
async def test_post_comment_with_valid_tag(pool):
|
async def test_post_comment_with_valid_tag(pool):
|
||||||
owner_id, friend_id, stranger_id, round_id = await _setup_round_with_owner_and_friend()
|
owner_id, friend_id, stranger_id, round_id = await _setup_round_with_owner_and_friend()
|
||||||
message = await post_round_message(round_id, body="Innlegg", image=None, tags=None, user=CurrentUser(user_id=owner_id))
|
message = await post_round_message(round_id, body="Innlegg", image=None, tags=None, client_message_id=None, user=CurrentUser(user_id=owner_id))
|
||||||
|
|
||||||
comment_body = "Bra jobba @Venn"
|
comment_body = "Bra jobba @Venn"
|
||||||
payload = CommentIn(body=comment_body, tags=[TagIn(user_id=friend_id, start_index=10, end_index=14)])
|
payload = CommentIn(body=comment_body, tags=[TagIn(user_id=friend_id, start_index=10, end_index=14)])
|
||||||
|
|
@ -124,5 +124,56 @@ async def test_cannot_tag_self(pool):
|
||||||
owner_id, friend_id, stranger_id, round_id = await _setup_round_with_owner_and_friend()
|
owner_id, friend_id, stranger_id, round_id = await _setup_round_with_owner_and_friend()
|
||||||
body = "Meg selv"
|
body = "Meg selv"
|
||||||
tag_json = f'[{{"user_id": "{owner_id}", "start_index": 0, "end_index": 4}}]'
|
tag_json = f'[{{"user_id": "{owner_id}", "start_index": 0, "end_index": 4}}]'
|
||||||
out = await post_round_message(round_id, body=body, image=None, tags=tag_json, user=CurrentUser(user_id=owner_id))
|
out = await post_round_message(round_id, body=body, image=None, tags=tag_json, client_message_id=None, user=CurrentUser(user_id=owner_id))
|
||||||
assert out.tags == []
|
assert out.tags == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Idempotens for offline-kø-synk (ADR-028-tillegget, migrasjon 073,
|
||||||
|
# 2026-08-14) -- se round_messages.py::post_round_message.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_repeated_client_message_id_returns_same_message_not_a_duplicate(pool):
|
||||||
|
owner_id, friend_id, stranger_id, round_id = await _setup_round_with_owner_and_friend()
|
||||||
|
client_message_id = "11111111-1111-1111-1111-111111111111"
|
||||||
|
|
||||||
|
first = await post_round_message(
|
||||||
|
round_id, body="Fra banen", image=None, tags=None,
|
||||||
|
client_message_id=client_message_id, user=CurrentUser(user_id=owner_id),
|
||||||
|
)
|
||||||
|
second = await post_round_message(
|
||||||
|
round_id, body="Fra banen", image=None, tags=None,
|
||||||
|
client_message_id=client_message_id, user=CurrentUser(user_id=owner_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert first.id == second.id
|
||||||
|
async with app_db.plain_connection() as conn:
|
||||||
|
count = await conn.fetchval(
|
||||||
|
"SELECT count(*) FROM round_message WHERE round_id = $1 AND client_message_id = $2",
|
||||||
|
round_id, client_message_id,
|
||||||
|
)
|
||||||
|
assert count == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_different_client_message_ids_create_separate_messages(pool):
|
||||||
|
owner_id, friend_id, stranger_id, round_id = await _setup_round_with_owner_and_friend()
|
||||||
|
|
||||||
|
first = await post_round_message(
|
||||||
|
round_id, body="Første", image=None, tags=None,
|
||||||
|
client_message_id="22222222-2222-2222-2222-222222222222", user=CurrentUser(user_id=owner_id),
|
||||||
|
)
|
||||||
|
second = await post_round_message(
|
||||||
|
round_id, body="Andre", image=None, tags=None,
|
||||||
|
client_message_id="33333333-3333-3333-3333-333333333333", user=CurrentUser(user_id=owner_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert first.id != second.id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_missing_client_message_id_still_works(pool):
|
||||||
|
owner_id, friend_id, stranger_id, round_id = await _setup_round_with_owner_and_friend()
|
||||||
|
out = await post_round_message(
|
||||||
|
round_id, body="Uten idempotens-nøkkel", image=None, tags=None,
|
||||||
|
client_message_id=None, user=CurrentUser(user_id=owner_id),
|
||||||
|
)
|
||||||
|
assert out.body == "Uten idempotens-nøkkel"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue