Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 114 additions & 2 deletions apps/server/api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1162,13 +1162,24 @@ paths:
description: |
Read through the recording, for the same reason a capture's image is
(`product.md` §8.1). A recording is a supporting exhibit: never
byte-compared, never a source of state (ADR 0013).
byte-compared, judged, never byte-compared (ADR 0013, ADR 0023).
tags:
- blobs
responses:
'200':
description: The video.
description: |
The video, served as what its first bytes say it is (#226): a browser
streams a `video/*` answer inline instead of downloading it. Unknown
bytes fall back to `application/octet-stream`.
content:
video/webm:
schema:
type: string
format: binary
video/mp4:
schema:
type: string
format: binary
application/octet-stream:
schema:
type: string
Expand All @@ -1179,6 +1190,94 @@ paths:
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/projects/{slug}/recordings/{recordingId}/judgment:
parameters:
- name: slug
in: path
required: true
schema:
type: string
- name: recordingId
in: path
required: true
schema:
type: string
post:
operationId: judgeRecording
security:
- serviceToken: []
- session: []
summary: Render a verdict on the recording on screen
description: |
A recording is judged (ADR 0023): the verdict lands on exactly these
bytes — one edition, one variant. A refusal carries its mandatory remark
(ADR 0020). A new edition brings new bytes and a new `to-review`
recording.
tags:
- reviews
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- accept
properties:
accept:
type: boolean
remark:
type: string
description: Mandatory on a refusal. It is what the dev has to read.
responses:
'200':
description: The judgment is recorded; the case state follows.
content:
application/json:
schema:
type: object
required:
- caseState
properties:
caseState:
$ref: '#/components/schemas/CaseState'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthenticated'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
delete:
operationId: unjudgeRecording
security:
- serviceToken: []
- session: []
summary: Take the recording's verdict back
description: |
Symmetric with giving it (ADR 0023): the recording returns to the
reviewer, the history keeps the move.
tags:
- reviews
responses:
'200':
description: The take-back is recorded; the case state follows.
content:
application/json:
schema:
type: object
required:
- caseState
properties:
caseState:
$ref: '#/components/schemas/CaseState'
'401':
$ref: '#/components/responses/Unauthenticated'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/projects/{slug}/cases/{caseId}:
parameters:
- name: slug
Expand Down Expand Up @@ -2445,6 +2544,7 @@ components:
- id
- variantId
- hash
- status
properties:
id:
type: string
Expand All @@ -2453,6 +2553,18 @@ components:
type: string
hash:
type: string
status:
type: string
enum:
- to-review
- accepted
- refused
description: |
Where the judgment on exactly these bytes stands (ADR 0023). A new
edition brings new bytes and a new `to-review` recording.
refusal:
type: string
description: The standing refusal's remark, absent otherwise.
Grid:
type: object
required:
Expand Down
11 changes: 10 additions & 1 deletion apps/server/api/src/components/evidence.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,19 @@ GridCapture:

GridRecording:
type: object
required: [id, variantId, hash]
required: [id, variantId, hash, status]
properties:
id:
type: string
description: Fetch the video at `/projects/{slug}/recordings/{recordingId}`.
variantId: { type: string }
hash: { type: string }
status:
type: string
enum: [to-review, accepted, refused]
description: |
Where the judgment on exactly these bytes stands (ADR 0023). A new
edition brings new bytes and a new `to-review` recording.
refusal:
type: string
description: The standing refusal's remark, absent otherwise.
2 changes: 2 additions & 0 deletions apps/server/api/src/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ paths:
$ref: "./paths/capture-image.yaml"
/projects/{slug}/recordings/{recordingId}:
$ref: "./paths/recording-video.yaml"
/projects/{slug}/recordings/{recordingId}/judgment:
$ref: "./paths/recording-judgment.yaml"
/projects/{slug}/cases/{caseId}:
$ref: "./paths/case.yaml"
/projects/{slug}/cases/{caseId}/captures:
Expand Down
82 changes: 82 additions & 0 deletions apps/server/api/src/paths/recording-judgment.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
parameters:
- name: slug
in: path
required: true
schema: { type: string }
- name: recordingId
in: path
required: true
schema: { type: string }

post:
operationId: judgeRecording
security:
- serviceToken: []
- session: []
summary: Render a verdict on the recording on screen
description: |
A recording is judged (ADR 0023): the verdict lands on exactly these
bytes — one edition, one variant. A refusal carries its mandatory remark
(ADR 0020). A new edition brings new bytes and a new `to-review`
recording.
tags: [reviews]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accept]
properties:
accept:
type: boolean
remark:
type: string
description: Mandatory on a refusal. It is what the dev has to read.
responses:
"200":
description: The judgment is recorded; the case state follows.
content:
application/json:
schema:
type: object
required: [caseState]
properties:
caseState:
$ref: "../components/schemas.yaml#/CaseState"
'400':
$ref: "../components/responses.yaml#/BadRequest"
'401':
$ref: "../components/responses.yaml#/Unauthenticated"
'403':
$ref: "../components/responses.yaml#/Forbidden"
"404":
$ref: "../components/responses.yaml#/NotFound"

