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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",
+ "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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
+ {{ heldBy.name }} is reviewing this case — read-only until they let go +