From 9c659e2494f318e30564cf149af0f5e565f06a17 Mon Sep 17 00:00:00 2001 From: Nicolas CHAUVIN Date: Fri, 11 Sep 2026 15:00:04 +0200 Subject: [PATCH 1/2] docs(design): the lock expiry window is answered --- docs/design/product.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/design/product.md b/docs/design/product.md index b7fda66..25bb76d 100644 --- a/docs/design/product.md +++ b/docs/design/product.md @@ -105,6 +105,18 @@ afterwards. - A held case is read-only for everyone else ([ADR 0005](../adr/0005-exclusive-case-locking.md)). - Locks expire: the session sends a heartbeat, and a lock whose heartbeat has gone silent for longer than the configured window is released automatically. +- The lock is claimed when the reviewer opens the case, renewed by a + 30-second heartbeat while the page stays open, and released on leaving — or + on its own once the heartbeat has been silent for the **expiry window** + (default two minutes, `OZALID_LOCK_WINDOW`; long enough to read a crowded + grid mid-thought, short enough that a closed laptop frees the case within + minutes). Read-only means **no verdict but the holder's**: `SaveReview`, + comment and recording judgments answer `423` naming the holder; the dev's + moves — tracking, delivering, discarding — are not verdicts and never + blocked (§7 already keeps the bytes still under a reviewer). Verified by + `TestAHeldCaseRefusesAnotherReviewersVerdict`, + `TestASilentLockExpiresOnItsOwn` and the two-reviewers e2e "a held case + reads the same and refuses the verdict". ### 3.3 Movement (is the evidence still the evidence that was judged) @@ -622,6 +634,8 @@ To settle before implementation starts. The technology stack, listed here until 3. **Non-fingerprintable steps** — some screens have no deterministic frame (animations, timers). The predecessor kept a per-project exemption list; confirm that model. -4. **Lock expiry window** — a concrete duration for [§3.2](#32-occupancy-is-someone-working-on-it-right-now). +4. **Lock expiry window** — answered: two minutes of heartbeat silence, + configurable (`OZALID_LOCK_WINDOW`), see + [§3.2](#32-occupancy-is-someone-working-on-it-right-now). 5. **Retention ceiling** — content addressing makes history cheap, not free. Decide whether editions are pruned beyond some horizon, and on what rule. From 10985d93419e836dce3c3f97fc676368f6487f9b Mon Sep 17 00:00:00 2001 From: Nicolas CHAUVIN Date: Fri, 11 Sep 2026 15:33:04 +0200 Subject: [PATCH 2/2] feat(review): say when somebody else is reviewing a case --- apps/server/api/openapi.yaml | 95 ++ apps/server/api/src/components/responses.yaml | 9 + apps/server/api/src/components/schemas.yaml | 13 + apps/server/api/src/openapi.yaml | 2 + apps/server/api/src/paths/case-lock.yaml | 55 ++ apps/server/api/src/paths/case-reviews.yaml | 2 + apps/server/api/src/paths/comment-moves.yaml | 4 + .../api/src/paths/recording-judgment.yaml | 4 + .../db/migrations/00021_a_case_is_held.sql | 14 + apps/server/db/queries/aggregates.sql | 32 + .../internal/adapters/postgres/catalogue.go | 10 +- .../internal/adapters/postgres/comment.go | 9 + .../server/internal/adapters/postgres/lock.go | 108 +++ .../internal/adapters/postgres/lock_test.go | 141 +++ .../internal/adapters/postgres/postgres.go | 4 + .../internal/adapters/postgres/recording.go | 4 + .../internal/adapters/postgres/session.go | 4 + .../postgres/sqlcgen/aggregates.sql.go | 82 ++ .../adapters/postgres/sqlcgen/models.go | 7 + apps/server/internal/app/session/session.go | 13 + .../internal/app/session/session_test.go | 19 + .../internal/domain/catalogue/catalogue.go | 3 + apps/server/internal/domain/review/state.go | 19 + apps/server/internal/ports/http/catalogue.go | 9 + apps/server/internal/ports/http/comment.go | 8 + apps/server/internal/ports/http/lock.go | 85 ++ .../internal/ports/http/openapi/server.gen.go | 859 +++++++++++++----- apps/server/internal/ports/http/recording.go | 8 + apps/server/internal/ports/http/session.go | 6 + apps/web/e2e/review.spec.ts | 71 ++ apps/web/src/features/review/useReview.ts | 32 + apps/web/src/pages/case/CasePage.vue | 49 +- apps/web/src/shared/api/schema.gen.ts | 108 +++ .../capture-carousel/CaptureCarousel.spec.ts | 21 + .../capture-carousel/CaptureCarousel.vue | 14 +- 35 files changed, 1685 insertions(+), 238 deletions(-) create mode 100644 apps/server/api/src/paths/case-lock.yaml create mode 100644 apps/server/db/migrations/00021_a_case_is_held.sql create mode 100644 apps/server/internal/adapters/postgres/lock.go create mode 100644 apps/server/internal/adapters/postgres/lock_test.go create mode 100644 apps/server/internal/ports/http/lock.go diff --git a/apps/server/api/openapi.yaml b/apps/server/api/openapi.yaml index 5933a41..b94b168 100644 --- a/apps/server/api/openapi.yaml +++ b/apps/server/api/openapi.yaml @@ -1249,6 +1249,8 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '423': + $ref: '#/components/responses/Held' delete: operationId: unjudgeRecording security: @@ -1278,6 +1280,8 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '423': + $ref: '#/components/responses/Held' /projects/{slug}/cases/{caseId}: parameters: - name: slug @@ -1341,6 +1345,64 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + /projects/{slug}/cases/{caseId}/lock: + parameters: + - name: slug + in: path + required: true + schema: + type: string + - name: caseId + in: path + required: true + schema: + type: string + post: + operationId: claimCase + security: + - session: [] + summary: Hold the case, or keep holding it + description: | + Claiming is also the heartbeat (ADR 0005, #95): the same call takes a free + or expired lock and renews the caller's own. Somebody else's live hold + answers 423, naming them. Occupancy is never a state — the case reads the + same before and after. + tags: + - reviews + responses: + '200': + description: The caller holds the case. + content: + application/json: + schema: + $ref: '#/components/schemas/Hold' + '401': + $ref: '#/components/responses/Unauthenticated' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '423': + $ref: '#/components/responses/Held' + delete: + operationId: releaseCase + security: + - session: [] + summary: Let the case go + description: | + Releasing a lock nobody holds, or somebody else's, changes nothing + (ADR 0005). + tags: + - reviews + responses: + '204': + description: The caller no longer holds the case. + '401': + $ref: '#/components/responses/Unauthenticated' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' /projects/{slug}/cases/{caseId}/captures: parameters: - name: slug @@ -1648,6 +1710,8 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/MoveRefused' + '423': + $ref: '#/components/responses/Held' post: operationId: judgeComment security: @@ -1699,6 +1763,8 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/MoveRefused' + '423': + $ref: '#/components/responses/Held' /projects/{slug}/cases/{caseId}/reviews: parameters: - name: slug @@ -1748,6 +1814,8 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '423': + $ref: '#/components/responses/Held' /projects/{slug}/cases/{caseId}/archive: parameters: - name: slug @@ -2049,6 +2117,21 @@ components: description: | Who holds the ball. Computed by the server from the case's comments; no endpoint accepts it as an argument. + Hold: + type: object + required: + - by + - name + - since + description: Who holds the case, and since when. Occupancy, never a state (ADR 0005). + properties: + by: + type: string + name: + type: string + since: + type: string + format: date-time CaptureCounts: type: object required: @@ -2092,6 +2175,10 @@ components: description: type: string nullable: true + held: + description: Present while a reviewer holds the case (ADR 0005, + allOf: + - $ref: '#/components/schemas/Hold' state: $ref: '#/components/schemas/CaseState' archived: @@ -2882,6 +2969,14 @@ components: application/problem+json: schema: $ref: '#/components/schemas/problem' + Held: + description: | + Somebody else holds this case (ADR 0005, #95): a held case takes no + verdict but its holder's. The detail names them. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/problem' MoveApplied: description: Where the comment and its case now stand. content: diff --git a/apps/server/api/src/components/responses.yaml b/apps/server/api/src/components/responses.yaml index e0be96b..fc76b2f 100644 --- a/apps/server/api/src/components/responses.yaml +++ b/apps/server/api/src/components/responses.yaml @@ -5,6 +5,15 @@ BadRequest: schema: $ref: "./problem.yaml" +Held: + description: | + Somebody else holds this case (ADR 0005, #95): a held case takes no + verdict but its holder's. The detail names them. + content: + application/problem+json: + schema: + $ref: "./problem.yaml" + NotFound: description: No such resource. content: diff --git a/apps/server/api/src/components/schemas.yaml b/apps/server/api/src/components/schemas.yaml index a952e16..e3b4c36 100644 --- a/apps/server/api/src/components/schemas.yaml +++ b/apps/server/api/src/components/schemas.yaml @@ -52,6 +52,10 @@ Case: categoryId: { type: string, nullable: true } title: { type: string } description: { type: string, nullable: true } + held: + description: Present while a reviewer holds the case (ADR 0005, #95). + allOf: + - $ref: "#/Hold" state: $ref: "#/CaseState" archived: @@ -78,6 +82,15 @@ NewCase: description: { type: string } categoryId: { type: string, minLength: 1 } +Hold: + type: object + required: [by, name, since] + description: Who holds the case, and since when. Occupancy, never a state (ADR 0005). + properties: + by: { type: string } + name: { type: string } + since: { type: string, format: date-time } + CaseUpdate: type: object required: [title] diff --git a/apps/server/api/src/openapi.yaml b/apps/server/api/src/openapi.yaml index c8077cc..d2eb4d2 100644 --- a/apps/server/api/src/openapi.yaml +++ b/apps/server/api/src/openapi.yaml @@ -65,6 +65,8 @@ paths: $ref: "./paths/recording-judgment.yaml" /projects/{slug}/cases/{caseId}: $ref: "./paths/case.yaml" + /projects/{slug}/cases/{caseId}/lock: + $ref: "./paths/case-lock.yaml" /projects/{slug}/cases/{caseId}/captures: $ref: "./paths/case-captures.yaml" /projects/{slug}/cases/{caseId}/comments: diff --git a/apps/server/api/src/paths/case-lock.yaml b/apps/server/api/src/paths/case-lock.yaml new file mode 100644 index 0000000..a4e13a6 --- /dev/null +++ b/apps/server/api/src/paths/case-lock.yaml @@ -0,0 +1,55 @@ +parameters: + - name: slug + in: path + required: true + schema: { type: string } + - name: caseId + in: path + required: true + schema: { type: string } + +post: + operationId: claimCase + security: + - session: [] + summary: Hold the case, or keep holding it + description: | + Claiming is also the heartbeat (ADR 0005, #95): the same call takes a free + or expired lock and renews the caller's own. Somebody else's live hold + answers 423, naming them. Occupancy is never a state — the case reads the + same before and after. + tags: [reviews] + responses: + "200": + description: The caller holds the case. + content: + application/json: + schema: + $ref: "../components/schemas.yaml#/Hold" + '401': + $ref: "../components/responses.yaml#/Unauthenticated" + '403': + $ref: "../components/responses.yaml#/Forbidden" + "404": + $ref: "../components/responses.yaml#/NotFound" + "423": + $ref: "../components/responses.yaml#/Held" + +delete: + operationId: releaseCase + security: + - session: [] + summary: Let the case go + description: | + Releasing a lock nobody holds, or somebody else's, changes nothing + (ADR 0005). + tags: [reviews] + responses: + "204": + description: The caller no longer holds the case. + '401': + $ref: "../components/responses.yaml#/Unauthenticated" + '403': + $ref: "../components/responses.yaml#/Forbidden" + "404": + $ref: "../components/responses.yaml#/NotFound" diff --git a/apps/server/api/src/paths/case-reviews.yaml b/apps/server/api/src/paths/case-reviews.yaml index 8b04e48..0a51fce 100644 --- a/apps/server/api/src/paths/case-reviews.yaml +++ b/apps/server/api/src/paths/case-reviews.yaml @@ -42,5 +42,7 @@ post: $ref: "../components/review.yaml#/ReviewOutcome" "400": $ref: "../components/responses.yaml#/BadRequest" + "423": + $ref: "../components/responses.yaml#/Held" "404": $ref: "../components/responses.yaml#/NotFound" diff --git a/apps/server/api/src/paths/comment-moves.yaml b/apps/server/api/src/paths/comment-moves.yaml index 39ce0f6..b0b3136 100644 --- a/apps/server/api/src/paths/comment-moves.yaml +++ b/apps/server/api/src/paths/comment-moves.yaml @@ -205,6 +205,8 @@ Judgment: $ref: "#/MoveApplied" "400": $ref: "../components/responses.yaml#/BadRequest" + "423": + $ref: "../components/responses.yaml#/Held" "404": $ref: "../components/responses.yaml#/NotFound" "409": @@ -252,6 +254,8 @@ Judgment: $ref: "#/MoveApplied" "400": $ref: "../components/responses.yaml#/BadRequest" + "423": + $ref: "../components/responses.yaml#/Held" "404": $ref: "../components/responses.yaml#/NotFound" "409": diff --git a/apps/server/api/src/paths/recording-judgment.yaml b/apps/server/api/src/paths/recording-judgment.yaml index 8fa12d6..8f3c04a 100644 --- a/apps/server/api/src/paths/recording-judgment.yaml +++ b/apps/server/api/src/paths/recording-judgment.yaml @@ -50,6 +50,8 @@ post: $ref: "../components/responses.yaml#/Unauthenticated" '403': $ref: "../components/responses.yaml#/Forbidden" + "423": + $ref: "../components/responses.yaml#/Held" "404": $ref: "../components/responses.yaml#/NotFound" @@ -78,5 +80,7 @@ delete: $ref: "../components/responses.yaml#/Unauthenticated" '403': $ref: "../components/responses.yaml#/Forbidden" + "423": + $ref: "../components/responses.yaml#/Held" "404": $ref: "../components/responses.yaml#/NotFound" diff --git a/apps/server/db/migrations/00021_a_case_is_held.sql b/apps/server/db/migrations/00021_a_case_is_held.sql new file mode 100644 index 0000000..01cbede --- /dev/null +++ b/apps/server/db/migrations/00021_a_case_is_held.sql @@ -0,0 +1,14 @@ +-- +goose Up +-- Occupancy is its own axis (ADR 0005, #95): who is reviewing a case right +-- now, never folded into the cycle state. One live row per case; expiry is +-- read, not written — a lock whose heartbeat went silent past the window +-- simply stops counting. +CREATE TABLE case_locks ( + case_id text PRIMARY KEY REFERENCES cases(id) ON DELETE CASCADE, + account_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE, + claimed_at timestamptz NOT NULL DEFAULT now(), + beaten_at timestamptz NOT NULL DEFAULT now() +); + +-- +goose Down +DROP TABLE case_locks; diff --git a/apps/server/db/queries/aggregates.sql b/apps/server/db/queries/aggregates.sql index b0899c7..72a0679 100644 --- a/apps/server/db/queries/aggregates.sql +++ b/apps/server/db/queries/aggregates.sql @@ -361,3 +361,35 @@ SET capture_id = ( LIMIT 1 ) WHERE cv.comment_id = @comment_id AND cv.variant_id = @variant_id; + +-- Claiming is also renewing (ADR 0005, #95): one atomic statement takes a +-- free or expired lock, or beats the caller's own. Somebody else's live lock +-- makes the upsert a no-op — no row comes back, and the caller reads who +-- holds it instead. The window rides in as seconds so expiry is read, never +-- written. +-- name: ClaimCaseLock :one +INSERT INTO case_locks (case_id, account_id) +VALUES (@case_id, @account_id) +ON CONFLICT (case_id) DO UPDATE +SET account_id = EXCLUDED.account_id, + claimed_at = CASE + WHEN case_locks.account_id = EXCLUDED.account_id + AND case_locks.beaten_at > now() - make_interval(secs => @window_seconds::int) + THEN case_locks.claimed_at + ELSE now() + END, + beaten_at = now() +WHERE case_locks.account_id = EXCLUDED.account_id + OR case_locks.beaten_at < now() - make_interval(secs => @window_seconds::int) +RETURNING case_id, account_id, claimed_at; + +-- name: ReadCaseLock :one +SELECT l.account_id, l.claimed_at, u.name AS holder_name +FROM case_locks l +JOIN users u ON u.id = l.account_id +WHERE l.case_id = @case_id + AND l.beaten_at > now() - make_interval(secs => @window_seconds::int); + +-- Releasing somebody else's lock, or one nobody holds, changes nothing. +-- name: ReleaseCaseLock :exec +DELETE FROM case_locks WHERE case_id = $1 AND account_id = $2; diff --git a/apps/server/internal/adapters/postgres/catalogue.go b/apps/server/internal/adapters/postgres/catalogue.go index 53e29f5..a95b015 100644 --- a/apps/server/internal/adapters/postgres/catalogue.go +++ b/apps/server/internal/adapters/postgres/catalogue.go @@ -115,7 +115,15 @@ func (r *Repository) CaseByID(ctx context.Context, slug, id string) (catalogue.C if err != nil { return catalogue.Case{}, translate("reading the case", err) } - return toCase(row), nil + out := toCase(row) + // The holder rides along (ADR 0005): occupancy shown, never stored in + // the state. + held, err := r.holderOf(ctx, r.q, row.ID) + if err != nil { + return catalogue.Case{}, err + } + out.Held = held + return out, nil } func (r *Repository) ListCases(ctx context.Context, projectID string, state, categoryID *string) ([]catalogue.Case, error) { diff --git a/apps/server/internal/adapters/postgres/comment.go b/apps/server/internal/adapters/postgres/comment.go index af3c280..7ef5bb2 100644 --- a/apps/server/internal/adapters/postgres/comment.go +++ b/apps/server/internal/adapters/postgres/comment.go @@ -480,6 +480,15 @@ func (r *Repository) move( return appcomment.Outcome{}, translate("reading the comment", err) } + // A held case takes no verdict but its holder's (ADR 0005, #95). The + // dev's moves — tracking, delivering, discarding — are not verdicts and + // pass untouched. + if m == review.MoveAccept || m == review.MoveRefuse || m == review.MoveUnjudge { + if err := r.refuseHeld(ctx, q, comment.CaseID, by); err != nil { + return appcomment.Outcome{}, err + } + } + // Each move decides for itself: discard consults the comment machine, the // ref moves consult the ref machine and derive the comment from its refs. // A refused move is the domain's answer, not a database failure. diff --git a/apps/server/internal/adapters/postgres/lock.go b/apps/server/internal/adapters/postgres/lock.go new file mode 100644 index 0000000..c00da75 --- /dev/null +++ b/apps/server/internal/adapters/postgres/lock.go @@ -0,0 +1,108 @@ +package postgres + +import ( + "context" + "time" + + "github.com/haribo/ozalid/apps/server/internal/adapters/postgres/sqlcgen" + "github.com/haribo/ozalid/apps/server/internal/domain/actor" + "github.com/haribo/ozalid/apps/server/internal/domain/review" +) + +// lockWindow is how long a silent heartbeat keeps holding (ADR 0005, #95). +// A field rather than a constant: the window is a product parameter +// (product.md §3.2), and the tests shrink it to prove expiry. +const defaultLockWindow = 2 * time.Minute + +// SetLockWindow overrides the expiry window (OZALID_LOCK_WINDOW). +func (r *Repository) SetLockWindow(d time.Duration) { r.lockWindow = d } + +func (r *Repository) window() int32 { + w := r.lockWindow + if w <= 0 { + w = defaultLockWindow + } + return int32(w / time.Second) +} + +// ClaimCase takes the case's lock, or renews it: one atomic statement claims +// a free or expired lock and beats the caller's own. Somebody else's live +// lock answers review.ErrHeld, naming the holder (ADR 0005). +func (r *Repository) ClaimCase(ctx context.Context, slug, caseID string, by actor.Actor) (review.Hold, error) { + kase, err := r.q.CaseInProject(ctx, sqlcgen.CaseInProjectParams{ID: caseID, Slug: slug}) + if err != nil { + if isNoRows(err) { + return review.Hold{}, review.ErrMoveNotAllowed + } + return review.Hold{}, translate("finding the case", err) + } + row, err := r.q.ClaimCaseLock(ctx, sqlcgen.ClaimCaseLockParams{ + CaseID: kase.ID, AccountID: by.ID, WindowSeconds: r.window(), + }) + if err == nil { + hold := review.Hold{By: row.AccountID, Since: row.ClaimedAt.Time} + if own, err := r.holderOf(ctx, r.q, kase.ID); err == nil && own != nil { + hold.Name = own.Name + } + return hold, nil + } + if !isNoRows(err) { + return review.Hold{}, translate("claiming the case", err) + } + held, err := r.holderOf(ctx, r.q, kase.ID) + if err != nil { + return review.Hold{}, err + } + if held == nil { + // The lock moved between the two reads; the caller retries. + return review.Hold{}, review.ErrMoveNotAllowed + } + return review.Hold{}, &review.Held{By: held.By, Name: held.Name, Since: held.Since} +} + +// ReleaseCase lets go. Releasing a lock nobody holds, or somebody else's, +// changes nothing (ADR 0005's validation). +func (r *Repository) ReleaseCase(ctx context.Context, slug, caseID string, by actor.Actor) error { + kase, err := r.q.CaseInProject(ctx, sqlcgen.CaseInProjectParams{ID: caseID, Slug: slug}) + if err != nil { + if isNoRows(err) { + return review.ErrMoveNotAllowed + } + return translate("finding the case", err) + } + if err := r.q.ReleaseCaseLock(ctx, sqlcgen.ReleaseCaseLockParams{ + CaseID: kase.ID, AccountID: by.ID, + }); err != nil { + return translate("releasing the case", err) + } + return nil +} + +// holderOf reads who holds the case right now, nil when it is free — an +// expired heartbeat simply stops counting. +func (r *Repository) holderOf(ctx context.Context, q *sqlcgen.Queries, caseID string) (*review.Held, error) { + row, err := q.ReadCaseLock(ctx, sqlcgen.ReadCaseLockParams{ + CaseID: caseID, WindowSeconds: r.window(), + }) + if err != nil { + if isNoRows(err) { + return nil, nil + } + return nil, translate("reading the lock", err) + } + return &review.Held{By: row.AccountID, Name: row.HolderName, Since: row.ClaimedAt.Time}, nil +} + +// refuseHeld answers review.Held when somebody else holds the case: a held +// case takes no verdict but the holder's (ADR 0005). Every reviewer write +// path calls it; the dev's moves never do. +func (r *Repository) refuseHeld(ctx context.Context, q *sqlcgen.Queries, caseID string, by actor.Actor) error { + held, err := r.holderOf(ctx, q, caseID) + if err != nil { + return err + } + if held != nil && held.By != by.ID { + return held + } + return nil +} diff --git a/apps/server/internal/adapters/postgres/lock_test.go b/apps/server/internal/adapters/postgres/lock_test.go new file mode 100644 index 0000000..981416a --- /dev/null +++ b/apps/server/internal/adapters/postgres/lock_test.go @@ -0,0 +1,141 @@ +package postgres_test + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/haribo/ozalid/apps/server/internal/adapters/postgres" + "github.com/haribo/ozalid/apps/server/internal/adapters/postgres/sqlcgen" + "github.com/haribo/ozalid/apps/server/internal/app/session" + "github.com/haribo/ozalid/apps/server/internal/domain/actor" + "github.com/haribo/ozalid/apps/server/internal/domain/review" +) + +// reviewer registers a human so the lock can name them (ADR 0005). +func reviewer(t *testing.T, ctx context.Context, repo *postgres.Repository, name string) actor.Actor { + t.Helper() + u, err := repo.Queries().CreateUser(ctx, sqlcgen.CreateUserParams{ + Name: name, Email: name + "-" + t.Name() + "@ozalid.test", + }) + if err != nil { + t.Fatalf("registering %s: %v", name, err) + } + t.Cleanup(func() { + _, _ = repo.Pool().Exec(ctx, "DELETE FROM users WHERE id = $1", u.ID) + }) + return actor.Actor{ID: u.ID, Kind: actor.Human} +} + +// A held case takes no verdict but its holder's (ADR 0005, #95): the server +// refuses, whatever the screen offers — and holding never touches the state, +// because occupancy is not a state. +func TestAHeldCaseRefusesAnotherReviewersVerdict(t *testing.T) { + ctx, repo, project, kase := intakeFixture(t) + captures := seedGrid(t, ctx, repo, project, kase) + nina := reviewer(t, ctx, repo, "nina") + marc := reviewer(t, ctx, repo, "marc") + stateBefore := caseState(t, ctx, repo, project.Slug, kase.ID) + + if _, err := repo.ClaimCase(ctx, project.Slug, kase.ID, nina); err != nil { + t.Fatalf("nina claiming: %v", err) + } + if state := caseState(t, ctx, repo, project.Slug, kase.ID); state != stateBefore { + t.Errorf("state = %q after the claim, want %q — occupancy is not a state", state, stateBefore) + } + + // Marc's verdicts are refused, and the refusal names the holder. + var held *review.Held + if _, err := repo.SaveReview(ctx, project.Slug, kase.ID, marc, session.Save{ + Accepted: captures[:1], + }); !errors.As(err, &held) || held.Name != "nina" { + t.Errorf("save by marc = %v, want Held naming nina", err) + } + // The holder keeps working. + if _, err := repo.SaveReview(ctx, project.Slug, kase.ID, nina, session.Save{ + Accepted: captures[:1], + }); err != nil { + t.Errorf("save by nina = %v, want it accepted — she holds the case", err) + } + + // Releasing somebody else's lock changes nothing; releasing one's own + // frees the case for the next reviewer. + if err := repo.ReleaseCase(ctx, project.Slug, kase.ID, marc); err != nil { + t.Fatalf("marc releasing a lock he does not hold: %v", err) + } + if _, err := repo.SaveReview(ctx, project.Slug, kase.ID, marc, session.Save{ + Accepted: captures[1:2], + }); !errors.As(err, &held) { + t.Errorf("save by marc = %v, want still refused — his release was a no-op", err) + } + if err := repo.ReleaseCase(ctx, project.Slug, kase.ID, nina); err != nil { + t.Fatalf("nina releasing: %v", err) + } + if _, err := repo.SaveReview(ctx, project.Slug, kase.ID, marc, session.Save{ + Accepted: captures[1:2], + }); err != nil { + t.Errorf("save by marc after the release = %v, want it accepted", err) + } +} + +// A lock whose heartbeat goes silent is released on its own: the next +// reviewer takes it without an administrator involved (ADR 0005). +func TestASilentLockExpiresOnItsOwn(t *testing.T) { + ctx, repo, project, kase := intakeFixture(t) + seedGrid(t, ctx, repo, project, kase) + nina := reviewer(t, ctx, repo, "nina") + marc := reviewer(t, ctx, repo, "marc") + repo.SetLockWindow(1 * time.Second) + + if _, err := repo.ClaimCase(ctx, project.Slug, kase.ID, nina); err != nil { + t.Fatalf("nina claiming: %v", err) + } + var held *review.Held + if _, err := repo.ClaimCase(ctx, project.Slug, kase.ID, marc); !errors.As(err, &held) { + t.Fatalf("marc claiming a live lock = %v, want Held", err) + } + + time.Sleep(1200 * time.Millisecond) + if _, err := repo.ClaimCase(ctx, project.Slug, kase.ID, marc); err != nil { + t.Errorf("marc claiming after the silence = %v, want the expired lock taken", err) + } +} + +// Two reviewers reaching for the lock at the same moment: one gets it, the +// other is told who holds it. In parallel — a check that reads before it +// writes is a check both pass (ADR 0005's validation). +func TestTwoReviewersRaceForTheLock(t *testing.T) { + ctx, repo, project, kase := intakeFixture(t) + seedGrid(t, ctx, repo, project, kase) + nina := reviewer(t, ctx, repo, "nina") + marc := reviewer(t, ctx, repo, "marc") + + var wg sync.WaitGroup + results := make([]error, 2) + for i, by := range []actor.Actor{nina, marc} { + wg.Add(1) + go func() { + defer wg.Done() + _, results[i] = repo.ClaimCase(ctx, project.Slug, kase.ID, by) + }() + } + wg.Wait() + + var wins, refusals int + for _, err := range results { + var held *review.Held + switch { + case err == nil: + wins++ + case errors.As(err, &held): + refusals++ + default: + t.Fatalf("unexpected claim answer: %v", err) + } + } + if wins != 1 || refusals != 1 { + t.Errorf("wins = %d, refusals = %d — want exactly one of each", wins, refusals) + } +} diff --git a/apps/server/internal/adapters/postgres/postgres.go b/apps/server/internal/adapters/postgres/postgres.go index ef3de47..7e7a8eb 100644 --- a/apps/server/internal/adapters/postgres/postgres.go +++ b/apps/server/internal/adapters/postgres/postgres.go @@ -8,6 +8,7 @@ package postgres import ( "context" "fmt" + "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" @@ -19,6 +20,9 @@ import ( type Repository struct { pool *pgxpool.Pool q *sqlcgen.Queries + // lockWindow is how long a silent heartbeat keeps holding (ADR 0005); + // zero means the default. + lockWindow time.Duration } // Open connects to the database and verifies it answers. diff --git a/apps/server/internal/adapters/postgres/recording.go b/apps/server/internal/adapters/postgres/recording.go index 5b38fc2..49f2fad 100644 --- a/apps/server/internal/adapters/postgres/recording.go +++ b/apps/server/internal/adapters/postgres/recording.go @@ -57,6 +57,10 @@ func (r *Repository) moveRecording( if err != nil { return "", translate("reading the case", err) } + // A held case takes no verdict but its holder's (ADR 0005, #95). + if err := r.refuseHeld(ctx, q, kase.ID, by); err != nil { + return "", err + } before := review.CaseState(kase.State) var remarkPtr *string diff --git a/apps/server/internal/adapters/postgres/session.go b/apps/server/internal/adapters/postgres/session.go index e37faab..a8ee92a 100644 --- a/apps/server/internal/adapters/postgres/session.go +++ b/apps/server/internal/adapters/postgres/session.go @@ -39,6 +39,10 @@ func (r *Repository) SaveReview( if err != nil { return session.Result{}, translate("reading the case", err) } + // A held case takes no verdict but its holder's (ADR 0005, #95). + if err := r.refuseHeld(ctx, q, caseID, by); err != nil { + return session.Result{}, err + } before := review.CaseState(kase.State) for _, c := range save.Comments { diff --git a/apps/server/internal/adapters/postgres/sqlcgen/aggregates.sql.go b/apps/server/internal/adapters/postgres/sqlcgen/aggregates.sql.go index bf2bb3e..cdcfcac 100644 --- a/apps/server/internal/adapters/postgres/sqlcgen/aggregates.sql.go +++ b/apps/server/internal/adapters/postgres/sqlcgen/aggregates.sql.go @@ -579,6 +579,47 @@ func (q *Queries) CategoryTreeWithCounts(ctx context.Context, projectID string) return items, nil } +const claimCaseLock = `-- name: ClaimCaseLock :one +INSERT INTO case_locks (case_id, account_id) +VALUES ($1, $2) +ON CONFLICT (case_id) DO UPDATE +SET account_id = EXCLUDED.account_id, + claimed_at = CASE + WHEN case_locks.account_id = EXCLUDED.account_id + AND case_locks.beaten_at > now() - make_interval(secs => $3::int) + THEN case_locks.claimed_at + ELSE now() + END, + beaten_at = now() +WHERE case_locks.account_id = EXCLUDED.account_id + OR case_locks.beaten_at < now() - make_interval(secs => $3::int) +RETURNING case_id, account_id, claimed_at +` + +type ClaimCaseLockParams struct { + CaseID string + AccountID string + WindowSeconds int32 +} + +type ClaimCaseLockRow struct { + CaseID string + AccountID string + ClaimedAt pgtype.Timestamptz +} + +// Claiming is also renewing (ADR 0005, #95): one atomic statement takes a +// free or expired lock, or beats the caller's own. Somebody else's live lock +// makes the upsert a no-op — no row comes back, and the caller reads who +// holds it instead. The window rides in as seconds so expiry is read, never +// written. +func (q *Queries) ClaimCaseLock(ctx context.Context, arg ClaimCaseLockParams) (ClaimCaseLockRow, error) { + row := q.db.QueryRow(ctx, claimCaseLock, arg.CaseID, arg.AccountID, arg.WindowSeconds) + var i ClaimCaseLockRow + err := row.Scan(&i.CaseID, &i.AccountID, &i.ClaimedAt) + return i, err +} + const commentCoveredVariants = `-- name: CommentCoveredVariants :many SELECT variant_id FROM comment_variants WHERE comment_id = $1 ` @@ -904,6 +945,32 @@ func (q *Queries) GetCommentIssue(ctx context.Context, arg GetCommentIssueParams return items, nil } +const readCaseLock = `-- name: ReadCaseLock :one +SELECT l.account_id, l.claimed_at, u.name AS holder_name +FROM case_locks l +JOIN users u ON u.id = l.account_id +WHERE l.case_id = $1 + AND l.beaten_at > now() - make_interval(secs => $2::int) +` + +type ReadCaseLockParams struct { + CaseID string + WindowSeconds int32 +} + +type ReadCaseLockRow struct { + AccountID string + ClaimedAt pgtype.Timestamptz + HolderName string +} + +func (q *Queries) ReadCaseLock(ctx context.Context, arg ReadCaseLockParams) (ReadCaseLockRow, error) { + row := q.db.QueryRow(ctx, readCaseLock, arg.CaseID, arg.WindowSeconds) + var i ReadCaseLockRow + err := row.Scan(&i.AccountID, &i.ClaimedAt, &i.HolderName) + return i, err +} + const reanchorCommentVariant = `-- name: ReanchorCommentVariant :exec UPDATE comment_variants cv SET capture_id = ( @@ -984,6 +1051,21 @@ func (q *Queries) RecordJudgment(ctx context.Context, arg RecordJudgmentParams) return err } +const releaseCaseLock = `-- name: ReleaseCaseLock :exec +DELETE FROM case_locks WHERE case_id = $1 AND account_id = $2 +` + +type ReleaseCaseLockParams struct { + CaseID string + AccountID string +} + +// Releasing somebody else's lock, or one nobody holds, changes nothing. +func (q *Queries) ReleaseCaseLock(ctx context.Context, arg ReleaseCaseLockParams) error { + _, err := q.db.Exec(ctx, releaseCaseLock, arg.CaseID, arg.AccountID) + return err +} + const releaseCommentVariant = `-- name: ReleaseCommentVariant :execrows DELETE FROM comment_variants WHERE comment_id = $1 AND variant_id = $2 ` diff --git a/apps/server/internal/adapters/postgres/sqlcgen/models.go b/apps/server/internal/adapters/postgres/sqlcgen/models.go index 7541ead..aaee36f 100644 --- a/apps/server/internal/adapters/postgres/sqlcgen/models.go +++ b/apps/server/internal/adapters/postgres/sqlcgen/models.go @@ -62,6 +62,13 @@ type Case struct { CurrentEditionID *string } +type CaseLock struct { + CaseID string + AccountID string + ClaimedAt pgtype.Timestamptz + BeatenAt pgtype.Timestamptz +} + type Category struct { ID string ProjectID string diff --git a/apps/server/internal/app/session/session.go b/apps/server/internal/app/session/session.go index 8a7371e..71bc94c 100644 --- a/apps/server/internal/app/session/session.go +++ b/apps/server/internal/app/session/session.go @@ -61,6 +61,8 @@ type Result struct { // the transaction that guarantees it belongs in the adapter (backend ADR 0001). type Repository interface { SaveReview(ctx context.Context, slug, caseID string, by actor.Actor, save Save) (Result, error) + ClaimCase(ctx context.Context, slug, caseID string, by actor.Actor) (review.Hold, error) + ReleaseCase(ctx context.Context, slug, caseID string, by actor.Actor) error } // Service saves review sessions. @@ -69,6 +71,17 @@ type Service struct{ repo Repository } // New returns a Service backed by repo. func New(repo Repository) *Service { return &Service{repo: repo} } +// Claim holds the case for this reviewer, or renews the hold — the same call +// is the heartbeat (ADR 0005, #95). +func (s *Service) Claim(ctx context.Context, slug, caseID string, by actor.Actor) (review.Hold, error) { + return s.repo.ClaimCase(ctx, slug, caseID, by) +} + +// Release lets the case go; a lock not held answers nothing. +func (s *Service) Release(ctx context.Context, slug, caseID string, by actor.Actor) error { + return s.repo.ReleaseCase(ctx, slug, caseID, by) +} + // Save validates the session and records it. // // Validation happens before anything is written: a session carrying one diff --git a/apps/server/internal/app/session/session_test.go b/apps/server/internal/app/session/session_test.go index 4296523..70f52fd 100644 --- a/apps/server/internal/app/session/session_test.go +++ b/apps/server/internal/app/session/session_test.go @@ -3,6 +3,7 @@ package session_test import ( "context" "errors" + "github.com/haribo/ozalid/apps/server/internal/domain/review" "testing" "github.com/haribo/ozalid/apps/server/internal/app/session" @@ -20,6 +21,16 @@ func (r refusingRepo) SaveReview( return session.Result{}, nil } +func (r refusingRepo) ClaimCase(context.Context, string, string, actor.Actor) (review.Hold, error) { + r.t.Error("the session reached the repository, want it refused first") + return review.Hold{}, nil +} + +func (r refusingRepo) ReleaseCase(context.Context, string, string, actor.Actor) error { + r.t.Error("the session reached the repository, want it refused first") + return nil +} + // recordingRepo remembers what the service handed it. type recordingRepo struct{ got session.Save } @@ -30,6 +41,14 @@ func (r *recordingRepo) SaveReview( return session.Result{}, nil } +func (r *recordingRepo) ClaimCase(context.Context, string, string, actor.Actor) (review.Hold, error) { + return review.Hold{}, nil +} + +func (r *recordingRepo) ReleaseCase(context.Context, string, string, actor.Actor) error { + return nil +} + func TestACommentWithNothingWrittenInItIsRefused(t *testing.T) { svc := session.New(refusingRepo{t}) diff --git a/apps/server/internal/domain/catalogue/catalogue.go b/apps/server/internal/domain/catalogue/catalogue.go index 29e1184..d3655c6 100644 --- a/apps/server/internal/domain/catalogue/catalogue.go +++ b/apps/server/internal/domain/catalogue/catalogue.go @@ -80,6 +80,9 @@ type Case struct { ArchivedAt *time.Time CreatedAt time.Time UpdatedAt time.Time + // Held names who is reviewing this case right now, nil when free. + // Occupancy, never a state (ADR 0005, #95). + Held *review.Held } // Archived reports whether the case has left the catalogue. An archived case diff --git a/apps/server/internal/domain/review/state.go b/apps/server/internal/domain/review/state.go index 5d170b6..77c602e 100644 --- a/apps/server/internal/domain/review/state.go +++ b/apps/server/internal/domain/review/state.go @@ -6,6 +6,8 @@ // (ADR 0002, backend ADR 0001). package review +import "time" + // CaseState answers one question: who holds the ball (ADR 0012). type CaseState string @@ -39,3 +41,20 @@ const ( func (s CommentState) Open() bool { return s != CommentAccepted && s != CommentDiscarded } + +// Hold says who holds a case and since when (ADR 0005): occupancy, never a +// state. +type Hold struct { + By string + Name string + Since time.Time +} + +// Held is the refusal a held case gives every reviewer but its holder. +type Held struct { + By string + Name string + Since time.Time +} + +func (h *Held) Error() string { return "review: held by " + h.By } diff --git a/apps/server/internal/ports/http/catalogue.go b/apps/server/internal/ports/http/catalogue.go index 7740a1d..39703e2 100644 --- a/apps/server/internal/ports/http/catalogue.go +++ b/apps/server/internal/ports/http/catalogue.go @@ -3,6 +3,7 @@ package http import ( "context" "errors" + "github.com/haribo/ozalid/apps/server/internal/domain/review" "net/http" app "github.com/haribo/ozalid/apps/server/internal/app/catalogue" @@ -450,9 +451,17 @@ func toAPICase(c catalogue.Case) openapi.Case { Archived: c.Archived(), CreatedAt: c.CreatedAt, UpdatedAt: c.UpdatedAt, + Held: toAPIHold(c.Held), } } +func toAPIHold(h *review.Held) *openapi.Hold { + if h == nil { + return nil + } + return &openapi.Hold{By: h.By, Name: h.Name, Since: h.Since} +} + func toAPICategory(c catalogue.Category) openapi.Category { return openapi.Category{ Id: c.ID, diff --git a/apps/server/internal/ports/http/comment.go b/apps/server/internal/ports/http/comment.go index 69d6a19..8790345 100644 --- a/apps/server/internal/ports/http/comment.go +++ b/apps/server/internal/ports/http/comment.go @@ -173,6 +173,10 @@ func (s *Server) JudgeComment(ctx context.Context, request openapi.JudgeCommentR }, nil case isRefusedMove(err): return openapi.JudgeComment409ApplicationProblemPlusJSONResponse{MoveRefusedApplicationProblemPlusJSONResponse: openapi.MoveRefusedApplicationProblemPlusJSONResponse(refusedMove(err))}, nil + case heldOf(err) != nil: + return openapi.JudgeComment423ApplicationProblemPlusJSONResponse{ + HeldApplicationProblemPlusJSONResponse: openapi.HeldApplicationProblemPlusJSONResponse(heldProblem(heldOf(err))), + }, nil case err != nil: return nil, err } @@ -217,6 +221,10 @@ func (s *Server) UnjudgeComment(ctx context.Context, request openapi.UnjudgeComm }, nil case isRefusedMove(err): return openapi.UnjudgeComment409ApplicationProblemPlusJSONResponse{MoveRefusedApplicationProblemPlusJSONResponse: openapi.MoveRefusedApplicationProblemPlusJSONResponse(refusedMove(err))}, nil + case heldOf(err) != nil: + return openapi.UnjudgeComment423ApplicationProblemPlusJSONResponse{ + HeldApplicationProblemPlusJSONResponse: openapi.HeldApplicationProblemPlusJSONResponse(heldProblem(heldOf(err))), + }, nil case err != nil: return nil, err } diff --git a/apps/server/internal/ports/http/lock.go b/apps/server/internal/ports/http/lock.go new file mode 100644 index 0000000..8afdee3 --- /dev/null +++ b/apps/server/internal/ports/http/lock.go @@ -0,0 +1,85 @@ +package http + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/haribo/ozalid/apps/server/internal/domain/access" + "github.com/haribo/ozalid/apps/server/internal/domain/review" + "github.com/haribo/ozalid/apps/server/internal/ports/http/openapi" +) + +// heldOf unwraps the holder from an error, nil when it is something else. +func heldOf(err error) *review.Held { + var h *review.Held + if errors.As(err, &h) { + return h + } + return nil +} + +// heldProblem is the 423 a held case answers, naming the holder (ADR 0005). +func heldProblem(h *review.Held) openapi.Problem { + return problem("held", "Somebody is reviewing this case", http.StatusLocked, + fmt.Sprintf("%s holds this case since %s. A held case takes no verdict but theirs.", + h.Name, h.Since.UTC().Format("15:04"))) +} + +// ClaimCase holds the case for the caller, or renews the hold — the same +// call is the heartbeat (ADR 0005, #95). +func (s *Server) ClaimCase(ctx context.Context, request openapi.ClaimCaseRequestObject) (openapi.ClaimCaseResponseObject, error) { + if why, no := s.mayNot(ctx, request.Slug, access.WriteProject); no { + if why.Status == http.StatusUnauthorized { + return openapi.ClaimCase401ApplicationProblemPlusJSONResponse{ + UnauthenticatedApplicationProblemPlusJSONResponse: openapi.UnauthenticatedApplicationProblemPlusJSONResponse(why), + }, nil + } + return openapi.ClaimCase403ApplicationProblemPlusJSONResponse{ + ForbiddenApplicationProblemPlusJSONResponse: openapi.ForbiddenApplicationProblemPlusJSONResponse(why), + }, nil + } + + hold, err := s.session.Claim(ctx, request.Slug, request.CaseId, actorFrom(ctx)) + var held *review.Held + switch { + case errors.As(err, &held): + return openapi.ClaimCase423ApplicationProblemPlusJSONResponse{ + HeldApplicationProblemPlusJSONResponse: openapi.HeldApplicationProblemPlusJSONResponse(heldProblem(held)), + }, nil + case errors.Is(err, review.ErrMoveNotAllowed): + return openapi.ClaimCase404ApplicationProblemPlusJSONResponse{ + NotFoundApplicationProblemPlusJSONResponse: notFound("case"), + }, nil + case err != nil: + return nil, err + } + return openapi.ClaimCase200JSONResponse{By: hold.By, Name: hold.Name, Since: hold.Since}, nil +} + +// ReleaseCase lets the case go. Releasing a lock nobody holds, or somebody +// else's, changes nothing. +func (s *Server) ReleaseCase(ctx context.Context, request openapi.ReleaseCaseRequestObject) (openapi.ReleaseCaseResponseObject, error) { + if why, no := s.mayNot(ctx, request.Slug, access.WriteProject); no { + if why.Status == http.StatusUnauthorized { + return openapi.ReleaseCase401ApplicationProblemPlusJSONResponse{ + UnauthenticatedApplicationProblemPlusJSONResponse: openapi.UnauthenticatedApplicationProblemPlusJSONResponse(why), + }, nil + } + return openapi.ReleaseCase403ApplicationProblemPlusJSONResponse{ + ForbiddenApplicationProblemPlusJSONResponse: openapi.ForbiddenApplicationProblemPlusJSONResponse(why), + }, nil + } + + err := s.session.Release(ctx, request.Slug, request.CaseId, actorFrom(ctx)) + switch { + case errors.Is(err, review.ErrMoveNotAllowed): + return openapi.ReleaseCase404ApplicationProblemPlusJSONResponse{ + NotFoundApplicationProblemPlusJSONResponse: notFound("case"), + }, nil + case err != nil: + return nil, err + } + return openapi.ReleaseCase204Response{}, nil +} diff --git a/apps/server/internal/ports/http/openapi/server.gen.go b/apps/server/internal/ports/http/openapi/server.gen.go index 398c0f1..a259b68 100644 --- a/apps/server/internal/ports/http/openapi/server.gen.go +++ b/apps/server/internal/ports/http/openapi/server.gen.go @@ -326,6 +326,9 @@ type Case struct { CreatedAt time.Time `json:"createdAt"` Description *string `json:"description,omitempty"` + // Held Present while a reviewer holds the case (ADR 0005, + Held *Hold `json:"held,omitempty"` + // Id Generated by the server when the case was created. Stable forever. Id string `json:"id"` LastEdition *time.Time `json:"lastEdition,omitempty"` @@ -501,6 +504,13 @@ type GridVariant struct { Values map[string]string `json:"values"` } +// Hold Who holds the case, and since when. Occupancy, never a state (ADR 0005). +type Hold struct { + By string `json:"by"` + Name string `json:"name"` + Since time.Time `json:"since"` +} + // IntakeRefused defines model for IntakeRefused. type IntakeRefused struct { // Detail An explanation specific to this occurrence. @@ -898,6 +908,9 @@ type BadRequest = Problem // Forbidden defines model for Forbidden. type Forbidden = Problem +// Held defines model for Held. +type Held = Problem + // MoveApplied defines model for MoveApplied. type MoveApplied = MoveOutcome @@ -1142,6 +1155,12 @@ type ServerInterface interface { // ListComments Read what has been said about a case // (GET /projects/{slug}/cases/{caseId}/comments) ListComments(w http.ResponseWriter, r *http.Request, slug string, caseId string) + // ReleaseCase Let the case go + // (DELETE /projects/{slug}/cases/{caseId}/lock) + ReleaseCase(w http.ResponseWriter, r *http.Request, slug string, caseId string) + // ClaimCase Hold the case, or keep holding it + // (POST /projects/{slug}/cases/{caseId}/lock) + ClaimCase(w http.ResponseWriter, r *http.Request, slug string, caseId string) // SaveReview Save what one review session decided // (POST /projects/{slug}/cases/{caseId}/reviews) SaveReview(w http.ResponseWriter, r *http.Request, slug string, caseId string) @@ -1825,6 +1844,76 @@ func (siw *ServerInterfaceWrapper) ListComments(w http.ResponseWriter, r *http.R handler.ServeHTTP(w, r) } +// ReleaseCase operation middleware +func (siw *ServerInterfaceWrapper) ReleaseCase(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "slug" ------------- + var slug string + + err = runtime.BindStyledParameterWithOptions("simple", "slug", r.PathValue("slug"), &slug, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "slug", Err: err}) + return + } + + // ------------- Path parameter "caseId" ------------- + var caseId string + + err = runtime.BindStyledParameterWithOptions("simple", "caseId", r.PathValue("caseId"), &caseId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "caseId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ReleaseCase(w, r, slug, caseId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ClaimCase operation middleware +func (siw *ServerInterfaceWrapper) ClaimCase(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "slug" ------------- + var slug string + + err = runtime.BindStyledParameterWithOptions("simple", "slug", r.PathValue("slug"), &slug, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "slug", Err: err}) + return + } + + // ------------- Path parameter "caseId" ------------- + var caseId string + + err = runtime.BindStyledParameterWithOptions("simple", "caseId", r.PathValue("caseId"), &caseId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "caseId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ClaimCase(w, r, slug, caseId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // SaveReview operation middleware func (siw *ServerInterfaceWrapper) SaveReview(w http.ResponseWriter, r *http.Request) { @@ -2806,6 +2895,8 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/projects/{slug}/recordings/{recordingId}/judgment", wrapper.JudgeRecording) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/projects/{slug}/cases/{caseId}", wrapper.GetCase) m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/projects/{slug}/cases/{caseId}", wrapper.UpdateCase) + m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/projects/{slug}/cases/{caseId}/lock", wrapper.ReleaseCase) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/projects/{slug}/cases/{caseId}/lock", wrapper.ClaimCase) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/projects/{slug}/cases/{caseId}/captures", wrapper.GetCaseCaptures) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/projects/{slug}/cases/{caseId}/comments", wrapper.ListComments) m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/projects/{slug}/comments/{commentId}", wrapper.EditComment) @@ -2826,6 +2917,8 @@ type BadRequestApplicationProblemPlusJSONResponse Problem type ForbiddenApplicationProblemPlusJSONResponse Problem +type HeldApplicationProblemPlusJSONResponse Problem + type MoveAppliedJSONResponse MoveOutcome type MoveRefusedApplicationProblemPlusJSONResponse Problem @@ -4342,6 +4435,158 @@ func (response ListComments404ApplicationProblemPlusJSONResponse) VisitListComme return err } +type ReleaseCaseRequestObject struct { + Slug string `json:"slug"` + CaseId string `json:"caseId"` +} + +type ReleaseCaseResponseObject interface { + VisitReleaseCaseResponse(w http.ResponseWriter) error +} + +type ReleaseCase204Response struct { +} + +func (response ReleaseCase204Response) VisitReleaseCaseResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type ReleaseCase401ApplicationProblemPlusJSONResponse struct { + UnauthenticatedApplicationProblemPlusJSONResponse +} + +func (response ReleaseCase401ApplicationProblemPlusJSONResponse) VisitReleaseCaseResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(401) + _, err := buf.WriteTo(w) + return err +} + +type ReleaseCase403ApplicationProblemPlusJSONResponse struct { + ForbiddenApplicationProblemPlusJSONResponse +} + +func (response ReleaseCase403ApplicationProblemPlusJSONResponse) VisitReleaseCaseResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(403) + _, err := buf.WriteTo(w) + return err +} + +type ReleaseCase404ApplicationProblemPlusJSONResponse struct { + NotFoundApplicationProblemPlusJSONResponse +} + +func (response ReleaseCase404ApplicationProblemPlusJSONResponse) VisitReleaseCaseResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(404) + _, err := buf.WriteTo(w) + return err +} + +type ClaimCaseRequestObject struct { + Slug string `json:"slug"` + CaseId string `json:"caseId"` +} + +type ClaimCaseResponseObject interface { + VisitClaimCaseResponse(w http.ResponseWriter) error +} + +type ClaimCase200JSONResponse Hold + +func (response ClaimCase200JSONResponse) VisitClaimCaseResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, err := buf.WriteTo(w) + return err +} + +type ClaimCase401ApplicationProblemPlusJSONResponse struct { + UnauthenticatedApplicationProblemPlusJSONResponse +} + +func (response ClaimCase401ApplicationProblemPlusJSONResponse) VisitClaimCaseResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(401) + _, err := buf.WriteTo(w) + return err +} + +type ClaimCase403ApplicationProblemPlusJSONResponse struct { + ForbiddenApplicationProblemPlusJSONResponse +} + +func (response ClaimCase403ApplicationProblemPlusJSONResponse) VisitClaimCaseResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(403) + _, err := buf.WriteTo(w) + return err +} + +type ClaimCase404ApplicationProblemPlusJSONResponse struct { + NotFoundApplicationProblemPlusJSONResponse +} + +func (response ClaimCase404ApplicationProblemPlusJSONResponse) VisitClaimCaseResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(404) + _, err := buf.WriteTo(w) + return err +} + +type ClaimCase423ApplicationProblemPlusJSONResponse struct { + HeldApplicationProblemPlusJSONResponse +} + +func (response ClaimCase423ApplicationProblemPlusJSONResponse) VisitClaimCaseResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(423) + _, err := buf.WriteTo(w) + return err +} + type SaveReviewRequestObject struct { Slug string `json:"slug"` CaseId string `json:"caseId"` @@ -4430,6 +4675,22 @@ func (response SaveReview404ApplicationProblemPlusJSONResponse) VisitSaveReviewR return err } +type SaveReview423ApplicationProblemPlusJSONResponse struct { + HeldApplicationProblemPlusJSONResponse +} + +func (response SaveReview423ApplicationProblemPlusJSONResponse) VisitSaveReviewResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(423) + _, err := buf.WriteTo(w) + return err +} + type ListCategoriesRequestObject struct { Slug string `json:"slug"` } @@ -5182,6 +5443,22 @@ func (response UnjudgeComment409ApplicationProblemPlusJSONResponse) VisitUnjudge return err } +type UnjudgeComment423ApplicationProblemPlusJSONResponse struct { + HeldApplicationProblemPlusJSONResponse +} + +func (response UnjudgeComment423ApplicationProblemPlusJSONResponse) VisitUnjudgeCommentResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(423) + _, err := buf.WriteTo(w) + return err +} + type JudgeCommentRequestObject struct { Slug Slug `json:"slug"` CommentId CommentId `json:"commentId"` @@ -5286,6 +5563,22 @@ func (response JudgeComment409ApplicationProblemPlusJSONResponse) VisitJudgeComm return err } +type JudgeComment423ApplicationProblemPlusJSONResponse struct { + HeldApplicationProblemPlusJSONResponse +} + +func (response JudgeComment423ApplicationProblemPlusJSONResponse) VisitJudgeCommentResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(423) + _, err := buf.WriteTo(w) + return err +} + type TrackCommentRequestObject struct { Slug Slug `json:"slug"` CommentId CommentId `json:"commentId"` @@ -5868,6 +6161,22 @@ func (response UnjudgeRecording404ApplicationProblemPlusJSONResponse) VisitUnjud return err } +type UnjudgeRecording423ApplicationProblemPlusJSONResponse struct { + HeldApplicationProblemPlusJSONResponse +} + +func (response UnjudgeRecording423ApplicationProblemPlusJSONResponse) VisitUnjudgeRecordingResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(423) + _, err := buf.WriteTo(w) + return err +} + type JudgeRecordingRequestObject struct { Slug string `json:"slug"` RecordingId string `json:"recordingId"` @@ -5960,6 +6269,22 @@ func (response JudgeRecording404ApplicationProblemPlusJSONResponse) VisitJudgeRe return err } +type JudgeRecording423ApplicationProblemPlusJSONResponse struct { + HeldApplicationProblemPlusJSONResponse +} + +func (response JudgeRecording423ApplicationProblemPlusJSONResponse) VisitJudgeRecordingResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(423) + _, err := buf.WriteTo(w) + return err +} + type CreateServiceAccountRequestObject struct { Slug string `json:"slug"` Body *CreateServiceAccountJSONRequestBody @@ -6537,6 +6862,12 @@ type StrictServerInterface interface { // ListComments Read what has been said about a case // (GET /projects/{slug}/cases/{caseId}/comments) ListComments(ctx context.Context, request ListCommentsRequestObject) (ListCommentsResponseObject, error) + // ReleaseCase Let the case go + // (DELETE /projects/{slug}/cases/{caseId}/lock) + ReleaseCase(ctx context.Context, request ReleaseCaseRequestObject) (ReleaseCaseResponseObject, error) + // ClaimCase Hold the case, or keep holding it + // (POST /projects/{slug}/cases/{caseId}/lock) + ClaimCase(ctx context.Context, request ClaimCaseRequestObject) (ClaimCaseResponseObject, error) // SaveReview Save what one review session decided // (POST /projects/{slug}/cases/{caseId}/reviews) SaveReview(ctx context.Context, request SaveReviewRequestObject) (SaveReviewResponseObject, error) @@ -7247,6 +7578,60 @@ func (sh *strictHandler) ListComments(w http.ResponseWriter, r *http.Request, sl } } +// ReleaseCase operation middleware +func (sh *strictHandler) ReleaseCase(w http.ResponseWriter, r *http.Request, slug string, caseId string) { + var request ReleaseCaseRequestObject + + request.Slug = slug + request.CaseId = caseId + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.ReleaseCase(ctx, request.(ReleaseCaseRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ReleaseCase") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ReleaseCaseResponseObject); ok { + if err := validResponse.VisitReleaseCaseResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// ClaimCase operation middleware +func (sh *strictHandler) ClaimCase(w http.ResponseWriter, r *http.Request, slug string, caseId string) { + var request ClaimCaseRequestObject + + request.Slug = slug + request.CaseId = caseId + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.ClaimCase(ctx, request.(ClaimCaseRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ClaimCase") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ClaimCaseResponseObject); ok { + if err := validResponse.VisitClaimCaseResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // SaveReview operation middleware func (sh *strictHandler) SaveReview(w http.ResponseWriter, r *http.Request, slug string, caseId string) { var request SaveReviewRequestObject @@ -8083,239 +8468,247 @@ func (sh *strictHandler) WhoIsTheToken(w http.ResponseWriter, r *http.Request) { // const string: with thousands of chunks the chained `+` fold is several // times slower for the Go compiler than parsing a slice literal. var swaggerSpec = []string{ - "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", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/apps/server/internal/ports/http/recording.go b/apps/server/internal/ports/http/recording.go index c43c86a..77789c2 100644 --- a/apps/server/internal/ports/http/recording.go +++ b/apps/server/internal/ports/http/recording.go @@ -39,6 +39,10 @@ func (s *Server) JudgeRecording(ctx context.Context, request openapi.JudgeRecord return openapi.JudgeRecording404ApplicationProblemPlusJSONResponse{ NotFoundApplicationProblemPlusJSONResponse: notFound("recording"), }, nil + case heldOf(err) != nil: + return openapi.JudgeRecording423ApplicationProblemPlusJSONResponse{ + HeldApplicationProblemPlusJSONResponse: openapi.HeldApplicationProblemPlusJSONResponse(heldProblem(heldOf(err))), + }, nil case err != nil: return nil, err } @@ -64,6 +68,10 @@ func (s *Server) UnjudgeRecording(ctx context.Context, request openapi.UnjudgeRe return openapi.UnjudgeRecording404ApplicationProblemPlusJSONResponse{ NotFoundApplicationProblemPlusJSONResponse: notFound("recording"), }, nil + case heldOf(err) != nil: + return openapi.UnjudgeRecording423ApplicationProblemPlusJSONResponse{ + HeldApplicationProblemPlusJSONResponse: openapi.HeldApplicationProblemPlusJSONResponse(heldProblem(heldOf(err))), + }, nil case err != nil: return nil, err } diff --git a/apps/server/internal/ports/http/session.go b/apps/server/internal/ports/http/session.go index 18f23fa..2413f3c 100644 --- a/apps/server/internal/ports/http/session.go +++ b/apps/server/internal/ports/http/session.go @@ -39,6 +39,12 @@ func (s *Server) SaveReview(ctx context.Context, request openapi.SaveReviewReque NotFoundApplicationProblemPlusJSONResponse: notFound("case"), }, nil case err != nil: + var held *review.Held + if errors.As(err, &held) { + return openapi.SaveReview423ApplicationProblemPlusJSONResponse{ + HeldApplicationProblemPlusJSONResponse: openapi.HeldApplicationProblemPlusJSONResponse(heldProblem(held)), + }, nil + } return nil, err } diff --git a/apps/web/e2e/review.spec.ts b/apps/web/e2e/review.spec.ts index 246941d..fca2dd4 100644 --- a/apps/web/e2e/review.spec.ts +++ b/apps/web/e2e/review.spec.ts @@ -230,6 +230,10 @@ test('a verdict held through an expired session survives closing the carousel', data: { rights: 'member' }, }) + // The reviewer lets the case go before the session dies: resuming will + // come from another account, and a held case would refuse it (ADR 0005). + await page.request.delete(`/api/projects/${seeded.slug}/cases/${seeded.caseId}/lock`) + // The session dies under the verdict. await context.clearCookies() await page.keyboard.press(' ') @@ -395,3 +399,70 @@ test('the recording is judged in the carousel, and a new push resets it (#226)', detail = await request.get(`${API}/api/projects/${seeded.slug}/cases/${seeded.caseId}`) expect(((await detail.json()) as { state: string }).state).toBe('to-review') }) + +test('a held case reads the same and refuses the verdict (#95)', async ({ page, context }) => { + const seeded = await seed(page) + + // A second reviewer, onboarded by the admin through the interface. + const email = `second-${Date.now()}@example.test` + await page.goto('/accounts') + await page.getByRole('button', { name: 'New account' }).click() + await page.getByLabel('name').fill('marc') + await page.getByLabel('address').fill(email) + await page.getByRole('button', { name: 'Create the account' }).click() + await expect(page.getByRole('cell', { name: 'marc', exact: true })).toBeVisible() + await page.goto(`/projects/${seeded.slug}/access`) + await page.getByRole('button', { name: 'Add' }).first().click() + await page.getByLabel('account').selectOption({ label: 'marc' }) + await page.getByRole('button', { name: 'Add', exact: true }).last().click() + await expect(page.getByRole('cell', { name: 'marc', exact: true })).toBeVisible() + + // The first reviewer opens the case: opening is claiming (ADR 0005). + await page.goto(`/projects/${seeded.slug}/cases/${seeded.caseId}`) + await expect(page.locator('table').first()).toBeVisible() + + // Marc, in a browser of his own. + const theirs = await context.browser()!.newContext() + const their = await theirs.newPage() + const { emptyMailbox: empty, linkSentTo: link } = await import('./mailbox') + await empty(email) + await their.goto('/sign-in') + await their.getByLabel('address').fill(email) + await their.getByRole('button', { name: 'Send the link' }).click() + await expect(their.getByText('The link is on its way.')).toBeVisible() + await their.goto(`/sign-in/${await link(email)}`) + await expect(their.getByRole('button', { name: 'sign out' })).toBeVisible() + + // The server refuses his verdict, whatever the screen offers — and names + // the holder. The state reads the same as before the hold. + const grid = await ( + await theirs.request.get(`${API}/api/projects/${seeded.slug}/cases/${seeded.caseId}/captures`) + ).json() + const cell = { stepId: grid.steps[0].id, variantId: grid.steps[0].captures[0].variantId } + const refused = await theirs.request.post( + `${API}/api/projects/${seeded.slug}/cases/${seeded.caseId}/reviews`, + { data: { accepted: [cell] } }, + ) + expect(refused.status()).toBe(423) + expect(await refused.text()).toContain('e2e') + + const detail = await ( + await theirs.request.get(`${API}/api/projects/${seeded.slug}/cases/${seeded.caseId}`) + ).json() + expect(detail.state).toBe('to-review') + expect(detail.held.name).toBe('e2e') + + // Marc's own screen says who holds it. + await their.goto(`/projects/${seeded.slug}/cases/${seeded.caseId}`) + await expect(their.getByText('held by e2e')).toBeVisible() + + // The first reviewer walks away: leaving is letting go, and marc's next + // verdict lands. + await page.goto(`/projects/${seeded.slug}`) + const landed = await theirs.request.post( + `${API}/api/projects/${seeded.slug}/cases/${seeded.caseId}/reviews`, + { data: { accepted: [cell] } }, + ) + expect(landed.status()).toBe(200) + await theirs.close() +}) diff --git a/apps/web/src/features/review/useReview.ts b/apps/web/src/features/review/useReview.ts index 6669f57..50b41ea 100644 --- a/apps/web/src/features/review/useReview.ts +++ b/apps/web/src/features/review/useReview.ts @@ -125,6 +125,35 @@ export function useReview(slug: () => string, caseId: () => string) { await load() } + /** Who else holds the case (ADR 0005, #95): null while the caller does — + * or while nobody does. Filled from the claim's 423 and the case read. */ + const lockedBy = ref<{ name: string; since: string } | null>(null) + + /** Claim the case, or keep holding it — the same call is the heartbeat. + * A 423 means somebody else reviews: the page turns read-only. */ + async function claim() { + const result = await api.POST('/projects/{slug}/cases/{caseId}/lock', { + params: { path: { slug: slug(), caseId: caseId() } }, + }) + if (result.response.status === 423) { + const found = await api.GET('/projects/{slug}/cases/{caseId}', { + params: { path: { slug: slug(), caseId: caseId() } }, + }) + lockedBy.value = found.data?.held + ? { name: found.data.held.name, since: found.data.held.since } + : { name: 'somebody', since: '' } + return + } + if (!result.error) lockedBy.value = null + } + + /** Let the case go — leaving the page is letting go. */ + async function release() { + await api.DELETE('/projects/{slug}/cases/{caseId}/lock', { + params: { path: { slug: slug(), caseId: caseId() } }, + }) + } + /** Judge the flow video on screen (ADR 0023): the verdict lands on * exactly these bytes, and a new edition resets it. */ async function judgeRecording(recordingId: string, accept: boolean, remark: string) { @@ -228,6 +257,9 @@ export function useReview(slug: () => string, caseId: () => string) { saving, held, load, + claim, + release, + lockedBy, accept, unaccept, refuse, diff --git a/apps/web/src/pages/case/CasePage.vue b/apps/web/src/pages/case/CasePage.vue index 422a442..a3809a7 100644 --- a/apps/web/src/pages/case/CasePage.vue +++ b/apps/web/src/pages/case/CasePage.vue @@ -3,7 +3,7 @@ * One case: what it is, the evidence it is judged from, and — when a capture is * open — the carousel where judging happens. */ -import { computed, ref, watch } from 'vue' +import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { api, type components } from '@/shared/api' import { StatePill } from '@/shared/ui' @@ -115,11 +115,36 @@ watch( }) categories.value = tree.error ? [] : tree.data await review.load() + // Opening the case is claiming it (ADR 0005, #95); the interval is the + // heartbeat, and leaving the page lets go. + await review.claim() loading.value = false }, { immediate: true }, ) +const HEARTBEAT_MS = 30_000 +let heartbeat: ReturnType | undefined + +/** Leaving is letting go — including by closing the tab or a hard + * navigation, where Vue never unmounts: keepalive lets the release outlive + * the page (ADR 0005). */ +function releaseOnLeave() { + void fetch(`/api/projects/${slug.value}/cases/${caseId.value}/lock`, { + method: 'DELETE', + keepalive: true, + }) +} +onMounted(() => { + heartbeat = setInterval(() => void review.claim(), HEARTBEAT_MS) + window.addEventListener('pagehide', releaseOnLeave) +}) +onBeforeUnmount(() => { + clearInterval(heartbeat) + window.removeEventListener('pagehide', releaseOnLeave) + void review.release() +}) + async function onAccept(stepId: string, variantId: string, withdraw: boolean) { await review.accept(stepId, variantId, withdraw) await refreshCase() @@ -217,6 +242,27 @@ async function refreshCase() {