delete:
operationId: unjudgeRecording
security:
- serviceToken: []
- session: []
summary: Take the recording's verdict back
description: |
Symmetric with giving it (ADR 0023): the recording returns to the
reviewer, the history keeps the move.
tags: [reviews]
responses:
"200":
description: The take-back is recorded; the case state follows.
content:
application/json:
schema:
type: object
required: [caseState]
properties:
caseState:
$ref: "../components/schemas.yaml#/CaseState"
'401':
$ref: "../components/responses.yaml#/Unauthenticated"
'403':
$ref: "../components/responses.yaml#/Forbidden"
"404":
$ref: "../components/responses.yaml#/NotFound"
15 changes: 13 additions & 2 deletions apps/server/api/src/paths/recording-video.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,23 @@ get:
description: |
Read through the recording, for the same reason a capture's image is
(`product.md` §8.1). A recording is a supporting exhibit: never
byte-compared, never a source of state (ADR 0013).
byte-compared, judged, never byte-compared (ADR 0013, ADR 0023).
tags: [blobs]
responses:
"200":
description: The video.
description: |
The video, served as what its first bytes say it is (#226): a browser
streams a `video/*` answer inline instead of downloading it. Unknown
bytes fall back to `application/octet-stream`.
content:
video/webm:
schema:
type: string
format: binary
video/mp4:
schema:
type: string
format: binary
application/octet-stream:
schema:
type: string
Expand Down
16 changes: 16 additions & 0 deletions apps/server/db/migrations/00020_a_recording_is_judged.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- +goose Up
-- A recording is judged (ADR 0023): the verdict lands on the recording on
-- screen — one edition, one variant — and every judgment is kept, take-backs
-- included. Statuses are derived from the last row, never stored.
CREATE TABLE recording_judgments (
id text PRIMARY KEY DEFAULT short_id(),
recording_id text NOT NULL REFERENCES recordings(id) ON DELETE CASCADE,
verdict text NOT NULL,
remark text,
actor_id text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX recording_judgments_recording_idx ON recording_judgments (recording_id, created_at);

-- +goose Down
DROP TABLE recording_judgments;
24 changes: 24 additions & 0 deletions apps/server/db/queries/evidence.sql
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,27 @@ SELECT r.blob_hash FROM recordings r
JOIN cases k ON k.id = r.case_id
JOIN projects p ON p.id = k.project_id
WHERE r.id = $1 AND p.slug = $2;

-- A recording's standing comes from its last judgment (ADR 0023): none or a
-- take-back reads to-review, and a refusal keeps its remark for the dev.
-- name: InsertRecordingJudgment :exec
INSERT INTO recording_judgments (recording_id, verdict, remark, actor_id)
VALUES ($1, $2, $3, $4);

-- name: LastRecordingJudgments :many
SELECT r.id AS recording_id, j.verdict, j.remark
FROM recordings r
JOIN LATERAL (
SELECT verdict, remark FROM recording_judgments
WHERE recording_id = r.id
ORDER BY created_at DESC LIMIT 1
) j ON true
WHERE r.case_id = $1 AND r.edition_id = $2;

-- The recording inside the project the caller named, with its case: what a
-- judgment needs to authorise and to recompute.
-- name: RecordingInProject :one
SELECT r.id, r.case_id FROM recordings r
JOIN cases k ON k.id = r.case_id
JOIN projects p ON p.id = k.project_id
WHERE r.id = $1 AND p.slug = $2;
26 changes: 26 additions & 0 deletions apps/server/internal/adapters/postgres/deriver.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,32 @@ func factsOf(ctx context.Context, q *sqlcgen.Queries, kase sqlcgen.Case) (review
}
}

if editionID != nil {
recordings, err := q.CaseRecordings(ctx, sqlcgen.CaseRecordingsParams{
CaseID: kase.ID, EditionID: *editionID,
})
if err != nil {
return facts, translate("reading the recordings", err)
}
judged, err := q.LastRecordingJudgments(ctx, sqlcgen.LastRecordingJudgmentsParams{
CaseID: kase.ID, EditionID: *editionID,
})
if err != nil {
return facts, translate("reading the recording judgments", err)
}
verdictByRecording := map[string]string{}
for _, row := range judged {
if row.Verdict != "taken-back" {
verdictByRecording[row.RecordingID] = row.Verdict
}
}
for _, r := range recordings {
facts.Recordings = append(facts.Recordings, review.RecordingFact{
ID: r.RecordingID, Verdict: verdictByRecording[r.RecordingID],
})
}
}

accepted, err := q.CaseAcceptedCaptures(ctx, kase.ID)
if err != nil {
return facts, translate("reading the acceptances", err)
Expand Down
22 changes: 20 additions & 2 deletions apps/server/internal/adapters/postgres/evidence.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,28 @@ func (r *Repository) CaseGrid(ctx context.Context, slug, caseID string, editionI
if err != nil {
return evidence.Grid{}, translate("reading the recordings", err)
}
judged, err := r.q.LastRecordingJudgments(ctx, sqlcgen.LastRecordingJudgmentsParams{
CaseID: caseID, EditionID: edition.ID,
})
if err != nil {
return evidence.Grid{}, translate("reading the recording judgments", err)
}
lastByRecording := map[string]sqlcgen.LastRecordingJudgmentsRow{}
for _, row := range judged {
lastByRecording[row.RecordingID] = row
}
for _, rec := range recordings {
grid.Recordings = append(grid.Recordings, evidence.Recording{
out := evidence.Recording{
ID: rec.RecordingID, VariantID: rec.VariantID, Hash: rec.BlobHash,
})
Status: "to-review",
}
if last, ok := lastByRecording[rec.RecordingID]; ok && last.Verdict != "taken-back" {
out.Status = last.Verdict
if last.Verdict == "refused" && last.Remark != nil {
out.Refusal = *last.Remark
}
}
grid.Recordings = append(grid.Recordings, out)
}

return grid, nil
Expand Down
Loading
Loading