diff --git a/apps/server/api/openapi.yaml b/apps/server/api/openapi.yaml index f1fe37e..5933a41 100644 --- a/apps/server/api/openapi.yaml +++ b/apps/server/api/openapi.yaml @@ -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 @@ -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 @@ -2445,6 +2544,7 @@ components: - id - variantId - hash + - status properties: id: type: string @@ -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: diff --git a/apps/server/api/src/components/evidence.yaml b/apps/server/api/src/components/evidence.yaml index 3ea1782..6d4a3a0 100644 --- a/apps/server/api/src/components/evidence.yaml +++ b/apps/server/api/src/components/evidence.yaml @@ -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. diff --git a/apps/server/api/src/openapi.yaml b/apps/server/api/src/openapi.yaml index 2b639b4..c8077cc 100644 --- a/apps/server/api/src/openapi.yaml +++ b/apps/server/api/src/openapi.yaml @@ -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: diff --git a/apps/server/api/src/paths/recording-judgment.yaml b/apps/server/api/src/paths/recording-judgment.yaml new file mode 100644 index 0000000..8fa12d6 --- /dev/null +++ b/apps/server/api/src/paths/recording-judgment.yaml @@ -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" diff --git a/apps/server/api/src/paths/recording-video.yaml b/apps/server/api/src/paths/recording-video.yaml index 8c5df97..ab0cf50 100644 --- a/apps/server/api/src/paths/recording-video.yaml +++ b/apps/server/api/src/paths/recording-video.yaml @@ -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 diff --git a/apps/server/db/migrations/00020_a_recording_is_judged.sql b/apps/server/db/migrations/00020_a_recording_is_judged.sql new file mode 100644 index 0000000..9102896 --- /dev/null +++ b/apps/server/db/migrations/00020_a_recording_is_judged.sql @@ -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; diff --git a/apps/server/db/queries/evidence.sql b/apps/server/db/queries/evidence.sql index 63abb6d..537d10f 100644 --- a/apps/server/db/queries/evidence.sql +++ b/apps/server/db/queries/evidence.sql @@ -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; diff --git a/apps/server/internal/adapters/postgres/deriver.go b/apps/server/internal/adapters/postgres/deriver.go index 988408e..2b35d53 100644 --- a/apps/server/internal/adapters/postgres/deriver.go +++ b/apps/server/internal/adapters/postgres/deriver.go @@ -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) diff --git a/apps/server/internal/adapters/postgres/evidence.go b/apps/server/internal/adapters/postgres/evidence.go index 999c358..da94c29 100644 --- a/apps/server/internal/adapters/postgres/evidence.go +++ b/apps/server/internal/adapters/postgres/evidence.go @@ -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 diff --git a/apps/server/internal/adapters/postgres/intake.go b/apps/server/internal/adapters/postgres/intake.go index 34545cf..a863ab2 100644 --- a/apps/server/internal/adapters/postgres/intake.go +++ b/apps/server/internal/adapters/postgres/intake.go @@ -167,6 +167,37 @@ func (r *Repository) WriteEdition( return appintake.Result{}, translate("pointing the cases at the edition", err) } + // A new edition brings new video bytes, and nobody has judged them: the + // cases it carries recordings for return to the reviewer (ADR 0023). + // Deliberately narrow — a full recompute here would also raise `moved` + // to the case, which stays with the capture (ADR 0021). + for _, c := range m.Cases { + if len(c.Recordings) == 0 { + continue + } + from := review.CaseState(known[c.ID].State) + if from != review.CaseAccepted && from != review.CaseRefused { + continue + } + if err := q.SetCaseState(ctx, sqlcgen.SetCaseStateParams{ + ID: c.ID, State: string(review.CaseToReview), + }); err != nil { + return appintake.Result{}, translate("reopening the case for its video", err) + } + inputs, err := json.Marshal(map[string]any{"edition": edition.ID, "recordings": len(c.Recordings)}) + if err != nil { + return appintake.Result{}, fmt.Errorf("encoding the transition inputs: %w", err) + } + if err := q.RecordTransition(ctx, sqlcgen.RecordTransitionParams{ + ProjectID: project.ID, CaseID: &c.ID, + FromState: ptr(string(from)), ToState: ptr(string(review.CaseToReview)), + Cause: "recording-arrived", ActorID: "intake", ActorKind: "machine", + Inputs: inputs, RuleVersion: 1, + }); err != nil { + return appintake.Result{}, translate("journalling the transition", err) + } + } + // The evidence has arrived, so the cases that had none leave the edge of // the funnel. This is the only transition intake drives: every other one // comes from a comment (ADR 0012). diff --git a/apps/server/internal/adapters/postgres/recording.go b/apps/server/internal/adapters/postgres/recording.go new file mode 100644 index 0000000..5b38fc2 --- /dev/null +++ b/apps/server/internal/adapters/postgres/recording.go @@ -0,0 +1,104 @@ +package postgres + +import ( + "context" + "encoding/json" + "fmt" + + "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" +) + +// JudgeRecording renders a verdict on one flow video (ADR 0023): the judgment +// lands on the recording on screen, the row is kept forever, and the case is +// recomputed from the facts in the same transaction. +func (r *Repository) JudgeRecording( + ctx context.Context, slug, recordingID string, by actor.Actor, accept bool, remark string, +) (review.CaseState, error) { + verdict := "accepted" + if !accept { + if remark == "" { + return "", review.ErrRemarkRequired + } + verdict = "refused" + } + return r.moveRecording(ctx, slug, recordingID, by, verdict, remark) +} + +// UnjudgeRecording takes a video's verdict back, symmetrically: the recording +// returns to the reviewer, the history keeps the move (ADR 0023). +func (r *Repository) UnjudgeRecording( + ctx context.Context, slug, recordingID string, by actor.Actor, +) (review.CaseState, error) { + return r.moveRecording(ctx, slug, recordingID, by, "taken-back", "") +} + +func (r *Repository) moveRecording( + ctx context.Context, slug, recordingID string, by actor.Actor, verdict, remark string, +) (review.CaseState, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return "", fmt.Errorf("beginning the judgment: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + q := r.q.WithTx(tx) + + // Scoped by the project, so somebody else's video is not found rather + // than judged (#71). + found, err := q.RecordingInProject(ctx, sqlcgen.RecordingInProjectParams{ID: recordingID, Slug: slug}) + if err != nil { + if isNoRows(err) { + return "", review.ErrMoveNotAllowed + } + return "", translate("finding the recording", err) + } + kase, err := q.CaseInProject(ctx, sqlcgen.CaseInProjectParams{ID: found.CaseID, Slug: slug}) + if err != nil { + return "", translate("reading the case", err) + } + before := review.CaseState(kase.State) + + var remarkPtr *string + if remark != "" { + remarkPtr = &remark + } + if err := q.InsertRecordingJudgment(ctx, sqlcgen.InsertRecordingJudgmentParams{ + RecordingID: recordingID, Verdict: verdict, Remark: remarkPtr, ActorID: by.ID, + }); err != nil { + return "", translate("recording the judgment", err) + } + + facts, err := factsOf(ctx, q, kase) + if err != nil { + return "", err + } + outcome := review.Compute(facts) + if outcome.State != before { + if err := q.SetCaseState(ctx, sqlcgen.SetCaseStateParams{ + ID: kase.ID, State: string(outcome.State), + }); err != nil { + return "", translate("moving the case", err) + } + inputs, err := json.Marshal(map[string]any{ + "recording": recordingID, + "verdict": verdict, + }) + if err != nil { + return "", fmt.Errorf("encoding the transition inputs: %w", err) + } + if err := q.RecordTransition(ctx, sqlcgen.RecordTransitionParams{ + ProjectID: kase.ProjectID, CaseID: &kase.ID, + FromState: ptr(string(before)), ToState: ptr(string(outcome.State)), + Cause: "recording-judged", ActorID: by.ID, ActorKind: string(by.Kind), + Inputs: inputs, RuleVersion: 1, + }); err != nil { + return "", translate("journalling the transition", err) + } + } + + if err := tx.Commit(ctx); err != nil { + return "", fmt.Errorf("committing the judgment: %w", err) + } + return outcome.State, nil +} diff --git a/apps/server/internal/adapters/postgres/recording_test.go b/apps/server/internal/adapters/postgres/recording_test.go new file mode 100644 index 0000000..65f2673 --- /dev/null +++ b/apps/server/internal/adapters/postgres/recording_test.go @@ -0,0 +1,170 @@ +package postgres_test + +import ( + "context" + "testing" + + "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" + "github.com/haribo/ozalid/internal/contract" +) + +// seedGridWithRecordings is seedGrid plus one flow video per variant. +func seedGridWithRecordings( + t *testing.T, ctx context.Context, repo *postgres.Repository, + project sqlcgen.Project, kase sqlcgen.Case, take string, +) []review.Capture { + t.Helper() + light := storeBlob(t, ctx, repo, "light "+t.Name()) + dark := storeBlob(t, ctx, repo, "dark "+t.Name()) + videoLight := storeBlob(t, ctx, repo, "video light "+take+t.Name()) + videoDark := storeBlob(t, ctx, repo, "video dark "+take+t.Name()) + + if _, err := repo.WriteEdition(ctx, project.Slug, contract.Manifest{ + Cases: []contract.ManifestCase{{ + ID: kase.ID, + Steps: []contract.ManifestStep{{ + Name: "opens", + Captures: []contract.ManifestCapture{ + {Variant: map[string]string{"theme": "light"}, Hash: light}, + {Variant: map[string]string{"theme": "dark"}, Hash: dark}, + }, + }}, + Recordings: []contract.ManifestRecording{ + {Variant: map[string]string{"theme": "light"}, Hash: videoLight}, + {Variant: map[string]string{"theme": "dark"}, Hash: videoDark}, + }, + }}, + }, nil); err != nil { + t.Fatalf("taking the edition in: %v", err) + } + + grid, err := repo.CaseGrid(ctx, project.Slug, kase.ID, nil) + if err != nil { + t.Fatalf("reading the grid: %v", err) + } + captures := make([]review.Capture, 0, 2) + for _, capture := range grid.Steps[0].Captures { + captures = append(captures, review.Capture{StepID: grid.Steps[0].ID, VariantID: capture.VariantID}) + } + return captures +} + +func recordingStates( + t *testing.T, ctx context.Context, repo *postgres.Repository, slug, caseID string, +) map[string]string { + t.Helper() + grid, err := repo.CaseGrid(ctx, slug, caseID, nil) + if err != nil { + t.Fatalf("reading the grid: %v", err) + } + out := map[string]string{} + for _, r := range grid.Recordings { + out[r.VariantID] = r.Status + } + return out +} + +func caseState(t *testing.T, ctx context.Context, repo *postgres.Repository, slug, caseID string) string { + t.Helper() + kase, err := repo.Queries().CaseInProject(ctx, sqlcgen.CaseInProjectParams{ID: caseID, Slug: slug}) + if err != nil { + t.Fatalf("re-reading the case: %v", err) + } + return kase.State +} + +// A recording is evidence, and evidence is judged (ADR 0023, #226): an +// unjudged video keeps the case to-review even when every capture is +// accepted; the last accepted video settles it; a refusal hands the case to +// the dev with its remark. +func TestARecordingIsJudgedAndTheCaseFollows(t *testing.T) { + ctx, repo, project, kase := intakeFixture(t) + captures := seedGridWithRecordings(t, ctx, repo, project, kase, "one") + nina := actor.Actor{ID: "nina", Kind: actor.Human} + + if _, err := repo.SaveReview(ctx, project.Slug, kase.ID, nina, session.Save{Accepted: captures}); err != nil { + t.Fatalf("accepting every capture: %v", err) + } + if state := caseState(t, ctx, repo, project.Slug, kase.ID); state != "to-review" { + t.Fatalf("case = %q with two unjudged recordings, want to-review", state) + } + + grid, err := repo.CaseGrid(ctx, project.Slug, kase.ID, nil) + if err != nil || len(grid.Recordings) != 2 { + t.Fatalf("recordings = %v, %v", grid.Recordings, err) + } + first, second := grid.Recordings[0], grid.Recordings[1] + + if _, err := repo.JudgeRecording(ctx, project.Slug, first.ID, nina, true, ""); err != nil { + t.Fatalf("accepting the first video: %v", err) + } + if state := caseState(t, ctx, repo, project.Slug, kase.ID); state != "to-review" { + t.Errorf("case = %q with one video still unjudged, want to-review", state) + } + if _, err := repo.JudgeRecording(ctx, project.Slug, second.ID, nina, true, ""); err != nil { + t.Fatalf("accepting the second video: %v", err) + } + if state := caseState(t, ctx, repo, project.Slug, kase.ID); state != "accepted" { + t.Errorf("case = %q once everything is judged, want accepted", state) + } + + // Refusing one video hands the case to the dev, remark carried. + if _, err := repo.JudgeRecording(ctx, project.Slug, first.ID, nina, false, "the flow stutters at the door"); err != nil { + t.Fatalf("refusing the first video: %v", err) + } + if state := caseState(t, ctx, repo, project.Slug, kase.ID); state != "refused" { + t.Errorf("case = %q with a refused video, want refused", state) + } + states := recordingStates(t, ctx, repo, project.Slug, kase.ID) + if states[first.VariantID] != "refused" || states[second.VariantID] != "accepted" { + t.Errorf("recording states = %v, want the refusal on its own variant", states) + } + + // The take-back is symmetric: the video returns to the reviewer. + if _, err := repo.UnjudgeRecording(ctx, project.Slug, first.ID, nina); err != nil { + t.Fatalf("taking the refusal back: %v", err) + } + if states := recordingStates(t, ctx, repo, project.Slug, kase.ID); states[first.VariantID] != "to-review" { + t.Errorf("recording = %q after the take-back, want to-review", states[first.VariantID]) + } +} + +// Every push brings new bytes (ADR 0023): the accepted video of one edition +// says nothing about the next one's. +func TestANewEditionResetsTheRecordingToReview(t *testing.T) { + ctx, repo, project, kase := intakeFixture(t) + captures := seedGridWithRecordings(t, ctx, repo, project, kase, "one") + nina := actor.Actor{ID: "nina", Kind: actor.Human} + + if _, err := repo.SaveReview(ctx, project.Slug, kase.ID, nina, session.Save{Accepted: captures}); err != nil { + t.Fatalf("accepting every capture: %v", err) + } + grid, err := repo.CaseGrid(ctx, project.Slug, kase.ID, nil) + if err != nil { + t.Fatalf("reading the grid: %v", err) + } + for _, r := range grid.Recordings { + if _, err := repo.JudgeRecording(ctx, project.Slug, r.ID, nina, true, ""); err != nil { + t.Fatalf("accepting a video: %v", err) + } + } + if state := caseState(t, ctx, repo, project.Slug, kase.ID); state != "accepted" { + t.Fatalf("case = %q before the new edition, want accepted", state) + } + + // Same captures, new video bytes — what a real pushing run does. + seedGridWithRecordings(t, ctx, repo, project, kase, "two") + + if state := caseState(t, ctx, repo, project.Slug, kase.ID); state != "to-review" { + t.Errorf("case = %q after the new edition, want to-review — nobody watched these bytes", state) + } + for variant, status := range recordingStates(t, ctx, repo, project.Slug, kase.ID) { + if status != "to-review" { + t.Errorf("recording %s = %q on the new edition, want to-review", variant, status) + } + } +} diff --git a/apps/server/internal/adapters/postgres/session.go b/apps/server/internal/adapters/postgres/session.go index 6db741f..e37faab 100644 --- a/apps/server/internal/adapters/postgres/session.go +++ b/apps/server/internal/adapters/postgres/session.go @@ -200,12 +200,6 @@ func (r *Repository) SaveReview( outcome := review.Compute(facts) if outcome.State != before { - if err := q.SetCaseState(ctx, sqlcgen.SetCaseStateParams{ - ID: caseID, State: string(outcome.State), - }); err != nil { - return session.Result{}, translate("moving the case", err) - } - // The reviewer has let go, so the case catches up with whatever landed // while they were looking. It was only held back to keep one fixed set // of bytes under them (product.md §7). @@ -213,6 +207,27 @@ func (r *Repository) SaveReview( if err := q.ReleaseToLatestEdition(ctx, caseID); err != nil { return session.Result{}, translate("releasing the case onto the latest edition", err) } + // The caught-up edition can carry unjudged videos (ADR 0023): + // the state is derived against what the case now shows, not + // against the edition the reviewer just left. + released, err := q.CaseInProject(ctx, sqlcgen.CaseInProjectParams{ID: caseID, Slug: slug}) + if err != nil { + return session.Result{}, translate("re-reading the case", err) + } + released.State = string(before) + facts, err = factsOf(ctx, q, released) + if err != nil { + return session.Result{}, err + } + outcome = review.Compute(facts) + } + } + + if outcome.State != before { + if err := q.SetCaseState(ctx, sqlcgen.SetCaseStateParams{ + ID: caseID, State: string(outcome.State), + }); err != nil { + return session.Result{}, translate("moving the case", err) } // The fingerprint of what the computation consumed. Without it a // stored state is no regression oracle (ADR 0002). diff --git a/apps/server/internal/adapters/postgres/sqlcgen/evidence.sql.go b/apps/server/internal/adapters/postgres/sqlcgen/evidence.sql.go index 9f03625..9698c1b 100644 --- a/apps/server/internal/adapters/postgres/sqlcgen/evidence.sql.go +++ b/apps/server/internal/adapters/postgres/sqlcgen/evidence.sql.go @@ -175,6 +175,72 @@ func (q *Queries) EditionByID(ctx context.Context, arg EditionByIDParams) (Editi return i, err } +const insertRecordingJudgment = `-- name: InsertRecordingJudgment :exec +INSERT INTO recording_judgments (recording_id, verdict, remark, actor_id) +VALUES ($1, $2, $3, $4) +` + +type InsertRecordingJudgmentParams struct { + RecordingID string + Verdict string + Remark *string + ActorID string +} + +// 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. +func (q *Queries) InsertRecordingJudgment(ctx context.Context, arg InsertRecordingJudgmentParams) error { + _, err := q.db.Exec(ctx, insertRecordingJudgment, + arg.RecordingID, + arg.Verdict, + arg.Remark, + arg.ActorID, + ) + return err +} + +const lastRecordingJudgments = `-- 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 +` + +type LastRecordingJudgmentsParams struct { + CaseID string + EditionID string +} + +type LastRecordingJudgmentsRow struct { + RecordingID string + Verdict string + Remark *string +} + +func (q *Queries) LastRecordingJudgments(ctx context.Context, arg LastRecordingJudgmentsParams) ([]LastRecordingJudgmentsRow, error) { + rows, err := q.db.Query(ctx, lastRecordingJudgments, arg.CaseID, arg.EditionID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []LastRecordingJudgmentsRow{} + for rows.Next() { + var i LastRecordingJudgmentsRow + if err := rows.Scan(&i.RecordingID, &i.Verdict, &i.Remark); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const latestEdition = `-- name: LatestEdition :one SELECT id, project_id, revision, created_at FROM editions WHERE project_id = $1 @@ -213,3 +279,29 @@ func (q *Queries) RecordingBlobInProject(ctx context.Context, arg RecordingBlobI err := row.Scan(&blob_hash) return blob_hash, err } + +const recordingInProject = `-- 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 +` + +type RecordingInProjectParams struct { + ID string + Slug string +} + +type RecordingInProjectRow struct { + ID string + CaseID string +} + +// The recording inside the project the caller named, with its case: what a +// judgment needs to authorise and to recompute. +func (q *Queries) RecordingInProject(ctx context.Context, arg RecordingInProjectParams) (RecordingInProjectRow, error) { + row := q.db.QueryRow(ctx, recordingInProject, arg.ID, arg.Slug) + var i RecordingInProjectRow + err := row.Scan(&i.ID, &i.CaseID) + return i, err +} diff --git a/apps/server/internal/adapters/postgres/sqlcgen/models.go b/apps/server/internal/adapters/postgres/sqlcgen/models.go index e2de080..7541ead 100644 --- a/apps/server/internal/adapters/postgres/sqlcgen/models.go +++ b/apps/server/internal/adapters/postgres/sqlcgen/models.go @@ -157,6 +157,15 @@ type Recording struct { BlobHash string } +type RecordingJudgment struct { + ID string + RecordingID string + Verdict string + Remark *string + ActorID string + CreatedAt pgtype.Timestamptz +} + type ServiceAccount struct { ID string Name string diff --git a/apps/server/internal/app/evidence/evidence.go b/apps/server/internal/app/evidence/evidence.go index 97e03fc..21bf6ce 100644 --- a/apps/server/internal/app/evidence/evidence.go +++ b/apps/server/internal/app/evidence/evidence.go @@ -7,6 +7,8 @@ package evidence import ( "context" "errors" + "github.com/haribo/ozalid/apps/server/internal/domain/actor" + "github.com/haribo/ozalid/apps/server/internal/domain/review" "time" "github.com/haribo/ozalid/internal/contract" @@ -48,11 +50,16 @@ type Step struct { } // Recording is the flow video for one variant. Optional, never compared -// (ADR 0013). +// (ADR 0013) — judged instead (ADR 0023). type Recording struct { ID string VariantID string Hash string + // Status is to-review, accepted or refused, derived from the last + // judgment on exactly these bytes. + Status string + // Refusal is the standing refusal's remark, empty otherwise. + Refusal string } // Grid is what a case is judged from. @@ -71,6 +78,8 @@ type Repository interface { CaseGrid(ctx context.Context, slug, caseID string, editionID *string) (Grid, error) CaptureBlob(ctx context.Context, slug, captureID string) (string, error) RecordingBlob(ctx context.Context, slug, recordingID string) (string, error) + JudgeRecording(ctx context.Context, slug, recordingID string, by actor.Actor, accept bool, remark string) (review.CaseState, error) + UnjudgeRecording(ctx context.Context, slug, recordingID string, by actor.Actor) (review.CaseState, error) } // Service reads evidence. @@ -102,3 +111,13 @@ func (s *Service) CaptureBlob(ctx context.Context, slug, captureID string) (stri func (s *Service) RecordingBlob(ctx context.Context, slug, recordingID string) (string, error) { return s.repo.RecordingBlob(ctx, slug, recordingID) } + +// JudgeRecording renders a verdict on the recording on screen (ADR 0023). +func (s *Service) JudgeRecording(ctx context.Context, slug, recordingID string, by actor.Actor, accept bool, remark string) (review.CaseState, error) { + return s.repo.JudgeRecording(ctx, slug, recordingID, by, accept, remark) +} + +// UnjudgeRecording takes it back, symmetrically. +func (s *Service) UnjudgeRecording(ctx context.Context, slug, recordingID string, by actor.Actor) (review.CaseState, error) { + return s.repo.UnjudgeRecording(ctx, slug, recordingID, by) +} diff --git a/apps/server/internal/domain/review/compute.go b/apps/server/internal/domain/review/compute.go index 895d535..acc6abd 100644 --- a/apps/server/internal/domain/review/compute.go +++ b/apps/server/internal/domain/review/compute.go @@ -59,10 +59,21 @@ type Facts struct { // Every comment on the case, settled ones included: a discarded comment // stops counting, but it still exists (ADR 0006). Comments []Comment + // Recordings present at the edition, each with the verdict of its last + // judgment — empty when nobody judged these bytes (ADR 0023). + Recordings []RecordingFact // PixelThreshold is how many differing pixels this project calls noise. PixelThreshold int } +// RecordingFact is one flow video and where its judgment stands. +type RecordingFact struct { + ID string + // Verdict is "accepted", "refused", or "" while nobody has judged — + // a taken-back judgment reads as nobody having judged. + Verdict string +} + // Outcome is what the facts amount to. type Outcome struct { State CaseState @@ -119,6 +130,14 @@ func Compute(f Facts) Outcome { return out } } + // A recording is evidence too (ADR 0023): unjudged bytes await the + // reviewer exactly like an unjudged capture. + for _, r := range f.Recordings { + if r.Verdict == "" { + out.State = CaseToReview + return out + } + } // Nothing awaits the reviewer. Anything still open awaits the dev — and // which of the two it is does not belong on the case: the comment carries @@ -129,6 +148,12 @@ func Compute(f Facts) Outcome { return out } } + for _, r := range f.Recordings { + if r.Verdict == "refused" { + out.State = CaseRefused + return out + } + } out.State = CaseAccepted return out diff --git a/apps/server/internal/ports/http/blobs.go b/apps/server/internal/ports/http/blobs.go index 709bedf..7227721 100644 --- a/apps/server/internal/ports/http/blobs.go +++ b/apps/server/internal/ports/http/blobs.go @@ -1,6 +1,7 @@ package http import ( + "bytes" "context" "errors" "io" @@ -103,7 +104,24 @@ func (s *Server) GetRecordingVideo(ctx context.Context, request openapi.GetRecor if err != nil { return nil, err } - return openapi.GetRecordingVideo200ApplicationoctetStreamResponse{Body: rc}, nil + + // Served as what its first bytes say it is (#226): a browser streams a + // video/* answer inline instead of downloading it. The store never knew + // the format — a recording's format is nobody's business (ADR 0013) — + // so the answer sniffs rather than trusts. + head := make([]byte, 12) + n, _ := io.ReadFull(rc, head) + body := struct { + io.Reader + io.Closer + }{io.MultiReader(bytes.NewReader(head[:n]), rc), rc} + switch { + case n >= 4 && bytes.Equal(head[:4], []byte{0x1a, 0x45, 0xdf, 0xa3}): + return openapi.GetRecordingVideo200VideowebmResponse{Body: body}, nil + case n >= 12 && bytes.Equal(head[4:8], []byte("ftyp")): + return openapi.GetRecordingVideo200Videomp4Response{Body: body}, nil + } + return openapi.GetRecordingVideo200ApplicationoctetStreamResponse{Body: body}, nil } // errNoBytes covers both ways bytes fail to arrive: the row is not in this diff --git a/apps/server/internal/ports/http/blobs_test.go b/apps/server/internal/ports/http/blobs_test.go index 01248f6..2cca0e8 100644 --- a/apps/server/internal/ports/http/blobs_test.go +++ b/apps/server/internal/ports/http/blobs_test.go @@ -3,6 +3,7 @@ package http_test import ( "bytes" "context" + "github.com/haribo/ozalid/apps/server/internal/domain/review" "io" "net/http" "net/http/httptest" @@ -70,6 +71,14 @@ func (c oneCapture) CaptureBlob(_ context.Context, slug, captureID string) (stri return c.hash, nil } +func (c oneCapture) JudgeRecording(context.Context, string, string, actor.Actor, bool, string) (review.CaseState, error) { + return "", review.ErrMoveNotAllowed +} + +func (c oneCapture) UnjudgeRecording(context.Context, string, string, actor.Actor) (review.CaseState, error) { + return "", review.ErrMoveNotAllowed +} + func (c oneCapture) RecordingBlob(context.Context, string, string) (string, error) { return "", catalogue.ErrNotFound } diff --git a/apps/server/internal/ports/http/evidence.go b/apps/server/internal/ports/http/evidence.go index 6be6fc0..1155335 100644 --- a/apps/server/internal/ports/http/evidence.go +++ b/apps/server/internal/ports/http/evidence.go @@ -79,6 +79,8 @@ func toAPIGrid(g evidence.Grid) openapi.Grid { for _, r := range g.Recordings { out.Recordings = append(out.Recordings, openapi.GridRecording{ Id: r.ID, VariantId: r.VariantID, Hash: r.Hash, + Status: openapi.GridRecordingStatus(r.Status), + Refusal: nonEmptyPtr(r.Refusal), }) } return out diff --git a/apps/server/internal/ports/http/openapi/server.gen.go b/apps/server/internal/ports/http/openapi/server.gen.go index f09ffc5..398c0f1 100644 --- a/apps/server/internal/ports/http/openapi/server.gen.go +++ b/apps/server/internal/ports/http/openapi/server.gen.go @@ -127,6 +127,27 @@ func (e GridCaptureStatus) Valid() bool { } } +// Defines values for GridRecordingStatus. +const ( + GridRecordingStatusAccepted GridRecordingStatus = "accepted" + GridRecordingStatusRefused GridRecordingStatus = "refused" + GridRecordingStatusToReview GridRecordingStatus = "to-review" +) + +// Valid indicates whether the value is a known member of the GridRecordingStatus enum. +func (e GridRecordingStatus) Valid() bool { + switch e { + case GridRecordingStatusAccepted: + return true + case GridRecordingStatusRefused: + return true + case GridRecordingStatusToReview: + return true + default: + return false + } +} + // Defines values for IssueTrackingState. const ( IssueTrackingStateAccepted IssueTrackingState = "accepted" @@ -447,10 +468,21 @@ type GridRecording struct { Hash string `json:"hash"` // Id Fetch the video at `/projects/{slug}/recordings/{recordingId}`. - Id string `json:"id"` - VariantId string `json:"variantId"` + Id string `json:"id"` + + // Refusal The standing refusal's remark, absent otherwise. + Refusal *string `json:"refusal,omitempty"` + + // Status Where the judgment on exactly these bytes stands (ADR 0023). A new + // edition brings new bytes and a new `to-review` recording. + Status GridRecordingStatus `json:"status"` + VariantId string `json:"variantId"` } +// GridRecordingStatus Where the judgment on exactly these bytes stands (ADR 0023). A new +// edition brings new bytes and a new `to-review` recording. +type GridRecordingStatus string + // GridStep defines model for GridStep. type GridStep struct { // Captures One entry per variant that has a capture at this step. @@ -961,6 +993,14 @@ type JudgeCommentJSONBody struct { VariantId string `json:"variantId"` } +// JudgeRecordingJSONBody defines parameters for JudgeRecording. +type JudgeRecordingJSONBody struct { + Accept bool `json:"accept"` + + // Remark Mandatory on a refusal. It is what the dev has to read. + Remark *string `json:"remark,omitempty"` +} + // RequestSignInJSONBody defines parameters for RequestSignIn. type RequestSignInJSONBody struct { Email openapi_types.Email `json:"email"` @@ -1022,6 +1062,9 @@ type CreateEditionJSONRequestBody = Manifest // GrantMembershipJSONRequestBody defines body for GrantMembership for application/json ContentType. type GrantMembershipJSONRequestBody = NewMembership +// JudgeRecordingJSONRequestBody defines body for JudgeRecording for application/json ContentType. +type JudgeRecordingJSONRequestBody JudgeRecordingJSONBody + // CreateServiceAccountJSONRequestBody defines body for CreateServiceAccount for application/json ContentType. type CreateServiceAccountJSONRequestBody = NewServiceAccount @@ -1147,6 +1190,12 @@ type ServerInterface interface { // GetRecordingVideo The video a recording holds // (GET /projects/{slug}/recordings/{recordingId}) GetRecordingVideo(w http.ResponseWriter, r *http.Request, slug string, recordingId string) + // UnjudgeRecording Take the recording's verdict back + // (DELETE /projects/{slug}/recordings/{recordingId}/judgment) + UnjudgeRecording(w http.ResponseWriter, r *http.Request, slug string, recordingId string) + // JudgeRecording Render a verdict on the recording on screen + // (POST /projects/{slug}/recordings/{recordingId}/judgment) + JudgeRecording(w http.ResponseWriter, r *http.Request, slug string, recordingId string) // CreateServiceAccount Make a program an account on this project // (POST /projects/{slug}/service-accounts) CreateServiceAccount(w http.ResponseWriter, r *http.Request, slug string) @@ -2300,6 +2349,76 @@ func (siw *ServerInterfaceWrapper) GetRecordingVideo(w http.ResponseWriter, r *h handler.ServeHTTP(w, r) } +// UnjudgeRecording operation middleware +func (siw *ServerInterfaceWrapper) UnjudgeRecording(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 "recordingId" ------------- + var recordingId string + + err = runtime.BindStyledParameterWithOptions("simple", "recordingId", r.PathValue("recordingId"), &recordingId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "recordingId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.UnjudgeRecording(w, r, slug, recordingId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// JudgeRecording operation middleware +func (siw *ServerInterfaceWrapper) JudgeRecording(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 "recordingId" ------------- + var recordingId string + + err = runtime.BindStyledParameterWithOptions("simple", "recordingId", r.PathValue("recordingId"), &recordingId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "recordingId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.JudgeRecording(w, r, slug, recordingId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // CreateServiceAccount operation middleware func (siw *ServerInterfaceWrapper) CreateServiceAccount(w http.ResponseWriter, r *http.Request) { @@ -2683,6 +2802,8 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/projects/{slug}/categories", wrapper.CreateCategory) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/projects/{slug}/captures/{captureId}", wrapper.GetCaptureImage) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/projects/{slug}/recordings/{recordingId}", wrapper.GetRecordingVideo) + m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/projects/{slug}/recordings/{recordingId}/judgment", wrapper.UnjudgeRecording) + 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.MethodGet+" "+options.BaseURL+"/projects/{slug}/cases/{caseId}/captures", wrapper.GetCaseCaptures) @@ -5584,6 +5705,46 @@ func (response GetRecordingVideo200ApplicationoctetStreamResponse) VisitGetRecor return err } +type GetRecordingVideo200Videomp4Response struct { + Body io.Reader + ContentLength int64 +} + +func (response GetRecordingVideo200Videomp4Response) VisitGetRecordingVideoResponse(w http.ResponseWriter) error { + + w.Header().Set("Content-Type", "video/mp4") + if response.ContentLength != 0 { + w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength)) + } + w.WriteHeader(200) + + if closer, ok := response.Body.(io.ReadCloser); ok { + defer closer.Close() + } + _, err := io.Copy(w, response.Body) + return err +} + +type GetRecordingVideo200VideowebmResponse struct { + Body io.Reader + ContentLength int64 +} + +func (response GetRecordingVideo200VideowebmResponse) VisitGetRecordingVideoResponse(w http.ResponseWriter) error { + + w.Header().Set("Content-Type", "video/webm") + if response.ContentLength != 0 { + w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength)) + } + w.WriteHeader(200) + + if closer, ok := response.Body.(io.ReadCloser); ok { + defer closer.Close() + } + _, err := io.Copy(w, response.Body) + return err +} + type GetRecordingVideo401ApplicationProblemPlusJSONResponse struct { UnauthenticatedApplicationProblemPlusJSONResponse } @@ -5632,6 +5793,173 @@ func (response GetRecordingVideo404ApplicationProblemPlusJSONResponse) VisitGetR return err } +type UnjudgeRecordingRequestObject struct { + Slug string `json:"slug"` + RecordingId string `json:"recordingId"` +} + +type UnjudgeRecordingResponseObject interface { + VisitUnjudgeRecordingResponse(w http.ResponseWriter) error +} + +type UnjudgeRecording200JSONResponse struct { + // CaseState Who holds the ball. Computed by the server from the case's comments; no + // endpoint accepts it as an argument. + CaseState CaseState `json:"caseState"` +} + +func (response UnjudgeRecording200JSONResponse) 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/json") + w.WriteHeader(200) + _, err := buf.WriteTo(w) + return err +} + +type UnjudgeRecording401ApplicationProblemPlusJSONResponse struct { + UnauthenticatedApplicationProblemPlusJSONResponse +} + +func (response UnjudgeRecording401ApplicationProblemPlusJSONResponse) 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(401) + _, err := buf.WriteTo(w) + return err +} + +type UnjudgeRecording403ApplicationProblemPlusJSONResponse struct { + ForbiddenApplicationProblemPlusJSONResponse +} + +func (response UnjudgeRecording403ApplicationProblemPlusJSONResponse) 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(403) + _, err := buf.WriteTo(w) + return err +} + +type UnjudgeRecording404ApplicationProblemPlusJSONResponse struct { + NotFoundApplicationProblemPlusJSONResponse +} + +func (response UnjudgeRecording404ApplicationProblemPlusJSONResponse) 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(404) + _, err := buf.WriteTo(w) + return err +} + +type JudgeRecordingRequestObject struct { + Slug string `json:"slug"` + RecordingId string `json:"recordingId"` + Body *JudgeRecordingJSONRequestBody +} + +type JudgeRecordingResponseObject interface { + VisitJudgeRecordingResponse(w http.ResponseWriter) error +} + +type JudgeRecording200JSONResponse struct { + // CaseState Who holds the ball. Computed by the server from the case's comments; no + // endpoint accepts it as an argument. + CaseState CaseState `json:"caseState"` +} + +func (response JudgeRecording200JSONResponse) 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/json") + w.WriteHeader(200) + _, err := buf.WriteTo(w) + return err +} + +type JudgeRecording400ApplicationProblemPlusJSONResponse struct { + BadRequestApplicationProblemPlusJSONResponse +} + +func (response JudgeRecording400ApplicationProblemPlusJSONResponse) 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(400) + _, err := buf.WriteTo(w) + return err +} + +type JudgeRecording401ApplicationProblemPlusJSONResponse struct { + UnauthenticatedApplicationProblemPlusJSONResponse +} + +func (response JudgeRecording401ApplicationProblemPlusJSONResponse) 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(401) + _, err := buf.WriteTo(w) + return err +} + +type JudgeRecording403ApplicationProblemPlusJSONResponse struct { + ForbiddenApplicationProblemPlusJSONResponse +} + +func (response JudgeRecording403ApplicationProblemPlusJSONResponse) 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(403) + _, err := buf.WriteTo(w) + return err +} + +type JudgeRecording404ApplicationProblemPlusJSONResponse struct { + NotFoundApplicationProblemPlusJSONResponse +} + +func (response JudgeRecording404ApplicationProblemPlusJSONResponse) 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(404) + _, err := buf.WriteTo(w) + return err +} + type CreateServiceAccountRequestObject struct { Slug string `json:"slug"` Body *CreateServiceAccountJSONRequestBody @@ -6257,6 +6585,12 @@ type StrictServerInterface interface { // GetRecordingVideo The video a recording holds // (GET /projects/{slug}/recordings/{recordingId}) GetRecordingVideo(ctx context.Context, request GetRecordingVideoRequestObject) (GetRecordingVideoResponseObject, error) + // UnjudgeRecording Take the recording's verdict back + // (DELETE /projects/{slug}/recordings/{recordingId}/judgment) + UnjudgeRecording(ctx context.Context, request UnjudgeRecordingRequestObject) (UnjudgeRecordingResponseObject, error) + // JudgeRecording Render a verdict on the recording on screen + // (POST /projects/{slug}/recordings/{recordingId}/judgment) + JudgeRecording(ctx context.Context, request JudgeRecordingRequestObject) (JudgeRecordingResponseObject, error) // CreateServiceAccount Make a program an account on this project // (POST /projects/{slug}/service-accounts) CreateServiceAccount(ctx context.Context, request CreateServiceAccountRequestObject) (CreateServiceAccountResponseObject, error) @@ -7424,6 +7758,67 @@ func (sh *strictHandler) GetRecordingVideo(w http.ResponseWriter, r *http.Reques } } +// UnjudgeRecording operation middleware +func (sh *strictHandler) UnjudgeRecording(w http.ResponseWriter, r *http.Request, slug string, recordingId string) { + var request UnjudgeRecordingRequestObject + + request.Slug = slug + request.RecordingId = recordingId + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.UnjudgeRecording(ctx, request.(UnjudgeRecordingRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "UnjudgeRecording") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(UnjudgeRecordingResponseObject); ok { + if err := validResponse.VisitUnjudgeRecordingResponse(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)) + } +} + +// JudgeRecording operation middleware +func (sh *strictHandler) JudgeRecording(w http.ResponseWriter, r *http.Request, slug string, recordingId string) { + var request JudgeRecordingRequestObject + + request.Slug = slug + request.RecordingId = recordingId + + var body JudgeRecordingJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.JudgeRecording(ctx, request.(JudgeRecordingRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "JudgeRecording") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(JudgeRecordingResponseObject); ok { + if err := validResponse.VisitJudgeRecordingResponse(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)) + } +} + // CreateServiceAccount operation middleware func (sh *strictHandler) CreateServiceAccount(w http.ResponseWriter, r *http.Request, slug string) { var request CreateServiceAccountRequestObject @@ -7688,232 +8083,239 @@ 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{ - "7L3rbhvHli/+KgVmAMcaipKcm6Pgjz+0newdz9mJDdvZAU7oMyx2L5IVNas6VdWiOIaBeYh5h/MI830e", - "ZZ7kYK1VVd1NdpOUJStxxp8Si2R3Xdb1t25vBplZlkaD9m5w/mZQSiuX4MHSv56Y5RK0f5rjP5QenA9K", - "6ReD4UDLJQzO8bfh8+HAwm+VspAPzr2tYDhw2QKWEn/o1yV+2Xmr9Hzw9u1w8LKo5j2PdPjRTZ72Fr/s", - "SqMd0Jr/IvMX8FsFzuO/MqM9aPpfWZaFyqRXRp+U1kwLWP7zr85o/Kx+/D9ZmA3OB5+c1Odywp+6+Ct+", - "aQ4us6rExw3OB68WICy/VignlrKYGbuEfDR4Oxz81dipynPQ972iTBYFWFzQpTYrLaTOhTZeyKIwK8iF", - "NyI3wi+Uo3X+YK7gAtcE+Y6V3myF+Mxnlc/MErpW+fMCLAiPa2VaojUq70QmHQhtVsJ5qfO0vhcwq9zO", - "9b2Xk1yaKxC5AUfnB9fKeTGzZilWWxug9bqhMFYoIgZZWJD5eqwdeF9APhpr3M2Pxv/VVPpet/KjEa7K", - "FsKCM5XNgI71Jy0rvwDt8bX3f7SBSEsLDrQHpFBROTktQGQWclyXLEYkN8IT8YUXWWYqXmFpTQnWK+b/", - "zALu4oI+QiaUfnA+yKWHY6+WMBhuShBck8y8uqp/1l7kxRQXJlYLVfBNS363WBl76UbiQqe/KCcaDxsK", - "DVdgxzqHAjzk52K1kB6pwsKVAuRA5+XaCaQP2jASP77hV1NZLQtxCVA6oeVS6flYKy8+vfj2hTg9PXv8", - "EKloeOAGYSlV0ToP/kvHV1XeIWSHA+Uu8qXS24fzg9RyDi6egBsKvgAnSmt+hQz/gruyILMFOIEHshZG", - "gzAz3OpyrD+dlNbkVeZHy3wi/uv/Ph49CrsLy5gaU4AkpmEt0aVTao3xC24ifHWYdhp3MGxQyOv0CjPF", - "teIbLq6V66ABYUHngG8TuVqCdspouqqwTZFDVkgLbiTMv8lC5cItFN6dEdNKFf5YaVEo58/HWmohr5Vj", - "KeLEFDJZORBSZLL0lQWBYkQZDSgI+SDaJN5zCMNBaZziBb/plLNEeTJ3QmkhxZW0Smr/wIlCTqEYib+b", - "FViUY+DETFnnR/UdKO1hDnbrqMMppzd3HekT3tcTIpDttX1vVqSDSOQ/cPEYHEtSIT2dM+T0AtxDaZT2", - "Tkg/Ehfp0FbKL8ZaG3EFNleZF2vwgmlSSHyWKgpUeL9W+Ry6TlVmGZRB/m1uGvec9M72h978Cz6170Mv", - "i66PNk6Svzes11G/tH7DjuN9gRJ482x/lHiZyG7xnKZrUq/OQ0mMGahA/Pe//wcLtzy34BzSRzhI5cZ6", - "ZZX3oIX0Q5Er55XOggIMAumrB04cWZiBBZ3BkfiUnlWW1lxBLqZrD+5h16njOp52y5ywss5PN84uPKX5", - "mx0n9Q/eGF16UTybDc5/2a3BGif8dvhmawfSV/R/oKsl3+QxC/jWFTbuFa2J5gp7d0VP3t7Ja9qLg23t", - "J222UFdMpxsSTIv4IdtWBcgrcGy6SC8LM69ATCu/oZNGnXI48ug+3d9mffqhh7mxa75VXRUFviSa+Vs0", - "8E7avLHtA16hOg7rb6DB4ouRXfCEHNgrsGjt6XBiDsRKuqDt8pF46UmDz4xFFTfqWlkhnf8uTxK6czt7", - "lxv0TQ/PIMnA/ktx8JK+iOJJ+aJbm1RlfrOz79LC9XLjq+IihzWxNu+5+d5uHo6L71ByRixMkTNRT2VR", - "jMQTsyyr7Ysk0RUvEnUOG+/uG6HNWIPOSccI5lmHSkc6gYrbziv8IouyyPDaoH533tJnQWDvlAGvO24W", - "d/YT7b3Dqr0Z29yUBxIRLJX+O+i5XwzOz/bdL/+o+454tV37cPuFBl1vLTJ6TFLkpgs0tZVfvzs79dtS", - "0iboYz9TNgyvPWq+aZumnw3DwXQeJpNmh6yv/MLYHkEwNfm6i0OCNRUcECtW1ngYokHoF6As+jS5G4mn", - "XrjKXqmoIZRzFQi68U7R9i5yWrlM2vwFSGe6HYtcemPXLHTDt9GpQqvlEkofZS2ZfmTU8ImMbuTXuGqv", - "vHyKX2Llzz/oMGKfaRDWrEQJVkjv0dnJ+dSGAl0fQaarE2alRQ6FugIL+bHU+TEZpLmwptL5WH/6ydln", - "jx+OxKsaUXjAvyKpKdAHwUuJAswFW1Z5WLqDNvLKyuwy8j2fh7RWrvHfuJYldFrp35HjFr8wJBzk18ox", - "OSEvBgvSAvBehLfoACn0NpgilNGjQ5f6L+FFXas8TMnx4SU9d4ihSatKy9uWkq11dPF1skOJ/WpN13jD", - "sObbttqrz36HFOjVfN0o1EhMosqZEN9MEh9NhAw/MbpYCw92qbQsxprW7L4RUpDekoWw4CurHXpPk6TX", - "JiI4wBqufSTpdVsvenPskdqQIfG/h2jGJBh6tGQwoC4a3tqmiqlN021vLCmg7Y+Ch9lDJBYyY3Ol5+4A", - "IV8/Kr6xYTK3ntV113+zKu9WnT1ra628B7yCNlyxkE54eQkaGXmh9BxVwBp8p/Bs7/0g/sU9vIg/62Ji", - "pAHXg1UEJZUVCtcekJWcQYKIA6DxTcgKicKR+Fa5spDrBuqWRdPP6Bac1LSWobzZll56KLt2Q0d5E+0X", - "JEKHoEXJHz+NqCjr53r3pLAJtRHG5uxuHLyHf/DD90q0QHCNtcYTO4iCg+u3TcgL6Rbd2w6wc8IgSL8x", - "eEDSynmDdFBp3LIgOpGBStJ1OzEFNAxEVRZG4hJZV6342+jWov0PnuBINB16iKPLL2S8mvY1En/FZ5BS", - "V0s5ByH9WE9OIux58sYV1fztSWT7kzfh/57mbycJbcGzEJpAGm0Sc0q0BDKpcd1TCGaNcpCLbaz07GHP", - "+glkeK6uoejB3JZSr0VJXxC5ms3QIkE3aWlIMUgWGDN1DTmaNcfZQmoNhfCmACt1hpYHszjkwhkh0/oz", - "qRlno9NBm8ChXyas9Auw/Ox5hTdMAOdYN6UUngMuiq4KBdNCliVoyM+FyjlKUUQKwWMDChloGPKx0V04", - "YWZxTyhBImLrhMyXytP3+diWSqslaqvTYYdKIPhK4173Mdbz+pvBQqlcv55WCeUMipoIgm07Qjxx7wJl", - "R+2pMvGP9UwSqE5CTjjweGUyRlEowsefMK/EgMGjYUDqHhHBTIg6Juci6t1hij0wMaN+cEpnIPDa0UCd", - "wtrwV8Y6XPQDpFrloL5ius2/4j80XS85zeYKbCHXcc2STdlzIcUkBkImAVFZGJc4zI01hd0Yj2p8tdJe", - "FURayY1x+BWD1LVS0SS+NRx3IwhSteHHIYu5YT+Ix1Ky1pG9cvIgycTCCO/vSuVgkIa2ZVEttU/epP9H", - "eTQavI/N9+2ZlOhOu23btwLt7Zq8qwhYexToSKZ14ITsBsXg9o1UYtRVHZq9x3E8KAbzrlBAOIi+84sK", - "fOsIe5GSKRTdMa0Qd6yDWxyVQzdiqrRkW4NCxtKJSS7t5X/959JMVQF9NFME91jmbKrI4nlrjX1eVdxi", - "1xnxBtLTu87lqUYTrJEdcBiyn2LTW7D+Ujmn9PxJHQnfNgVqLeRNsDei9VFWjqxqOZeq7fXucSuHA238", - "xXMWCLteaWZ1pIx4AS0ktBie//i3kbgQDkpppUcNDkVOegTlfmtb5MSVlS2Ng/Nay9QoajS/hsKZsU4m", - "FUVueW9iZSp8vLqOngSBF2uxkFeA5zJF8joOS8030Ir9PnZH6CPhMV1BDlPK3yp8ZQhF4RKkDgCWM0vg", - "jA0oHIzEy4rTXSI6HDwOMiRYWZEinsrs8jzGdflstBHs0tpGlkLUtqefdca6mD13QqwNKLa9s5cLs9Ls", - "a3vT9PRTvDmud4baF9C0wksLOSho/bJX1NimkyqFV9HW6DEkK9vOHaisOgT77+TSFgDVI7+6MqtmAQRT", - "OVlKZMxPIugQoI0IoEzIoO5GAfH9T/vx5BeMePStYintZZSQhfTgAs6CHBHBEhM8NQuzoVAzIfV6JC7G", - "On7uSpCXjkEXTilREa0Rn37y6OzRw/PgmCPVURqR1G4FZPuRqYfkxhsf4m9THE+5wOrRuWhnkcRcgg7v", - "nlbWi/ltbzHoiMqSdU1oX0A5OVUlRFaI4yltBZ31uboCjccTmOTRo6Gg/Y7Ed8vSr6P1z2hEwK8Oxzbj", - "1e3CC9u7q01flJpXyEQU8sPjq/+lGAC/MpmcVoW065bg/fSTs69OH46a1uahQNfrw5k/ISJTYy5DCodB", - "2RaR5m+aEuGBEx7lBB46/jm3ctaN67wjZ9d8FM+2i9cTgrsduMh8b9xC3gBDYYbcFTowuoYxKagRRCGf", - "C1wxDGboSPfbwL1YAHNb2K8opM4Jdaop/eFIBBfXaLFQjhbHGpntBXa/jMbFHhdwBQWlHLoelr2qMxja", - "iyLJcYySg3yv2kFCc187Faw8qYPjhw7ruah0oHf6rE6pIW8sfmR0clBrn5QzGhva9tNPzr78qsUQ3fks", - "aZ370yDiZoeJcIhMumjuB6nVLKT+9gQeDxIm8TmUZPGWUIKn/LuzmyCZz9gW8Sbo6HORb6CUoglSigti", - "YUVuzWIoJAGcaIpUyynYpPnohw09vgSpHZow48FqobKFuAJL+WmMFQXkdDzoJKYO0M/tPNu9uF4pvQeL", - "+/8/biEfffHl+S+nx1/L49nrN19+/vafBt1JDe+Es1zVntCuHyXEc5Oywt93+KstSjjYYFF5tMrmKZOk", - "P3dkP+S+/YZZYVbB1W84xSPxrGSna9iAgvBEpA1pWMcEkByqVeP2d0L5N4PR4yO7ofS+qNpumjwARXkH", - "qrw/6tqPiNxQbPXCGRG3uEmiR4AoduISPwCKKLdQZZe+pzzMPo2f5zdLHEjZzJ2RrpkhhBHslcpSqvZQ", - "sGBEda9NHV8wnJc8amZS96dGu+dgXU92bQC2lRNLOglWvyX9gGx4FMZzK5cj8W1Ti/K6LhVq/9lYx0Ty", - "Oo1pmQ42qowYD+vInL5p3vRwYNV84fcb1/wtyma9BL0roFB7w8EnCfvmLSU7iK+Jzwdvx4x1iHWxb42v", - "GYmjo/8N1iQbXBYViJWxfiEcgNLzo6NzIeOJ+4X0KWzSLGmIt09XEgAQcQnrVjwCbSWv6FjjJaC+TX9u", - "5vjjCwrlUKoTmCCppKYdnBnrjkz2PiSw5pAECCZqS3dUs0onA+Kz81d4bndSFLEPS9z2XeKr24RxdMTQ", - "BfsrSScxLnZ0JJ6hI6y841AYgbfGon3kTHIG8ZDpZsmJRKeXLFy+n8I4z/QiyO0uC5lFpw61KGXaHGL6", - "tKBG3s2+YoFmdVOnyfnyxvmY2Uaix+E5LpuWXPPDYWM1XRv5EVa9FTU98vYnrX6rguNSV0E5Nddc66DJ", - "BXEEUb3i8gMOckrnVsbmhwrdRuHJTFaFH5zPZOFgl5R7B+XGC+g5mm7jr52TuQfW28jIvMsMzFZSde8O", - "+jIyDzqzdjrknrqTcEunh9WO9C24L+sxJjbuWe/B6V47ki+UF1T9Rvj+SDzTIHKYQeaFK6XWKJlmprKN", - "HzhxdGQ0HB1ROgKhAZSc2P5atoDssnYB6cOYgLwPH9/hifbVRIRctMa2e858lw13MzthYynhxz2vfc5R", - "yg7vimI6z02hsnWLtAaUjoBMWcMMeEQEEqTPXu9I991HPaFAueE0/CKP/+30+OvX4b/Hr9+cDb981OU4", - "bN4DVzTvIvaXbK32SuADV/1Ottzfu4ODCXKkQrBav86MHXL52KJaSv3AhYxhTqW4qdRtrKDnaHoMmmSG", - "3OSlRe+Leknw7gynbWJuHveEyXfCYCU4wV8PEQKyq6UjDZpQ5W/EJJL6JKa8hV+38x9uxhnbwh1M2QVK", - "1ylE9AX2pJLJj8eJrk4qhk0VojFOiSKy0lQW6xdojHMAIZh8e4tC21k66GHsTHMKXwlvUZ2ljTXfH2Ao", - "Ntl643bbeb3h/BrL7KHABgLWm90bkVnpErg3En9ZewggjyJ3E01qBgWdWQIfKOohpcfaUDLFlbJGcyK3", - "4w4AbDyHbK7aTqc0rWg/b2hia1YObHf5AX/2DwYiu5NW60X0KGrjejJxnSmq7jpX1N8wQx2NjkL9zWFj", - "b3R04bCbHokL+WV+PeoU6lt31ogWbijLnsDEz+8p9BADcyny0Aw31BGFZuAh5inECMVorx4Lm3rdeRAo", - "kPodoWDb7EINwldCVMERfE3ubg+j3tivClGEHpuPs7SEmYUC9ebpkuRdSDcUcuYD8u7k1eEg6kbZ6du9", - "hlvw2OKxNRbff/ov5RV0Qm8pTb6Xhly7JKgw5pKzEMl2jRLZG+Hk+qabDmUzmzBkP0nUkXbHgWZ0IpX3", - "Ss8fuEjpLoXWOPh60IoaXkXHiirdf1ZP4jlxRKwOnbUPDlWAa0ITS+WyQsUo/lg7yIzO6YC5wqcusRZS", - "eDOfFxCyG+sHC+DUgLMvvnz4DadftgP7U+MXzWjhHVxPpRv17jvPgoLLSQD1HMd5KC4zKx3vdqwDcYXM", - "nFQzJUMtf27lSsfSruZ1380eO+V5sp83jDNOGp8IB7GDBbMEKg9Oj3WRTb4RE4YjJ9wupv72UBjN6Tcx", - "LZPtnikUhthrJF6tTIjbGM7C7k/3jsYdLw2tb3prp2m3z724OyP35u05duNrYeX3h2puZkiy36MN+tCU", - "1o7Ljuza+CiXyBIMFffWfv/kDmgwkzBPycTTaIyjfDuHAYqC86tLsNIbO9aMczNGHyomVFGgv0YdRgip", - "w28QlGoObyGzCyndc4ONWt4d6l+iz+MUFbqQhc7VjlTpwTpAmxyGQmbWUNmAE0dHq4UpYKzxmaBzlMdH", - "RyR5U20ifi9XlmoSFqrILVCUfWqlRi9AC29NNS0gGmNTGOsr5RRV8sckB2uMv3nDEG3802ZJ+Lt0FWHN", - "fkAm8ebLGj/ek3tU31Qjq/jg3N1NdmmkDaPypjY3nLTbyCJ+Mx74BSxhPDgfD3JpL8eD4XiAiy2N9fRX", - "TjEeD95ORmP9qqPLTqqlldfAxRObXXcK5Tz3ZroO4aNYKJTaeLmqLIs1odRqif8niRHPhURShGj+ULIr", - "xNgh/SPI4Gu5LAs8ml/eDAqTSXSTBzOUxvQt4il7ifZb2NzgPGyNEo7TT0AP3nZdScxO3pJ8OfjuGKgW", - "cF0WMlyBKyFTM5VxyqhywmSctJf1pEdq56ML2kwKO075Pd1VdN3FLnhv37969Twa15nJodua70l7uxBu", - "YawfMtR0nLLVXbVcSrse4oNDzw0hQ34hPrxzb/yH7Vf89OJp8Ptm65i6GM49Paxx0YOF96U7Pzlhehvl", - "cBWTyN0JirFjbfzxjBq7vR7eLLWOPm23yeisHMEzh6yyyq9foqkT+tBs6Mqts2yHxB841hNDsaTIYR2g", - "cyleN9af/hLqh75+/Wnc+Vz5RTUdZWZ5spBWTU04i5NpYaYnObqapjzJTeZOZG5P8MfHfmWOL5XO3bGZ", - "HYcVHBsNxw48/omhy9Eyf/iQSoie+mAXkViGa5n5gtuGBUlwXmtgTjmNEuJChAhwWbnFWPu6qu4vdY2T", - "9B6WpWfMg6RjKxas5tqkoCFZkxRoAmnJ1ArXgedB9M/ual9Jo7lUQBKFA2OmBO1GYvK99+UzXawnIdIp", - "+JexQZcPpn4AUoQjL0D5Rh+BRlib7IECPJkDk3F1evpZppZz+h+YcLUjOiCxdpFtaE0tPHF9df9Nvsp/", - "jXuqzeZS/S9Ycw8/pWemi8IWhkLiwW2hXFVkTdD5sTfHgGcPzgu4Qn4LIog5P7yWPV0+zMHp6Gx0SjBQ", - "CVqWanA++Iz+NKSWoUTzJxFUxH/MocO26kUgQ/zem2ByO7kWzoTaBXJCuGCUDT7ddrwIWKKsa0PXt1B1", - "VQTXQ1BWdiMB84HjJ1FfGermJ30qmKDrYCsulHEP/q6cv4ib22hx+uj09Eb9OQ9ylqJ3sO0pdXZtrPv9", - "Tdch3/7tcPD56Vnfe9IOTjb7TdLvPtv/u7qNalMCkvJty75fXqN2TWz5y2tUrkFppKz2mM4R8/WT7hsO", - "vJy7RlaGG7zmGGd3FVAKbCOdMptX5XlIsaVXbDb601SFqpXzZLaLpcxBKN/TCVH8WMfM6/yIrX5tyokF", - "NdKDNS1irJUO4G5aYgJyZsbOwcd+sPGv6Gj430/mv6JmEqt0bLFlZFwhAyPOLIFcrrlNQdpGklQXLz0h", - "DyUSOOtccP4vIZJ8J31uGwkUb9t63dsK3m5x8Nmdvbn12l5GDex5up/NGg2U75Gj8Rdf32/rWVk3Gggt", - "ermcVDfO7FaC5gd5CU050Mx56xYzb4e1Sjt5k7LC3rLgKaCrjOXbrYazIvSbHQkWdTOZha6zIQTSbCtL", - "dWIyFnZHBZeK3GbGgpprSpcLhWdxO2x8lxYy5aBA7ZlU+1ibyhepe1RIg0yC5fFtBMvjY6mPqRLgWLlj", - "2vCx0lfk+DYMyByWpfHkzqWWvHXpA2+AuvHWt99o3RthvbGO0odakVIhVghSyOgGtoVNfR1NgdPi/c97", - "SjvDsghmMgKtXzT88PSVHt03M36+/xepg/X9c+9T37KzqG9BUODJbW8p2dvy8ktvyhYvW7Oku+HWOb1W", - "Q6Of/i+dDe+bqZ+Hd71/TUZwtugugbW50hJtnPoEFFqjWc3qqF0dl0eZggp5xMoaPS/WqVChToRdSReM", - "FAZOSrBLqbkN4lgfHb2KLcDadk3dwSSHpUH2PDoSF3rrplCdj3X8LVcoqU0+WLaF6ZD1f0v3C7SlqG0j", - "+n3k2zYtHAITCJYSufRyKh11nHGGkhdKqSwXUndx9UuI1vgLQ875u9oQGxlOdXbjZjbjJuQavtmBBhxg", - "bfRIHLr66D7iJUt3CTnK/I/i5vcVN8+tQY5BimbeaZB+v+mwAFn4RcMXbhPx38B/z9+4pUO5rwGyuezu", - "plJng2zTIpW6tV0xYSvtNvE3hgUO7Z1cv7SbcToyAQJMRrnL9grdc77LdDcvoERBskpFH60fkVHkDTea", - "bVxV+Eq4KQ6TdSIWm+3PNEBOKNilNqv6rYZwuoQwcI1JBJlmxi67xNjPC3OxfHq315/Sw//nzRI4mKbU", - "XEOOF8OW8C2k67ZU6ZEgPy9MbETAY2kamFWTLJliAlnGc+4lzlfULxI2Q9ec76Dz5pXoDTEZrIraKgE7", - "1irEaFp2A/dDq9uJeSOWRBqEfDYLosgoF6VZcWcL6Rs2B1yDzahpWrBawhe7Uwu5mxy1muLUhAUU9MtG", - "uF9RAfV5Sgpyw2ZSXvhXyG/gtmaboHsT1G6A2QxAQmzkYnQTeX/vzlIn9vg8UsJ9YI8x/fZA7LGWBh8c", - "9viqnf5BfeCocdtSrpECGpyZmvK34McuZCse33tDttL93C+y1XptLxkgk5oS9EeEqwvhckU1p7RxeUlH", - "dCvqfVYCeoxTYy4bhJqURluHhKZzu6zRJuHeQsbcnob+2O7Ore7sBci81qXd13YITnHjwXyvO4jhRF6D", - "66UIinldwz3Fu66VOzjYdQ3uz04ijcREyrONDQFx7435AKkZM/Udvk9yGg7Kync1hyy4dxs3QhZIQOQM", - "OCBrV1qIjTbQXOByJgrD+Vhk89///h9j3ZU/FP2KRpUAG080UikFrejNCUfZGqwVQvoza5bnoixkhsc6", - "iSlBk5CJP9YTShqaCG62Hpot5uAuvSknuFYjJuGf//Wf+OlkJL67pmFM87q4UOKjLNCbC6qjpi6uMqwl", - "dAXaaPfIOVrNYgg91qkagkzZRiJVVLxmifZ47C7maG+QkuSNo0ptvO+8y8p8hqeW2P0u0DQmyW1EFNdN", - "PsVQLI3zDNvOVCa1r+ecNTCGX+p8rZjJNYzJWq9fD991QgAv790AvD+AAKxlgCa4gdqO//FNrvsTo9+y", - "aKyFEUfLroGHabWjBLuNJnLo3MmbhXQLsqAWIDty8bnpKbkQwZsIVT0OuGMf9e48r/uqxzBn8G1D1SC3", - "9YScmzJsphUtKY1fillVFOJKuUrWlURyNjOWx4SxkOBGhRTpbEoK3xpGG3ujek63CT1HlXdQzGjanFtI", - "7rhByb7JX/rvf/+PRgvwui08baedr3b6+S285tPPj8OJHcd2q3lsYHqMi5VzIOeZNUcsoKMe1Dzhk++l", - "jdw1QxFyaiovVgtDfUhDtFV1Rh6+B5n/pTDTHqOoq5Sp2cEVPUzeywfDrH1bSnm71Et+Y2u3NIC2EV16", - "ZWKYrdNssDLx6vsye4YHzEkYUlp16Go1ETMTxjtP1+LLzwX+v+UaNrgW2UJameES20ovdZvC/yVzAFfB", - "j/x69vjL/PTx2ePHn2df5V9+8bV8NAMpT7MvvpD56dkX8rPp7PPZ2fTR9HT6+NGjLD/7Iv8yO/tiejo7", - "PZWnjwecNLh1GKFDev9h3LhnV6+V2EwHwKtFJoynuJkGQOJko6yHBRWJnLFGmRPTRtWMQc3ccGSUi3JZ", - "8mttKp2hIAtpJijA4ltDTRvlAVJGWBx5IX1N6byJKRlSHIBa1CjrrKISxTDRggK2FMJNWRgpNLWSlLyh", - "jVcZS+qfYzO6RvpYa//4WehoTU38g2CfPDr9fELPRQHGGaomX4spcINYmYdmSEOeCBE7DQd9hE/lNbL0", - "5voOyePJx7qxGNy9N0Xe0BB5OjsV57lmxlrI/DfiCmzI36ZjGuslSJ0mSPB5xe1oUmUNNce9U11oHi+v", - "jMrdSDyRRTHWk++/u/h2EtwGgmkvVRmkfPvIuoT388on2X2IhWsyD/7YeQty2TbpUiL5lEL7HaR/KDTX", - "L05SciGpike7pfEGtXxQeubRo/secs7kfoiQuINEFVtfKTMYzcbjx3forS77s2tqTW9QKOAH1lTzRTMi", - "EtPRWh+1VddIXDRm4ND85mgz9ll7QRZQuQKllxNz15am9NQHg+tVXIwL/l524WbuLbdyMNwDqJ29Qta3", - "aw21pnzEsbZmNaS+vC6NiLds6DeDSD3FooxRRFageXzLZUXJe+d1s98HLhxxaBlK6m/YGE6uYTXWtkpN", - "ZR3/LS21WT9hrsCurCJgIk3a2UJ/Q3nu06Wcw37Ij8bSnJTc4vOGcrGjQSs+7U8M6aU9Nto5kxF7j3Zr", - "x2OSMLk9kpwaOneKpL+qArcTo9BBZlBR6TCZLy4LXby4sXJyesOkYKpAJXeUf82GzUhcNMdoh3Y2xDRc", - "UtIXUX0S5gB2HfZvFRDtxtMOHSAOUz2NNhfp2Dce2GgQtwdjff+4U+ypfQjuRCf8J2ZTpIv2BPZWAJha", - "cb8/OL2vokUoNLWXsZdFamH9TTMtiZiCyy8oeaEuTyOFPNaumjq07rSPUwuper0LRu+p2HjCHd7eU1Cb", - "6fB+I9r1O7tpPcayh8JU3qmcbcRZRXP34tgzsgqigfYRgu0IUCdDyUIG3DHWCZV38FavZkHD121YvR0W", - "jIP3GbzeRy5//rA17nIoZNS4xqIlfV8yss+GcXBn2fpPiTCJVpFG2eaIg7xKaUMIUlKIqk1/P5X5+5WR", - "+Gh+yfuIUb0r3Vclz+Vp0P9H4Ucs84R8toRNBR8vxBqYld5JAp4E9mNE9o/NZZ0mzVOyVkoXUMpQ+U1m", - "C/+5MDSQLMRtQj04TR1NPSdCIyqXaujdsG5ah98PlXPtXtdUiKfi/Ok2/wY3oluHfN7XsI1thCgQP5Zp", - "dB8R1bIEgLJxVrdzqakAiZ+PHBWnoO2w3A/greaMj96M67lV+TlNNaUoPAWXhyFxI6R9UNyACs3rEq80", - "YrgRagjNlaCkb6WxMRdpLoLRsNURJzVVYqwt/GqsQ2G7jB/Q3NWxHutvuVOziyWjdWIRJWCgWdZ0Cy7q", - "TjX4+kxqGnvtLmnesbF1EgudPuXKsq8htTBFDrYeCx9H6imXghI8NzhkZNMT0uxY9kamADqeVl3lSSwv", - "tQCaTod3wEMFpB5rsNbYc8Gp5iE3PLVf4iSbAubKqyUqdFLrvSiYg9hT7zB0oJ72/z59+X0jc/v4r+7u", - "8efPl0uBNpkkM40959kuH56Zeoi8ajTu7JRXL8NIuKQclc6KKod8JH4MILhysUI9VnDkymXScu9aiFUY", - "sRFbVL2pZDUYyzwWn2TTePBUWArih2FnNJEcxNJov3BCzs1wrFcLIyzQsG+h/P8/Hohl5byQxQo1PY2r", - "lTpwf5rk+mVvhcSTuj3rPYBmfY1Le5RgnCbwJ2fCVZTjJMF5nm2PuRtv68/AhKzS3AdrkD/T3EFZZNJa", - "1e6pykNkyGBJLc7PW3VWGw2K6xYN3KG4Yf9EEbSyynsePsslWs6PxMuyCK+JyRVjHbs2hZbPxew4yPMp", - "+BWSmF8ZTrdLgTWq87RiZahhMWV1Seq2QQ3YR+Lo6EcjQOfU+Cm0DnbxFWQYcNNKmpNLUfQwEDL1n2y5", - "Gb7uGuyGaeYzoVz5WEep9WgYGnufPeqUXy/lFTT6Q949dtDoSX3P2EG7F3mHfEz1tanN+JLnEAhvPgIK", - "rVQCZIZVqEiMdcbx1HLIVA5NTDVKpT6/h0JQak/9x5P6a/cTjApzkQ5UrN4CDAUPQ0rZtqm17P8Akze5", - "u3QU7XLFeHHvP2TVHS0KV/keI0aRWO47atR8bxfkwZ//6aTXvQNIFwJdDsrQDuhRtFAIMQi1xrfipIsc", - "HR5tcojgyC5G2i1J0SKMgf2dPcKoSCpBGUbDSHxL0CTN+54p2nJuJXo+5BRRe2RVgPbFmmYLpB4xY81l", - "8NQ3jHM8wxoowYSf6KrpcfozdaaI/nHIm401RUdHEZ47OuLvxBadKZ/wHI0pTvfkBmYujBtU3LNmpgrI", - "h2PNDbrqWFF8I9ph1EMA3UpBPcNH4gfDcyQ5r1MlKI9WPNahHXhskb7Z4QsPuSVuDsNuw3koJ+Z4BR9Z", - "b9cREdyI5Hr75H6i30T9yHr3qrf6/KlGSs5dhBJfAD55KCwcs30SIuChOlEHaLUo4txUqeeVnIOYqetG", - "n63MOO/GmiWJ+GfkOVKv9L/hyXVONaPhzD6umqIoE59+cvbV15x5OIljIydxjAEPKqHv40WcC4neG743", - "nMcDJ1ROA1MIYcMrG2vefioroo784oIFE0lmTo7nHmEsXFODeJ2B88ZSJleQPlQwQSBtFPfUmzczRUGT", - "/Lu4PoZbb21k3Hr85ma/t73nJ7qPj6H9/09UmrM9u8dQNWd7dkwheO+1jHdhGPF9o1ZboRbQufsofPfY", - "PVOQodElS5VAOmwK1MwW+XiL3W4vtYkljeVXyp1iu9NKCqjJyZvwf8FE2hDwXQdWf+XkJQp7XOie7z2J", - "7xjskNBUScugtYWVsZcB7wqTkXiUUj3p6Fx4uGYpHqZYj3WM9w3TtJgwPwmlWRihNBLPaDSVlTxlltow", - "4ZceOEH91sfalSAvY0VvaFn0IBVw65zDaQzV1xbblkz8LlcRBr8zgXjgfN/2EN+7mJd7yJjcg8Xbbk5H", - "6/OCJgvnf1qXbf8JvAhzaW7ZzT2nDuBxDtmSB650wP6HComTHAqFxs29SYvePNwcroSTVNY2HjwVM6WV", - "W4QI15Bg8PFgJGIHjDXXGSsO1ye0XBbOjDVJUW5AbWFGHcHYMcuhBJ2DztZiWi1DTzU8XqMjHJ8mus1M", - "bLNGBrpyqQfGmoLy0l2m5PqFvCIBsiwLCGHxBspPZc8Bjkcb8pybnrEsy6QOy4pfiXPyeMYLxQDJ6okx", - "/TBrG9eADvtaOHyXLIJwtDCjCXqfPX54Lib0NzRJuc+c55Z2ZPIqErOUp4zPmgL1uMC1s7htwPF17KIe", - "TtLjLeJT71pMhk30zU+cibQbLqTyTkxUPokiP2zigQvH4SY8MGmsf5A6l1Tcb+KAw80d02g6LvSJe947", - "LTWIzo+S8veTlBfuMkiAX6t8vgzjYWWDUmbq+raykwP5v7/oDLkCyollJGgWbZdQUid+ipyl1AI2y7jY", - "eGZVRrlEisAuj+IxP6eDouH/uXJL5RwKhSDrZON1URwQXlZXUeCThFPXISWhKxki+OV1LkTkO05FjOkQ", - "Ro81Lplc791pCt/yo+5a9vBmbzp9Pfzqo1H1AYiKl0ApFIH+pFM53FIwRJGzC6V+1bQ1LGSGgGYbR3LE", - "mbvRiKmcLM7j/GthgftmeTPWaUb+di6k8uxMuXq07CdnX341FJ+cfXX2kAWAl5dwzEz7q1GaTYQkM1Pv", - "Gycm1FWRvjoJRYSmsfCY37RagB6icSOU5npVlC5RUBQq+NHpDSvpOhEoTckI925JVDokQbxfQyIYaJ32", - "xMHT0I0WLrMAeiReyct63kekHSpos8DFa2jL0oDK2K6NKEPOk1h99OjhNyEFlt3oBmWwZxwmq7PkjlOQ", - "mRRDRkqixY+G0oci/UKmd+JHvPADU8nu0cq5SPQW2UnWtk7whDlX0omjI2380ZGQsRVMrkIf6xyuxprR", - "KE4wCtagi83AaPSHXguLl+ECkEqjveN4oXROaC+heRXGjlkA/pXwVpVUi00hiIZVE42aLnH3L+9D2LEo", - "6Jq3MdwvCP8AYjAAHF2t88NbqM86UQa1LWrOiw6IQghuWpDdiP9NRe15k1dCNm0hkVj4xhNq2RCrI3FB", - "N8EJ+gVQJb3yjaHLAZMc6yiUv0m74hmaQlKBnEKBS1SGvg1tFH+rtOsTuE2jNNBDc9MfDdQPwZele6ud", - "1zVFKBiqFsrf0litxx3/7n5sGE2sYsHPTy/+nmxaAvOpWvToiGZZK276VlfpHx2NUmdbzhWlUbChLU6d", - "Pzqz4Oq/ks2qiiJ8TKgitfpiNG0lncgK45IorKfFcczBiswCrVsW/DAqUdI81yB4rJ91eqyv8AG3F/q7", - "wmBPcRMvYPaRr/+IfE0xLErWuPZAowiZ6LypHdLDuTuUSr2vTPlezn1eudjuallSIsVSajWjvPMLdymo", - "q5xodXlt9pgLnepW1MvK9zTAZJ8jPldEmcXtRSm03+jrFoJ4PNqEVhb7W8dOVo6HFuk8ZbbHkjpVJ9CH", - "LBJvpXaSQDLKzgp1gJRbNQxF+rFdDj0rFcSWoRFgLP3r7f3xHX/hPcmAH8Kp3XcmZ9gVay/or6GrTz7Y", - "l1NjLj+mdx4m36mIuxZHnSec2KaeF0RYjlD6vMk3oSp22MhgrHToVhkmFeXCr1RWp0jEdmy20k5MUFZk", - "fhLq3EIOfZxYMtZ346naSj9wdTUitYqPVNMQlkkadgvL0Iqut7LvOZiy4PSA0pq5lUsXBQL5OmJ73BEb", - "Bq0ud4W6hLGWeo2/ozDg7zBvOqSLhrHtoCiDCn+7mdtCUlKKQkGjZ3MmtXAANWOixHwBMgsFTNRZG3In", - "JtybdRLqhhvTOFFwOqDKYXyichyKCVdC6BJ/PEyUQ11eaUhUI47a0/Ovs2Dxh3C/91FX8UO68EMrKwKJ", - "DEXJZBYa838og51+JuRXBttauY6BKzccDHs3A1fCsR46TJpC+c6bkjfD1XEB6En1eet6kjSFpmJgrR4q", - "TVbLkhGmfSOlpV7vGCk91kmspZHSLXmyxQHb858tXBnGgps/5LmzYVkb7Z6b056DBdYz7vkFPhsa5H5g", - "Ovi6kXJLR92img9ooBlroDDh28xmnbOG3jfpD+94tHJXC/GtMYU0gLg5j5AbfMuiiHNjWO6bOKGGRFrP", - "jErxN3xarGI1SyDiDLohtTIJL6unk3P2IGu2VgvWmaSijnPuLd5cNj4z1UTwcNmxTm+kGmpn4qDNALi1", - "teKSOQoaHotV7pJadcyBa2q5wDIzqGVlMSOe/DHMdTw6YnI5OuqwGR40T7TpexCwqRhljgN96oE1VNZP", - "zkj3iMUYCeTcJcClZugPNc/M24o6A/2OoxeJCjakyXupZWsq6HefIL0m9CmRZXAujYNAkh/raVtDnSlV", - "JIrKhr1HvkPWaN/lY0ZdbvqnPW+qelbKSs/dyZv0/zfq251+NUy1Aqj36lnxdbNobi6MvNhpgoqL+mGh", - "0qIqS2O5dOR6oabKn8f5M9O1h2M8eEkh8zCBSjhT2YyqS7gtXkAOz7qRw7+BfxHf+A+Vg7mZnXvLzvvb", - "9ixaLeZP3mGa9kjOSbzqP0CP6Qbp3958DkdynFjvnoHEnROEG5mnu9WcmK6FHOsYLqu0+q0CoXQO16JD", - "Ad7n8GGOT0Z9vgAb+nM3oZThhp7n5htHRxcth5qNrdp2GB0dCcISG13N5tHIanoCjdrRffZdt+l2EfUf", - "m31jHSGgjMwmylReKQdiSS1AmtERuin2nGJBSJyfF8pCxrptDrHpFeHfAMREZ8ZCcqNjMW6wDfkLYx2a", - "NCSQ1yOzN7s9p5qQMC+Ax1ZxxS0Ubke75pdMqRdMqO/PdNl4z3uAcLdyBuhFe1a2uazhwLMg3YOUKGQH", - "lrkd0elwlPyoQ+fNh98NU6ssNtT5rpFwTN2g7aNtRursh+DHBn7irDESuFSCsA/QOUR3nLxxLRLZA8V8", - "CzLz6kr62iYKicJtXIWrGFS47YSWcJbHWLvKXqmrMGwmwiwodWJzJaH8/Qp8x4ToGGcyJWhGXALARO1m", - "wpiqNorjlY2yPHrE9EfYHtfVWYgRz7NDUO3zdZ76hO7VENGaMnf+1F1f8HRrtvjdEZ1NBrp7A6+DSU+Y", - "XHd1aFWWx+u6lO/LLtwKtWYhnY/l7zUv+6h83TkjRJLaTiwovYK+wWNHQjLdxhyhOEZojqyNepvL7fFV", - "PznIL/xka36nNZ4Tj7mMjJDWkL/hoaClC+YYY8eax6GEyn6at8g8GxdQ0eiTyAI9EYeXDWK7n7hD840H", - "93Si5eG9rMD53yHwcL9Mzf3P+DajYiOdt+m7fYgs3utH5ZSOCN8guac8CtYeTOLIYm4kXgXXqZN1ojbl", - "2Vk5lIVhZFfDio90KFbSZwvR4sRQxGiK2N7UlFSQSUAL0k5Q7BRqeVGrOfYXGHv1MSJZqhIKpUHkVG4f", - "hjAuDefUJuAYtb9dBzyZgFvlhZMzqpncYlY0Qlus896M92jo3mvaRdvG7hcCH23jbtuY3NbQZ4QPiuHB", - "WnjcqVUcFO7JG/rv/ohlsmvrUfSNtRJTKxcHfTaCEpT+lJg7wAD4HfowgSjSUZySgxvB7+ZTSF43ihHu", - "KEOxiejdh89RnKBUYMN0h1W7OYl2Tvny+01bttO2GHg/gB81Qciojq/4YKKPL8mD0JF9A4TyYeuw7kcG", - "Vri5wevUXB8rEpbdqvElje6SolD6MrakS4Ol58aEUZsyZNhLMYMVaqbKJyCOeJD7Zkcuk0tozdNujPal", - "9KmQJTg6OhI/89caQUgae1l74A3jlzRc8L+E81ZSAH0OPGqgAGm1mK7HWrrLXl4haf1SzfVTfWd1JbCU", - "qmhFC/gv++oP+FvvVnLwqMNNDWIrnnb7IIfxljndCOXkSr5718hH99sfzhguRMIdOB4OgQTJJZtGQy02", - "HZcXqaIgiRD7nSl9OdZcS01kjdQ8UzMPoBv0PBgOFpRrRaf8ArxdH1/MPNguzkFV4sJgNpptrXRuVoIr", - "Uc0V2FHXjIa6fxbts6tCP3AtLbohziIzs2oN/zrJCqmW/Qz+qtHrmPLKvGCymHzvfflMF+uJyIy5VEC+", - "pjaCf015bjTSW3li9O+uS8VFrfGgy9hmLkz1c66CnGLCBNKQSGC0amrNygX5YIqck2qIGOs8GCpCYxiL", - "OLmBtYTcu4ZPyvVlyo91T7nsEzyVO2Zzuo4umd1iavrWu/F0h5bGLUAulA6j0Oub3IDqqYAVr/EW+rtF", - "ji+pBUubGOl2DI/Zi2p5J30azqXpI03lImk8cPGBId5j2IobCTwBMpGqoAuSA4dvwD+PNRQOKEjR2e5c", - "zfWz6jCkLxy3qTgqj45UfBPdAkmTpOKYxEcbB/edDlMo9p1QihB0AksXLdc8FDNPzL+V/zoJBo90l5R2", - "74Cy6biDPENQyCoxDoVcwhYeiULU5FIVTsyk82HMjciQ5cTnp2cJvgrvoJgTo6ycEzDWUTHnTYhaaedR", - "WJgZtbBA6Rea6wddw0EIC1SxWhRj/eknZ49PH47E5GQJk9DlglxcypB44MRSWWvsN3yWwbJYUloiPOjk", - "+Z8X5ql7teizgU/voFFjl2zdTGAiLLC7m2IIIvQ+fusXZJLuFTnBcKWnbEue4bZFuv+J2zYsPb3ewyHR", - "qJ8TbYY7vKVs2ukpbMNc0VFDTcbGIWsS5R0UsyZbbsb5X7NqpuEO7C5UthicD05kqQZvX7/9fwEAAP//", + "7L3rchtHki/+KhXwRsjigiBFX8am4x//4MiesfaMLYUkjyOOobModCeAMhtVPVXVBLEKRexD7DucR9jv", + "+yj7JCcys6q6G+gGQPFiy6tPtgiguy55z19mvh1kZlkaDdq7wfnbQSmtXIIHS/96apZL0P5Zjv9QenA+", + "KKVfDIYDLZcwOMffhs+HAwv/qJSFfHDubQXDgcsWsJT4Q78u8cvOW6Xng3fvhoNXRTXveaTDj27ytHf4", + "ZVca7YDW/GeZv4R/VOA8/isz2oOm/5VlWahMemX0SWnNtIDlP//qjMbP6sf/k4XZ4HzwyUl9Lif8qYu/", + "4pfm4DKrSnzc4HzwegHC8muFcmIpi5mxS8hHg3fDwV+Mnao8B/3QK8pkUYDFBV1qs9JC6lxo44UsCrOC", + "XHgjciP8Qjla5w/mCi5wTZDvWOnNVojPfF75zCyha5U/L8CC8LhWpiVao/JOZNKB0GYlnJc6T+t7CbPK", + "7VzfvZzk0lyByA04Oj+4Vs6LmTVLsdraAK3XDYWxQhExyMKCzNdj7cD7AvLRWONufjT+L6bSD7qVH41w", + "VbYQFpypbAZ0rD9pWfkFaI+vffijDURaWnCgPSCFisrJaQEis5DjumQxIrkRnogvvMgyU/EKS2tKsF4x", + "/2cWcBcX9BEyofSD80EuPRx7tYTBcFOC4Jpk5tVV/bP2Ii+muDCxWqiCb1ryu8XK2Es3Ehc6/UU50XjY", + "UGi4AjvWORTgIT8Xq4X0SBUWrhQgBzov104gfdCGkfjxDb+aympZiEuA0gktl0rPx1p58enFty/F6emT", + "rx4jFQ0P3CAspSpa58F/6fiqyjuE7HCg3EW+VHr7cH6QWs7BxRNwQ8EX4ERpza+Q4V9wVxZktgAn8EDW", + "wmgQZoZbXY71p5PSmrzK/GiZT8R//d+vRmdhd2EZU2MKkMQ0rCW6dEqtMX7BTYSvDtNO4w6GDQp5k15h", + "prhWfMPFtXIdNCAs6BzwbSJXS9BOGU1XFbYpcsgKacGNhPk3WahcuIXCuzNiWqnCHystCuX8+VhLLeS1", + "cixFnJhCJisHQopMlr6yIFCMKKMBBSEfRJvEew5hOCiNU7zgt51ylihP5k4oLaS4klZJ7R85UcgpFCPx", + "N7MCi3IMnJgp6/yovgOlPczBbh11OOX05q4jfcr7ekoEsr22782KdBCJ/EcuHoNjSSqkp3OGnF6AeyiN", + "0t4J6UfiIh3aSvnFWGsjrsDmKvNiDV4wTQqJz1JFgQrv1yqfQ9epyiyDMsi/zU3jnpPe2f7Qm3/Bp/Z9", + "6GXR9dHGSfL3hvU66pfWb9hxvC9RAm+e7Y8SLxPZLZ7TdE3q1XkoiTEDFYj//vf/YOGW5xacQ/oIB6nc", + "WK+s8h60kH4ocuW80llQgEEg/emRE0cWZmBBZ3AkPqVnlaU1V5CL6dqDe9x16riOZ90yJ6ys89ONswtP", + "af5mx0n9nTdGl14Uz2eD8192a7DGCb8bvt3agfQV/R/oask3ecwCvnWFjXtFa6K5wt5d0ZO3d/KG9uJg", + "W/tJmy3UFdPphgTTIn7ItlUB8gocmy7Sy8LMKxDTym/opFGnHI48uk/3t1mffuhhbuyab1VXRYEviWb+", + "Fg28lzZvbPuAV6iOw/oraLD4YmQXPCEH9gosWns6nJgDsZIuaLt8JF550uAzY1HFjbpWVkjnv8uThO7c", + "zt7lBn3TwzNIMrD/Uhy8oi+ieFK+6NYmVZnf7Oy7tHC93PiquMhhTazNe26+t5uH4+I7lJwRC1PkTNRT", + "WRQj8dQsy2r7Ikl0xYtEncPGu/tGaDPWoHPSMYJ51qHSkU6g4rbzCr/IoiwyvDao35239FkQ2DtlwJuO", + "m8Wd/UR777Bqb8Y2N+WBRARLpf8Geu4Xg/Mn++6Xf9R9R7zarn24/UKDrrcWGT0mKXLTBZrayq/fn536", + "bSlpU+hjP1M2DK89ar5pm6afDcPBdB4mk2aHrK/8wtgeQTA1+bqLQ4I1FRwQK1bWeBiiQegXoCz6NLkb", + "iWdeuMpeqaghlHMVCLrxTtH2PnJauUza/CVIZ7odi1x6Y9csdMO30alCq+USSh9lLZl+ZNTwiYxu5Ne4", + "aq+8fIZfYuXPP+gwYp9rENasRAlWSO/R2cn51IYCXR9BpqsTZqVFDoW6Agv5sdT5MRmkubCm0vlYf/rJ", + "k8++ejwSr+uIwiP+FUlNgT4IXkoUYC7YssrD0h20kddWZpeR7/k8pLVyjf/GtSyh00r/jhy3+IUhxUF+", + "rRyTE/JisCAtAO9FeIsOkEJvgylCGT06dKn/El7UtcrDlBwfXtJzhxiatKq0vG0p2VpHF18nO5TYr9Z0", + "jTcMa75tq7367HdIgV7N1x2FGolJVDkT4ptJ4qOJkOEnRhdr4cEulZbFWNOa3TdCCtJbshAWfGW1Q+9p", + "kvTaRAQHWMO1jyS9butFb449UhsyJP73EM2YBEOPlgwG1EXDW9tUMbVpuu2NJQW0/VHwMHuIxEJmbK70", + "3B0g5OtHxTc2TObWs7ru+q9W5d2qs2dtrZX3BK+gHa5YSCe8vASNjLxQeo4qYA2+U3i2934Q/+IeXsaf", + "dTEx0oDriVUEJZUVCtceIis5BwliHACNb4qskCgciW+VKwu5bkTdsmj6Gd0KJzWtZShvtqVXHsqu3dBR", + "3kT7BYnQIWhR8sdPY1SU9XO9e1LYFLURxubsbhy8h7/zw/dKtEBwjbXGEzuIgoPrt03IC+kW3dsOYecU", + "gyD9xsEDklbOG6SDSuOWBdGJDFSSrtuJKaBhIKqyMBKXyLpqxd9Gtxbtf/AUjkTToYc4uvxCjlfTvkbi", + "L/gMUupqKecgpB/ryUkMe568dUU1f3cS2f7kbfi/Z/m7SYq24FkITUEabRJzSrQEMqlx3VMIZo1ykIvt", + "WOmTxz3rpyDDC3UNRU/MbSn1WpT0BZGr2QwtEnSTloYUg2SBMVPXkKNZc5wtpNZQCG8KsFJnaHkwi0Mu", + "nBEyrT+TmuNsdDpoEzj0y4SVfgGWnz2v8IYpwDnWTSmF54CLoqtCwbSQZQka8nOhcs5SFJFC8NiAUgYa", + "hnxsdBdOmFncE0qQGLF1QuZL5en7fGxLpdUStdXpsEMlUPhK4173MdaL+pvBQqlcv55WKcoZFDURBNt2", + "FPHEvQuUHbWnysQ/1jNJQXUScsKBxyuTMYtCGT7+hHklJgzOhiFSd0YEMyHqmJyLqHeHKffAxIz6wSmd", + "gcBrRwN1CmvDXxnrcNGPkGqVg/qK6Tb/gv/QdL3kNJsrsIVcxzVLNmXPhRSTmAiZhIjKwrjEYW6sKe3G", + "8ajGVyvtVUGkldwYh18xSF0rFU3iW4fjbhSCVO3w45DF3LA/iMdSstaRvXLyIMnEwgjv70rlYJCGtmVR", + "LbVP3qb/R3nUo/XJ/OuWgkS2yJ3hW4/wMpbSXg6FZGaub6Nb9e7mEEiuBtqZcC0zX6xbCiEwTiDws88e", + "j8SF0LAa62giTPFlKCFWUYfoXEj6d9OOTSfRTzYdMfnfhlzI/thp8m67paC9XZNjGmP9HnUhcnidcyKT", + "S3Fe4EbWRFTzHUZRj899UPrqfaMo4SD6zi/aPltH2BtkmkLRnQ4MKds6L8gJTfTApkpLNtMo2y6dmOTS", + "Xv7Xfy7NVBXQzW5XsgiRBZkzAcviRWuNfQ5p3GLXGfEG0tO7zuWZRuu1Aaw4LCmS0vpbGZGlck7p+dMa", + "RLAtP2oF7k0w1aLhVlaOHBI5l6odMNjjkQ8H2viLFyxLd73SzOokI/ECGpdobL348a8oRRyU0kqPxg8U", + "OalgVJmtbZH/W1a2NA7OawVdB6CjoBoKZ8Y6WaOU9Oa9iZWp8PHqOjphFPdZi4W8AjyXKZLXcVhqvhHo", + "2R+e6MgapVBWV37IlPIfFb4yZPFwCVKH2J8zS2CwCxQORuJVxUihGFgPzhrZYKznyYaZyuzyPKbE+Wy0", + "ERwNsA2AR5Tjp591pgmZPXdGpxtR7PbOXi3MSnOYwptmkCSl6uN6Z2i4AFqleGkBvoOOAzuUjW06qVJm", + "Gs20Hhu8sm3YRWXVIWmTTi5txe565FcXKG0W4ocqJyOT/KBJjNeEqFDUtRPyRboDqPj+Z/2h+Je7rAU2", + "DaKELKQH57dMCA4oKTQkZkOhZkLq9UhcjHX83JUgLx3HqxiNo3wyAz45e3L2+DzENJDqCIEltVsBmc1k", + "JSO58caH+NuUAlUusHr0y9oAnAjD6DOResOl21sMOqKy5JhQoDQEiBnlE5JSxPGE+FlJJ+bqCjQeTzR2", + "zoaC9jsS3y1Lv46OEwdyQujv8LBwvLpdodb27mqvAaXmFTIRZUvx+Op/Kc4dXJlMTqtC2nVL8H76yZM/", + "nT4eNS2uQ2OEbw5n/hRMmhpzGdAvBmVbDNJ/05QIj5zwKCfw0PHPuZWz7pDYe3J2zUfxbLt4PQW/t3M+", + "me9N+cgbhJ+YIXdlXYyuI8CUDwqikM8FrjiCaOhIR4N9xnBvGKVt6hdS5xSwqykdzfrgUGixUI4WxxqZ", + "7QX2XI3GxR4XcAUFoTVdD8te1eCP9qJIchyj5CC3tfYt0T/QTgUrT+rgM6Ovfy4qHeidPqvRSOTIxo+M", + "Tr597c4zGLShbT/95MmXf2oxRDcUKK1zP4IkbnaYCIfIpIvmfpBazQJquidne5Awic8hfMo7CrA84989", + "uUkQ+DnbIt4EHX0u8o0Ar2jGd8UFsbAit2YxFJJiw2iKVMsp2KT56IcNPb4EqR2aMOPBaqGyhbgCS9A+", + "DrOFoPN40ElMHfFSt/Ns94ZES+k9WNz//3ELefbFl+e/nB5/LY9nb95++fm7fxp040HeK0R1VXtCu36U", + "gsWblBX+HhzX3bvuQir1GSwqj1bZPIFw+mE3+7MV22+YFWYVoiQNp3gknpfsdA0bUTQ8EWkDgu04RjNu", + "xAg7syA3y0DER3ZnIfoSkrtp8oAA1HtQ5cNR1/6IyA3FVm84I8YtboKRCSGKnXGJHwBFlFuoskvfE4S1", + "T+Pn+c0wFwkI3pkknBkKzoK9UllCuQ8FC0ZU99rUqRnDkO5RE4Tejyp3L8C6HmByyAkoJ5Z0Eqx+S/oB", + "2fAojOdWLkfi26YW5XVdKtT+s7GOGPwaAbZMBxtVRkwldoDObwo5Hw6smi/8fuOav0VA4EvQu3IxtTcc", + "fJKwb95SsoP4mvh88HbMWIc0IfvW+JqRODr632BNssFlUYFYGesXwgEoPT86OhcynrhfSJ8yTs1qkHj7", + "dCUhACIuYd1K5aCt5BUda7wE1Lfpz83yCHxBoRxKdQomSKpGaue1xrqjCKAvElhzSAoIJmpLd1SzSicD", + "4rPz13hud1JPsi+WuO27xFe3CePoiEMX7K8kncRxsaMj8RwdYeUdZxEpeGss2kfOJGcQD5lulpxIdHrJ", + "wuX7KYzzTC+C3O6ykFl06lCLEkjpENOnFWrk3eyrs2gWhnWanK9uDGXNNjAyh8ODNi255ofDxmq6NvIj", + "rHqLkXrk7U9a/aMKjktdQObUXHOZiCYXxFGI6jVXbnB+WDq3MjY/VOg2anZmsir84HwmCwe7pNx7KDde", + "QM/RdBt/bTjrnrDeBpj1LsGrLTx67w76wKwHnVkbSbqnZCfc0ulhZTd9C+4DjEZM6J71HoyU24FbUV5Q", + "4SDF90fiuQaRwwwyL1wptUbJNDOVbfzAiaMjo+HoiJAcFA0gXGf7a9kCssvaBaQPI3Z7X3x8hyfaV04S", + "YHyNbfec+S4b7mZ2wsZSwo97XvuCE7wd3hXldF6YQmXrFmkNCMmBTFmHGfCIKEiQPnuzAym9j3pCbXfD", + "afhFHv/b6fHXb8J/j9+8fTL88qzLcdi8By4G30Xsr9ha7ZXAB676vWy5v3UnB1PIkWroav06M3bIlXeL", + "ain1IxfA1oxCuanUbayg52h6DJpkhtzkpUXvi3pJ8O4Mp21ibh73hMl3wsFKcIK/HjIEZFdLRxo0RZW/", + "EZNI6pOIFgy/bmMAbsYZ28IdTNkVlK7RV/QF9qSSyY/Hia5OqiNOxbUxT4kistJUUewXaIxzAiGYfHvr", + "adsAJ/QwdiLEwlfCW1RnVWjN9wcYik223rjdNiQ6nF9jmT0U2IiA9QKjY2RWuhTcG4k/rz2EII8idxNN", + "ag4KOrMEPlDUQ0qPtSEwxZWyRjMG3nHzBDaeAxCuttMJ4Rbt5w1NbM3Kge2u3ODP/s6ByG68b72IHkVt", + "XA+I2Zmi6i4RRv0NM9TR6CjU3xw29kZHFw676ZG4AM3z61GnUN+6s0a2cENZ9iQmfr6n1ENMzKXMQzPd", + "UGcUmomHiFOIGYrRXj0WNvWm8yBQIPU7QsG22RU1CF8JWQVH4Wtyd3sY9cZ+VcgiuF4kmK8IU8G1/c3T", + "Jcm7kG4o5MyHyLuTV4cHUTcqdt/tNdyCxxaPrbH4/tN/Ja+gM/SWKgx6aci1q6kKYy4ZwEm2a5TI3ggn", + "1zfddKg42gxD9pNEnWl3nGhGJ1J5r/Sc4Hmcq06pNU6+HrSihlfRsaJK95/V03hOnBGrU2ftg0MV4Jqh", + "iaVyWaFiFn+sHWRG53TAXBxVV6cLKbyZzwsIwND6wQIYGvDkiy8ff8PI1XZif2r8opktvIPrqXSjVcDO", + "s6DkchJAPcdxHuryzErHux3rQFwBmZPKzWRog5BbudKxKq553Xezx055nuznDeOM8fYT4SA2/2CWQOXB", + "yGIX2eQbMeFw5IQ77dTfHgqjGX4TEa1s90yhMMReI/F6ZULexjCAvR8pH407Xhpa3/TWTtNun3txd0bu", + "zTub7I6vhZU/XFRzEyHJfo826ENTRQAuO7Jr46NcIktwqLi3bP4nd0BvnhTzlEw8jZ5CyrcxDFAUDE0v", + "wUpv7FhznJtj9KHYRBUF+mvUnIUidfgNCqWaw7vv7IqU7rnBRhn0DvUv0edximqEyELnQlEqkmEdoE0O", + "QyEza6jiwomjo9XCFDDW+EzQOcrjoyOSvKmsE7+XK0vlHAtV5BYoyz61UqMXoIW3ppoWEI2xKYz1lXKK", + "miBEkIM1xt+814o2/lmzmv59GrKwZj8ASbz5ssaP92CP6ptqoIoPxu5usksDNozKmzoEMWi3gSJ+Ox74", + "BSxhPDgfD3JpL8eD4XiAiy2N9fRXhhiPB+8mo7F+3dGgKJUhy2vgupPNhkWFcp7bWl2H9FGssUod0FxV", + "lsWaotRqif/HoP9zIZEUIZo/BHaFmDukfwQZfC2XZYFH88vbQWEyiW7yYIbSmL5FPGUv0X4Lmxuch60R", + "4Dj9BPTgXdeVRHTyluTLwXfnQLWA67KQ4QpcCZmaqYwho8oJkzFoL+uBR2rnowvaBIUdJ3zPTaog8N6+", + "f/36RTSuM5NDtzXfA3u7EG5hrB9yqOk4odVdtVxKux7ig0O7EiEDvhAf3rk3/sP2K356+Sz4fbN1hC6G", + "c08Pa1z0YOF96c5PTpjeRjlcRRC5O0ExdqyNP55RT7w3w5tB6+jTdoeRzioKPHPIKqv8+hWaOqGFz4au", + "3DrLdkr8kWM9MRRLyhzWCTqX8nVj/ekvofTq6zefxp3PlV9U01FmlicLadXUhLM4mRZmepKjq2nKk9xk", + "7kTm9gR/fOxX5vhS6dwdm9lxWMGx0XDswOOfOHQ5WuaPH1P11TMf7CISy7FyxugkCc5rDcyQ0yghLkTI", + "AJeVW4y1rwsS/1yXh0nvYVl6jnmQdGzlgtVcm5Q0JGuSEk0gLZla4TrwPIj+2V3tqwY1lwpIonBizJSg", + "3UhMvve+fK6L9SRkOgX/MvY288HUD4EU4cgLUL7RgqGR1iZ7oABP5sBkXJ2efpap5Zz+ByZcKIoOSCz7", + "ZBtaU/dTXF/dupSv8l/jnmqzuVT/C9bc/lDpmemisIWhlHhwWwiriqwJOj/25hjw7MF5AVfIb0EEMeeH", + "17Kny4c5OB09GZ1SGKgELUs1OB98Rn8aUrdVovmTGFTEf8yhw7bqjUCG/L03weR2ci2cCbUL5IRwrS0b", + "fLrteFFgiVDXhq5voeqqCK6HIFR2A4D5yPGTqCUPNUKUPhVM0HWwFRcq4Ad/U85fxM1tdIc9Oz29UWvT", + "g5yl6B1se0qdDS/rVonTdcDbvxsOPj990veetIOTzVad9LvP9v+u7kDblICkfNuy75c3qF0TW/7yBpVr", + "UBoJ1R7hHBGvn3TfcODl3DVQGW7whnOc3VVAKbGNdMpsXpXnAWJLr9jskaipgFcr58lsF0uZg1C+p4mk", + "+LHOmdf4iK1Wd8qJBfUghDUtYqyVDsHdtMQUyJkZOwcfW+nGv6Kj4X87mf+a+nCs0rHFbptxhRwYcWYJ", + "5HLNbUrSNkBSXbz0lDyUSOCsc8H5P4dM8p20CG4AKN619bq3Fbzb4uAnd/bm1mt7GTWw5+l+Nmv0nn5A", + "jsZffP2wXXtl3aMhdDfmclLdOLNbCZof5CU05UAT89YtZt4Na5V28jahwt6x4Cmgq4zl261evSK06h0J", + "FnUzmYWGvSEF0uzIS3ViMtbERwWXitxmxoKaa4LLhcKzuB02vksLmXJQoPZMqn2sTeWL1HgrwCCTYPnq", + "NoLlq2Opj6kS4Fi5Y9rwsdJX5Pg2DMgclqXx5M6lbsZ16QNvgBoZ17ff6Hocw3pjHaUPlV1TIVZIUsjo", + "BraFTX0dTYHT4v3Pe0o7w7IozGQEWr9o+OHpKz16aGb8fP8vUvPvh+feZ75lZ1HLh6DAk9veUrK35eVX", + "3pQtXrZmSXfDXYd6rYbGKIJfOmcFNKGfhw8MeENGcLboLoG1udISbZz6BBRao1nN6qhdHZdHmYIKecTK", + "Gj0v1qlQoQbCrqQLRgoHTkqwS6m5g+RYHx29jt3T2nZN3fwlh6VB9jw6Ehd666ZQnY91/C1XKKlNPli2", + "hemQ9X9L9wu0pajjJfp95Ns2LRwKJlBYSuTSy6l01KzHGQIvlFJZLqTu4upXEK3xl4ac8/e1ITYQTjW6", + "cRPNuBlyDd/siAYcYG30SBy6+ug+4iVLdwk5yvyP4ua3FTcvrEGOQYpm3mmQfr/psABZ+EXDF24T8V/B", + "f8/fuKVDua93tLns7ixSo0G2aZFK3dqumLCVdpvxNw4LHNp2un5pN+N0IAFCmIywy/YK3XO+y3Q3L6FE", + "QbJKRR+tH5FR5A336G1cVfhKuClOk3VGLDY7x2mAnKJgl9qs6rcaitOlCAPXmMQg08zYZZcY+3lhLpbP", + "7vb6Ezz8f94YhoNpSs015HgxbAnfQrpuS5UeCfLzwsRGBDzRpxGzapIlU0wgy3jOvcT5mlptwmbqmvEO", + "Om9eid4Qk8GqqK0SsGOtQo6mZTdwK7m6E5s3YkmkQZHPZkEU90IqzYo7W0jfsDngGmxG/eaC1RK+2A0t", + "5EZ81KWLoQkLKOiXjXS/ogLq8wQKcsMmKC/8K+AbuCPcZtC9GdRuBLM5AAmxkYvRzcj7vTtLnbHHF5ES", + "HiL2GOG3B8Yea2nwwcUeX7fhH9RCj3reLeUaKaDBmWmeQSv82BXZisd3b5GtdD8PG9lqvbaXDJBJTQn6", + "Y4SrK8LlimpOsHF5SUd0K+p9XgJ6jFNjLhuEmpRGW4eEfn27rNEm4d5Cxtyehn7f7s6t7uwlyLzWpd3X", + "dkic4sYzDd90EMOJvAbXSxGU87qGB8p3XSt3cLLrGtwfnUQawETC2caGgLj3xmiF1MeaWjY/JDkNB2Xl", + "u5pDckvN0ENaIAGRM+CArF1pITbaQHOBy5koDedjkc1///t/jHUXfij6FY0qATaeaBpVSlrRm1McZWsm", + "WUjpz6xZnouykBke6yRCgiYBiT/WEwINTQT3qQ/NFnNwl96UE1yrEZPwz//6T/x0MhLfXdMcq3ldXCjx", + "URbozQXVUVMDXBnWEroCbbR7ZIxWsxhCj3WqhiBTtgGkiorXLNEej93FHO0NEkjeOKrUxvvOu6zM53hq", + "id3vIprGJLkdEcV1k08xFEvjPIdtZyqT2tcj4hoxhl9qvFZEcg0jWOvNm+H7Dlfg5b1fAO93IABrGaAp", + "3EAd23//JtfDidFvWTTWwoizZdfAc8jaWYLdRhM5dO7k7UK6BVlQC5AdWHxuekouRPAmQlWPA+7YR707", + "z+uW9DHNGXzbUDXIbT0h56YMm7CiJcH4pZhVRSGulKtkXUkkZzNjecIaCwluVEiZzqak8K05vrE3qme4", + "Teg5qryDYkaD+txCcscNAvsmf+m///0/Gt3T6476tJ02Xu3081t4zaefH4cTO47tVvPYwPQYFyvnQM4z", + "a45YQEftu3k4Kt9LO3LXTEXIqam8WC0M9SEN2VbVmXn4HmT+58JMe4yirlKmZgdX9DB5Lx8Ms/ZtKeF2", + "qQ3/xtZuaQBtR3TplYlhtk6zwcrEq/dl9gwPGDExJFh16Go1ETMTJmNP1+LLzwX+v+UaNrgW2UJameES", + "20ovdZvC/yVzAFfBj/x69tWX+elXT7766vPsT/mXX3wtz2Yg5Wn2xRcyP33yhfxsOvt89mR6Nj2dfnV2", + "luVPvsi/zJ58MT2dnZ7K068GDBrcOozQLbz/MG7cs6vXSmzCAfBqkQnjKW7CAEicbJT1sKAikTPWKHMi", + "bFTNOKiZG86MclEuS36tTaUzFGQBZoICLL411LQRDpAQYXFaiPQ1pfMmpmRIcQJqUUdZZxWVKIZhIJSw", + "pRRuQmGk1NRKEnhDG68yltQ/x2Z0DfhYa//4WehoTfMPgmCfnJ1+PqHnogBjhKrJ12IK3CBW5qEZ0pCH", + "acROw0Ef4VN5jSy9ub5D8mT3sW4sBnfvTZE3NESezk7FUbiZsRYy/424Ahvw23RMY70EqdPwDT6vuB1N", + "qqyh5rh3qgvN4+WVUbkbiaeyKMZ68v13F99OgttAYdpLVQYp3z6yLuH9ovJJdh9i4ZrMgz923oJctk26", + "BCSfUmq/g/QPDc31i5MELiRVcbZbGm9QywelZ87OHno+PJP7IULiDoAqtr5SZjAaK8iP79BbXfZn18Cf", + "3qRQiB9YU80XzYxIhKO1PmqrrpG4aIwPotHX0Wbss/aCLKByBYKXE3PXlqb01AeD61VczAv+VnbhJvaW", + "WzkY7gHURq+Q9e1a88AJjzjW1qyG1JfXpen6lg39ZhKpp1iUYxSRFWiU4XJZEXjvvG72+8iFIw4tQ0n9", + "DRtz3Wkoia1SU1kXhpDEpTbrJ8wV2JVVFJhIQ4q2or+hPPfZUs5hf8iPJvqclNzi84ZysaNBKz7tDxzS", + "S3tstHMmI/YB7daOxyRhcvtIcmro3CmS/qIK3E7MQgeZQUWlw2S+uCx08eLGysnpDUOWqQKV3FH+NRs2", + "I3HRnEAe2tkQ03BJSV9G9WkYodh12P+ogGg3nnboAHGY6mm0uUjHvvHARoO4PTHW+487xZ7ah8Sd6IT/", + "wGyKdNEeXt9KAFMr7vsLp/dVtAiFpvYy9rJILay/acKSiCm4/ILAC3V5GinksXbV1KF1p30c+EjV611h", + "9J6Kjafc4e2ektpMhw+b0a7f2U3rMZc9FKbyTuVsI84qGlkYJ8aRVRANtI8h2I4EdTKULGTAHWOdUHkH", + "b/VqFjR83YbV22HBOLjP5PU+cvnjp61xl0Mho8Y1Fi3ph5KRfTaMgztD6z8jwiRaRRplmyMO8iqlDSlI", + "SSmqNv39VOb3KyPx0fyS+8hRvS/dVyXP5WnQ/0fhRyzzlHy2FJsKPl7INTArvZcEPAnsxxHZ3zeXdZo0", + "z8haKV2IUobKbzJb+M+FoYFkIW8T6sFpYGvqOREaUblUQ++GddM6/H6onGv3uqZCPBVHd7f5N7gR3Trk", + "876GbWwjRIH4sUyj+4ioliUEKBtndTuXmgqQ+PnIUXEK2g7L/QDeas746EVcz63Kz2mqKWXhKbk8DMCN", + "APugvAEVmtclXmk6cyPVEJorQUnfSmNjLtJcBKNhqyNOaqrEsbbwq7EOhe0yfkBzV8d6rL/lTs0ulozW", + "wCICYKBZ1nQLLupONfj6TGqaGO4uaVS0sTWIhU6fsLLsa0gtTJGDrSfqx5F6yqWkBI9cDohsekKaHcve", + "yBRAx9OqqzyJ5aUWQNPp8A54qIDUYw3WGnsuGGoesOGp/RKDbAqYK6+WqNBJrfdGwRzEnnqHRQfCVu/Z", + "l983MreP/+ruHn98vFxKtMkkmWliPM92+fDM1EPkVaNxZ6e8ehVGwiXlqHRWVDnkI/FjCIIrFyvUYwVH", + "rlwmLfeuhViFERuxRdWbSlaDsYxUY3gq2njwTFhK4odhZzTMHcTSaL9wQs7NcKxXCyMs0Jx0ofz/Px6I", + "ZeW8kMUKNT2Nq5U6cH+a5Pplb4XE07o96wMEzfoal/YowThN4A/OhKsox0mC8zzbHnM33tYfgQlZpbkP", + "1iB/rrmDssiktardU5WHyJDBklqcn7fqrDYaFNctGrhDccP+iSJoZZX3PHyWS7ScH4lXZRFeE8EVYx27", + "NoWWz8XsOMjzKfgVkphfGYbbpcQa1XlasTLUsJhQXZK6bVAD9pE4OvrRCNA5NX4KrYNdfAUZBty0kubk", + "UhY9DIRM/Sdbboavuwa7YZr5TFGufKyj1DobhsbeT8465dcreQWN/pB3Hzto9KR+4NhBuxd5h3xM9bWp", + "zfiS5xAIbz4GFFpQAmSGVahIjHXG8dRyyFQOzZhqlEp9fg+loNSe+o+n9dceJhkV5iIdqFi9BRgKHoaU", + "0Laptez/AJM3ubt0FO1yxXhx95+y6s4Whau8x4xRJJaHzho139sV8uDP/3DS68EDSBcCXQ5CaIfoUbRQ", + "KGIQao1vxUkXOTo82uQQgyO7GGm3JEWLMCb2d/YIoyKpFMowGkbiWwpN0rzvmaIt51ai50NOEbVHVgVo", + "X6xptkDqETPWXAZPfcMY4xnWQAATfqKrpsfpz9SZIvrHATcba4qOjmJ47uiIvxNbdCY84TkaUwz35AZm", + "LowbVNyzZqYKyIdjzQ266lxRfCPaYdRDAN1KQT3DR+IHw3MkGdepUiiPVjzWoR14bJG+2eELD7klbg6L", + "3YbzUE7M8Qo+st6uI6JwI5Lr7cH9RL+J+pH1HlRv9flTDUjOXaQSXwI+eSgsHLN9EjLgoTpRh9BqUcS5", + "qVLPKzkHMVPXjT5bmXHejTVLEvHPyHOkXul/w5NrTDVHw5l9XDVFUSY+/eTJn75m5OEkjo2cxDEGPKiE", + "vo8XcS4kem/43nAej5xQOQ1MoQgbXtlY8/ZTWRF15BcXLJhIMjM4nnuEsXBNDeJ1Bs4bS0iuIH2oYIKC", + "tFHcU2/ezBQFTfLv4vqYbr21kXHr8Zub/d72np/oPj4O7f9/otKM9uweQ9Wc7dkxheDeaxnvwjDi+0at", + "tkItoHP3UfjusXumIEOjS5YqgXTYFKiZLfLxFrvdXmoTSxrLr5Q7xXanlRSiJidvw/8FE2lDwHcdWP2V", + "k1co7HGhe773NL5jsENCUyUtB60trIy9DPGuMBmJRynVk47OhYdrluJhivVYx3zfME2LCfOTUJqFEUoj", + "8ZxGU1nJU2apDRN+6ZET1G99rF0J8jJW9IaWRY9SAbfOOZ3GofraYtuSid/lKobB70wgHjjftz3E9y7m", + "5R4yJvdg8bab09H6vKDJwvkf1mXbfwIvw1yaW3Zzz6kDeJxDtuSBKx1h/0OFxEkOhULj5sGkRS8ON4cr", + "4SSVtY0Hz8RMaeUWIcM1pDD4eDASsQPGmuuMFafrU7RcFs6MNUlRbkBtYUYdwdgxy6EEnYPO1mJaLUNP", + "NTxeo2M4Pk10m5nYZo0MdOVSD4w1JeWlu0zg+oW8IgGyLAsIafFGlJ/KnkM4Hm3Ic256xrIskzosK34l", + "zsnjGS+UAySrJ+b0w6xtXAM67Gvh8F2yCMLRwowm6H321eNzMaG/oUnKfeY8t7Qjk1eRmCWcMj5rCtTj", + "AtfO4rYRjq9zF/Vwkh5vEZ9612IybKJvfuJMpN1wIZV3YqLySRT5YROPXDgON+GBSWP9g9S5pOJ+Ewcc", + "bu6YRtNxoU/c895pqUF0fpSUv52kvHCXQQL8WuXzZRgPKxuUMlPXt5WdnMj/7UVnwAooJ5aRoFm0XUJJ", + "nfgpc5agBWyWcbHxzKqMsESKgl0exWN+TgdFw/9z5ZbKORQKQdbJxuuiOKB4WV1FgU8STl0HSEIXGCL4", + "5TUWIvIdQxEjHMLoscYlk+u9G6bwLT/qrmUPb/am09fDrz4aVR+AqHgFBKEI9CedyuGWgiGKnF1R6tdN", + "W8NCZijQbONIjjhzNxoxlZPFeZx/LSxw3yxvxjrNyN/GQirPzpSrR8t+8uTLPw3FJ0/+9OQxCwAvL+GY", + "mfZXozSbCElmpt43TkyoqyJ9dRKKCE1j4RHftFqAHqJxI5TmelWULlFQFCr40ekNK+k6I1CawAgPbklU", + "OoAg7teQCAZapz1x8DR0o4XLLIAeidfysp73EWmHCtoscPEa2rI0oDK2ayPKkPMkVs/OHn8TILDsRjco", + "gz3jMFmdJXecgsykGBApiRY/GkofivQLSO/Ej3jhB0LJHtDKuUj0FtlJ1rZO8IQZK+nE0ZE2/uhIyNgK", + "Jlehj3UOV2PN0SgGGAVr0MVmYDT6Q6+FxctwIZBKo73jeKF0TmgvoXkVxo5ZAP6V8FaVVItNKYiGVRON", + "mi5x9y/3IexYFHTN2xjuF4S/AzEYAhxdrfPDW6jPOlEGtS1qzosOEYWQ3LQguyP+NxW1501eCWjaQiKx", + "8I2nqGVDrI7EBd0EA/QLoEp65RtDl0NMcqyjUP4m7YpnaApJBXIKBS5RGfo2tFH8rdKuT+A2jdJAD81N", + "fzRQPwRflu6tdl7XlKHgULVQ/pbGaj3u+Df3Y8NoYhULfn56+bdk01Iwn6pFj45olrXipm91lf7R0Sh1", + "tmWsKI2CDW1xavzozIKr/0o2qyqK8DFFFanVF0fTVtKJrDAuicJ6WhznHKzILNC6ZcEPoxIlzXMNgsf6", + "WafH+hofcHuhvysN9gw38RJmH/n698jXlMMisMa1BxpFyETnTe2QHs7doVTqvpDyvZz7onKx3dWyJCDF", + "Umo1I9z5hbsU1FVOtLq8NnvMhU51K+pl5XsaYLLPEZ8roszi9qKU2m/0dQtJPB5tQiuL/a1jJyvHQ4t0", + "npDtsaRO1QD6gCLxVmonKUhG6KxQB0jYqmEo0o/tcuhZqSC2DI0AY+lfb++P7/gL9yQDfgin9tBIzrAr", + "1l7QX0NXn3ywL6fGXH6Edx4m36mIuxZHnSec2KaeF0SxHKH0eZNvQlXssIFgrHToVhkmFeXCr1RWQyRi", + "OzZbaScmKCsyPwl1bgFDHyeWjPXdeKq20o9cXY1IreIj1TSEZZKG3cIytKLrrex7AaYsGB5QWjO3cumi", + "QCBfR2yPO2LDoNXlrlCXMNZSr/F3lAb8DeZNB7hoGNsOihBU+NtNbAtJSSkKBY2ezZnUwgHUjIkS8yXI", + "LBQwUWdtyJ2YcG/WSagbbkzjRMHpgCqH8YnKcSomXAlFl/jjYaIc6vJKQ6IaedSenn+dBYs/hPt9iLqK", + "H9KFH1pZEUhkKEoms9CY/0MZ7PQzRX5lsK2V6xi4csPBsHczcCUc66HDpCmV77wpeTNcHRcCPak+b11P", + "kqbUVEys1UOlyWpZcoRp30hpqdc7RkqPdRJraaR0S55sccD2/GcLV4Zjwc0f8tzZsKyNds/Nac/BAusZ", + "9/wSnw0Ncj8QDr5uQG7pqFtU8wENNGMNFCZ8m9msc9bQfZP+8I5HK3e1EN8aU0gDiJvzCLnBtyyKODeG", + "5b6JE2pIpPXMqBR/xafFKlazBCLOoBtSK5Pwsno6OaMHWbO1WrDOJBV1nHNv8eay8ZmpJoKHy451eiPV", + "UDsTB22GgFtbKy6Zo6DhsVjlLqlVxxy4ppYLLDODWlYWM+LJH8Ncx6MjJpejow6b4VHzRJu+BwU2FUeZ", + "40CfemANlfWTM9I9YjFmAhm7BLjUDP2h5pl5W1FnoN9w9CJRwYY0uZdatqaCfv8J0muKPiWyDM6lcRBI", + "8mM9bWuoM0FFoqhs2HvkO2SN9l0+Iupy0z/teVPVs1JWeu5O3qb/v1Hf7vSrYaoVQL1Xz4qvm0Vzc2Hk", + "xU4TVFzUDwuVFlVZGsulI9cLNVX+PM6fma49HOPBS0qZc5olBidbH8YMwpPPYoH7WXcc8a/gX8b3/13l", + "YG5m9d6mD/9wgAaLOVmWn7/vT1cwXd5Jm2t63JB7FORCxvE03gXRzS2/nVyHjkmffnJ29uXjcyHF1JqV", + "w8vhQ8AbnPDijiaxR4rShdI84hcpycxEbla6MDx0Ae3Gn6KvzC+aoW6kFLw3YtJ34BPyi//InbnpIMmp", + "iyzyO+jN3RAZt3c7+mTRQTigV+vlErxVGSuUuboKXTo+TTx/3pZXDfQPZyoijIiTJhGywy5KrGPZgbFJ", + "suNuR6VnqVP3TVp6txKW9SMOHT6eMCscB2bH7ZuEvA49R3lo0B+5YdDrGNNJVPPIJWD5BsSjbmXxITDg", + "LoRIQwcH+MImE8UjSCn7CEzzC3AQRTd6NhSx4xjikP7RaB+4iUWhSe0baJSU/Ds7JQtBwyqFuqe4GUd/", + "CqM+KOyF/54kCNUEGTvsqBcy0ubd+waN7AdkkOEUjuc98BjdgIWHmCL5O5BdDWjRoaLro68R6yd5tFZk", + "79T7KkqEhN85uIVPWMBx8kMeOKu6nV9o+PqNMpzdPr+YroUc64gdqrT6R4VGbA7XoiMaUM8Ouv9wAAuH", + "GNxYgA3DSpp5peFG0IM7kR0dXbSyCxx5qgMpo6MjQYnVRovXeYw4NcOijUYa+4Jd3XGsixgM4BjYWMd8", + "WEYxJCrbWikHYkn90JpQEbopttFidWwcJhxqZMe6HRviOFTMhYesVIzsWkg5hdiZJATK+AvRSqx7uXlk", + "reboi1QgG4Yn8QxPbj8Chdsxu+IVU+oFE+r9xXE23nMP+ewtXUgv2rOyzWUNB57F1p60kUJ2YAnXofnC", + "UfKjDtUh4XfD1DeUXV++azZrso+d5FvK44cQ1A/8xBB6ErikRfZltw7RHSdvXYtE9uSlvgWZeXUlfR0Y", + "ClVT7SQTl3SqcNspdcQm1li7yl6pqzB5L+acUOrETpNC+YcV+I4J0XHSzZSgOf0Usm3Uey/M7GyntLyy", + "UZbH9AD9EbZnl3ZWpcbz7BBU+wK/z3xKddb5sjXBmP/QLfDwdGu2+M3TW5sMdPuozQFMesLkuqtdvbKi", + "kFMoXCp+4nj2CrVmIZ2PvYBqXvZR+bpzTpdJ6sG1IKwpfYNnsIXKgo2hinGm4hxZG/U29x7CV/3kIL/w", + "k61h5tZ4rsLimnpKOwcwq4eCli6YY4wda54NF9oc0fBp5tm4gIrmwEUW6IFfvGoQ28OAMJpvPLjBJS0P", + "72UFzv8GKIyHZWpuBsu3GRUb6bzNgOyHyOK9flROgR74Bsk9gUpZezCJI4u5kXgdXKdO1onalAeJ5lAW", + "htPcGlZ8pEOxkj5biBYnBj/UFLHXuympOwVlnZB2gmKn/MHLWs2xv8CJaB9DeaUqgXIQOfUeChOpl4YL", + "jFIWHbW/XYfkOmWxlRdOzqiBxBazohHaYp17M96jofugGNS2jd0vBD7axt22MbmtoekaHxTnSmvhcadW", + "cVC4J2/pv/vhW8muZUea+nLVayWmVi5OPW8gNAgLnpg7hAEoeYIfpiCKdATaYqRH8Lv5FJLXjWKE2+sR", + "UCN69+FzFCcoFdgw3WHVbo7ln1Px4H7Tlu20LQbej2aImiCUl8VXfDBQrFfkQejIviGE8mHrsO5HBla4", + "ucHr1FwfKxKW3arxFc0xlaJQ+jL25w0VEkMxNybMHZeh3FCKGaxQM1U+BeKIB0OC3NUYitUCQmwxPRE/", + "p/x4KJkYHR2Jn/lrDUQWzQCvPfCG8UsaLvhfwnkrCU04B567VIC0WkzXYy3dZS+vkLR+peb6mb6zfAks", + "pSpaqAX+y77cBn/r/VIbZx1uahBb8bTbBzmMt8zYa5STK/n+LbTPHrZZrjFclY07cDwpCwmS+1cYDbXY", + "dFxrrYqCJEJs/qr05VhzYxkia6TmmZp5AN2g58FwsCDgOZ3yS/B2fXwx82C7OAdViQtTapHKV0rnZiU4", + "MW+uwI66BlbVzURpn13tigLX0qIb4iwyM6vW8K+TrJBq2c/grxuDHwhk7wWTxeR778vnulhPRGbMpQLy", + "NbUR/GsC/eOJol2KjP7ddam4w0c86DL23A0jjp2rICeAHAVpSCRwtCqAa0g+mCJnhDERYw0Kpop8DmMR", + "JzdiLaEQoeGTcrG98mPd0zvkKZ7KHbM5XUeXzG4xNX3r/Xi6Q0vjFiAXSjOlN25yI1RP3TzwGm+hv1vk", + "+Ir60bWJkW7H8MzhqJZ30qdhYHEfaSoXSeORiw8M+R7DVtxI4AmQiVQFXZAcOHwD/nmsoXBASYrO2S9q", + "rp9Xh0X6wnGbiiGK6EjFN9EtkDRJKo5JfLRxcN/pMJJr3wmlDEFnYOmi5ZqHzi4T82/lv06CwSPdJdUg", + "OqDSAka0cQgKWSXmoZBL2MIjUYiaXKrCiZl0Psz8ExmynPj89EkKX4V3UM6Jo6wMkBzrqJjzZoi6iYlT", + "jloYBAxT0DWchLBAkImiGOtPP3ny1enjkZicLGESWn6Ri0tw0UdOLJW1xn7DZxksiyXVaMCjTp7/eWGe", + "udeLPhv49A66VnfJ1k00N8UCu1tLhyRC7+O3fkEm6V6REwxXesq25BluW6T7n7htw9LT6z0cko36OdFm", + "uMNbyqadnsJ2mCs6aqjJ2DhkTaK8g2LWZMvNPP8bVs006YrdhcoWg/PBiSzV4N2bd/8vAAD//w==", } // 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 new file mode 100644 index 0000000..c43c86a --- /dev/null +++ b/apps/server/internal/ports/http/recording.go @@ -0,0 +1,71 @@ +package http + +import ( + "context" + "errors" + "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" +) + +// JudgeRecording renders a verdict on the recording on screen (ADR 0023). +func (s *Server) JudgeRecording(ctx context.Context, request openapi.JudgeRecordingRequestObject) (openapi.JudgeRecordingResponseObject, error) { + if why, no := s.mayNot(ctx, request.Slug, access.WriteProject); no { + if why.Status == http.StatusUnauthorized { + return openapi.JudgeRecording401ApplicationProblemPlusJSONResponse{ + UnauthenticatedApplicationProblemPlusJSONResponse: openapi.UnauthenticatedApplicationProblemPlusJSONResponse(why), + }, nil + } + return openapi.JudgeRecording403ApplicationProblemPlusJSONResponse{ + ForbiddenApplicationProblemPlusJSONResponse: openapi.ForbiddenApplicationProblemPlusJSONResponse(why), + }, nil + } + remark := "" + if request.Body.Remark != nil { + remark = *request.Body.Remark + } + + state, err := s.evidence.JudgeRecording(ctx, request.Slug, request.RecordingId, actorFrom(ctx), request.Body.Accept, remark) + switch { + case errors.Is(err, review.ErrRemarkRequired): + return openapi.JudgeRecording400ApplicationProblemPlusJSONResponse{ + BadRequestApplicationProblemPlusJSONResponse: openapi.BadRequestApplicationProblemPlusJSONResponse( + problem("remark-required", "A refusal explains itself", http.StatusBadRequest, + "Refusing a recording needs the remark the dev will read (ADR 0020).")), + }, nil + case errors.Is(err, review.ErrMoveNotAllowed): + return openapi.JudgeRecording404ApplicationProblemPlusJSONResponse{ + NotFoundApplicationProblemPlusJSONResponse: notFound("recording"), + }, nil + case err != nil: + return nil, err + } + return openapi.JudgeRecording200JSONResponse{CaseState: openapi.CaseState(state)}, nil +} + +// UnjudgeRecording takes the recording's verdict back, symmetrically. +func (s *Server) UnjudgeRecording(ctx context.Context, request openapi.UnjudgeRecordingRequestObject) (openapi.UnjudgeRecordingResponseObject, error) { + if why, no := s.mayNot(ctx, request.Slug, access.WriteProject); no { + if why.Status == http.StatusUnauthorized { + return openapi.UnjudgeRecording401ApplicationProblemPlusJSONResponse{ + UnauthenticatedApplicationProblemPlusJSONResponse: openapi.UnauthenticatedApplicationProblemPlusJSONResponse(why), + }, nil + } + return openapi.UnjudgeRecording403ApplicationProblemPlusJSONResponse{ + ForbiddenApplicationProblemPlusJSONResponse: openapi.ForbiddenApplicationProblemPlusJSONResponse(why), + }, nil + } + + state, err := s.evidence.UnjudgeRecording(ctx, request.Slug, request.RecordingId, actorFrom(ctx)) + switch { + case errors.Is(err, review.ErrMoveNotAllowed): + return openapi.UnjudgeRecording404ApplicationProblemPlusJSONResponse{ + NotFoundApplicationProblemPlusJSONResponse: notFound("recording"), + }, nil + case err != nil: + return nil, err + } + return openapi.UnjudgeRecording200JSONResponse{CaseState: openapi.CaseState(state)}, nil +} diff --git a/apps/web/e2e/fixture.ts b/apps/web/e2e/fixture.ts index 25b2ea9..adb0b6b 100644 --- a/apps/web/e2e/fixture.ts +++ b/apps/web/e2e/fixture.ts @@ -140,6 +140,36 @@ export async function seed(page: Page): Promise { return { slug: PROJECT, caseId: kase.id } } +/** An edition identical to the seed, plus one flow video per variant. The + * video bytes carry the clock: a recording is never byte-stable (ADR 0013), + * and two pushes must bring two recordings. */ +export async function pushRecordings(page: Page, seeded: Seeded): Promise { + const still = await upload(await screen(page, 0)) + const videoLight = await upload(Buffer.from(`webm light ${seeded.caseId} ${Date.now()}`)) + const videoDark = await upload(Buffer.from(`webm dark ${seeded.caseId} ${Date.now()}`)) + await call( + `/projects/${seeded.slug}/editions`, + post({ + cases: [ + { + id: seeded.caseId, + steps: STEPS.map((name) => ({ + name, + captures: [ + { variant: LIGHT, hash: still, provenance: { environmentId: 'ci' } }, + { variant: DARK, hash: still, provenance: { environmentId: 'ci' } }, + ], + })), + recordings: [ + { variant: LIGHT, hash: videoLight }, + { variant: DARK, hash: videoDark }, + ], + }, + ], + }), + ) +} + /** A second edition where the call to action slid on the dark variant only. */ export async function moveTheDarkVariant(page: Page, seeded: Seeded): Promise { const still = await upload(await screen(page, 0)) diff --git a/apps/web/e2e/review.spec.ts b/apps/web/e2e/review.spec.ts index 33654c2..246941d 100644 --- a/apps/web/e2e/review.spec.ts +++ b/apps/web/e2e/review.spec.ts @@ -7,7 +7,13 @@ * written that way. */ import { expect, test, type Page } from '@playwright/test' -import { commentOnStep, moveTheDarkVariant, seed, acceptEverything } from './fixture' +import { + commentOnStep, + moveTheDarkVariant, + seed, + acceptEverything, + pushRecordings, +} from './fixture' import { emptyMailbox, linkSentTo } from './mailbox' /** The grid's own captures. The recap is another table, and its ticks are actions @@ -354,3 +360,38 @@ test('the case page finds its way back to its category (#190)', async ({ page }) page.locator(`a[href="/projects/${seeded.slug}/cases/${seeded.caseId}"]`), ).toBeVisible() }) + +test('the recording is judged in the carousel, and a new push resets it (#226)', async ({ + page, + request, +}) => { + const seeded = await seed(page) + await pushRecordings(page, seeded) + await acceptEverything(seeded) + + // Every capture accepted, yet the case waits: nobody judged the videos. + let detail = await request.get(`${API}/api/projects/${seeded.slug}/cases/${seeded.caseId}`) + expect(((await detail.json()) as { state: string }).state).toBe('to-review') + + await page.goto(`/projects/${seeded.slug}/cases/${seeded.caseId}`) + // Clicking the video opens the player — no download, an address. + await page.locator('[aria-label^="watch the recording"]').first().click() + const carousel = page.getByRole('dialog', { name: 'capture' }) + await expect(carousel.locator('video')).toBeVisible() + expect(page.url()).toContain('/recordings/') + + // Judge both videos: accept on each variant of the walk. + await carousel.getByRole('button', { name: 'accept', exact: true }).click() + await expect(carousel.getByRole('button', { name: '✓ accepted' })).toBeVisible() + await page.keyboard.press('ArrowDown') + await carousel.getByRole('button', { name: 'accept', exact: true }).click() + await expect(carousel.getByRole('button', { name: '✓ accepted' })).toBeVisible() + + detail = await request.get(`${API}/api/projects/${seeded.slug}/cases/${seeded.caseId}`) + expect(((await detail.json()) as { state: string }).state).toBe('accepted') + + // New push, new bytes: the videos are to judge again. + await pushRecordings(page, seeded) + detail = await request.get(`${API}/api/projects/${seeded.slug}/cases/${seeded.caseId}`) + expect(((await detail.json()) as { state: string }).state).toBe('to-review') +}) diff --git a/apps/web/src/app/router.ts b/apps/web/src/app/router.ts index b426872..3c2a8dc 100644 --- a/apps/web/src/app/router.ts +++ b/apps/web/src/app/router.ts @@ -33,6 +33,12 @@ export const router = createRouter({ path: '/projects/:slug/cases/:caseId/steps/:stepId/variants/:variantId', component: CasePage, }, + // The recording view, addressed by variant: the address survives a push — + // it always shows the current edition's video (ADR 0023). + { + path: '/projects/:slug/cases/:caseId/recordings/:variantId', + component: CasePage, + }, // The gallery exists only in a dev build: no end user ever sees it, and // the production bundle does not carry it (#155). ...(import.meta.env.DEV diff --git a/apps/web/src/features/review/useReview.ts b/apps/web/src/features/review/useReview.ts index f0e1cd7..6669f57 100644 --- a/apps/web/src/features/review/useReview.ts +++ b/apps/web/src/features/review/useReview.ts @@ -125,6 +125,46 @@ export function useReview(slug: () => string, caseId: () => string) { await load() } + /** 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) { + saving.value = true + const result = await api.POST('/projects/{slug}/recordings/{recordingId}/judgment', { + params: { path: { slug: slug(), recordingId } }, + body: { accept, remark: remark || undefined }, + }) + saving.value = false + if (result.error) { + if (expired(result.response)) { + held.value = () => judgeRecording(recordingId, accept, remark) + return + } + error.value = result.error.title + return + } + held.value = null + await load() + } + + /** Take the video's verdict back, symmetrically. */ + async function unjudgeRecording(recordingId: string) { + saving.value = true + const result = await api.DELETE('/projects/{slug}/recordings/{recordingId}/judgment', { + params: { path: { slug: slug(), recordingId } }, + }) + saving.value = false + if (result.error) { + if (expired(result.response)) { + held.value = () => unjudgeRecording(recordingId) + return + } + error.value = result.error.title + return + } + held.value = null + await load() + } + /** Take a judgment back — the reviewer reconsiders an acceptance or a * refusal, and the ref returns to their court (#167, #171). */ async function unjudge(commentId: string, issueId: string, variantId: string) { @@ -195,6 +235,8 @@ export function useReview(slug: () => string, caseId: () => string) { edit, judge, unjudge, + judgeRecording, + unjudgeRecording, resume, } } diff --git a/apps/web/src/pages/case/CasePage.vue b/apps/web/src/pages/case/CasePage.vue index 338d01b..422a442 100644 --- a/apps/web/src/pages/case/CasePage.vue +++ b/apps/web/src/pages/case/CasePage.vue @@ -51,12 +51,27 @@ const open = computed(() => : null, ) +/** The recording view: same carousel, addressed by variant (ADR 0023). */ +const openRecording = computed(() => + route.path.includes('/recordings/') && route.params.variantId + ? String(route.params.variantId) + : null, +) + const caseUrl = computed(() => `/projects/${slug.value}/cases/${caseId.value}`) function openCapture(stepId: string, variantId: string) { void router.push(`${caseUrl.value}/steps/${stepId}/variants/${variantId}`) } +function openRecordingView(variantId: string) { + void router.push(`${caseUrl.value}/recordings/${variantId}`) +} + +function moveToRecording(variantId: string) { + void router.replace(`${caseUrl.value}/recordings/${variantId}`) +} + /** Arrow keys walk, they do not stack: replace, so back means the grid. */ function moveTo(stepId: string, variantId: string) { void router.replace(`${caseUrl.value}/steps/${stepId}/variants/${variantId}`) @@ -146,6 +161,16 @@ async function onUnjudge(commentId: string, issueRefId: string, variantId: strin await refreshCase() } +async function onJudgeRecording(recordingId: string, accept: boolean, remark: string) { + await review.judgeRecording(recordingId, accept, remark) + await refreshCase() +} + +async function onUnjudgeRecording(recordingId: string) { + await review.unjudgeRecording(recordingId) + await refreshCase() +} + /** The case's own state is recomputed by the server on every move, so it is * read back rather than guessed here (ADR 0012). */ async function refreshCase() { @@ -202,16 +227,20 @@ async function refreshCase() { { expect(w.emitted('unjudge')).toEqual([['k7', 'ref7', 'v1']]) }) }) + +describe('the recording is judged in the carousel (ADR 0023, #226)', () => { + const withVideo: Grid = { + ...grid, + recordings: [ + { id: 'rec1', variantId: 'v1', hash: 'sha256:vid', status: 'to-review' }, + { id: 'rec2', variantId: 'v2', hash: 'sha256:vid2', status: 'to-review' }, + ], + } + const mountRecording = (over: Partial = {}) => + mount(CaptureCarousel, { + props: { + slug: 'atlas', + grid: { ...withVideo, ...over }, + comments: [], + stepId: '', + variantId: 'v1', + recording: true, + }, + }) + + it('shows the player and judges these exact bytes', async () => { + const w = mountRecording() + const video = w.find('video') + expect(video.attributes('src')).toBe('/api/projects/atlas/recordings/rec1') + expect(w.text()).toContain('recording') + + await half(w, 'accept').trigger('click') + expect(w.emitted('judgeRecording')).toEqual([['rec1', true, '']]) + }) + + it('refuses through the sheet — remark mandatory, no variant ticks', async () => { + const w = mountRecording() + await half(w, 'refuse').trigger('click') + expect(w.text()).toContain('Refuse the recording') + expect(w.find('input[type="checkbox"]').exists()).toBe(false) + + await w.find('textarea').setValue('the flow stutters at the door') + await w.find('form').findAll('button').at(-1)!.trigger('click') + expect(w.emitted('judgeRecording')).toEqual([['rec1', false, 'the flow stutters at the door']]) + }) + + it('takes a verdict back from the filled half and shows the standing refusal', async () => { + const w = mountRecording({ + recordings: [ + { + id: 'rec1', + variantId: 'v1', + hash: 'sha256:vid', + status: 'refused', + refusal: 'the flow stutters at the door', + }, + ], + }) + expect(w.text()).toContain('the flow stutters at the door') + await half(w, '✗ refused').trigger('click') + expect(w.emitted('unjudgeRecording')).toEqual([['rec1']]) + }) + + it('walks: right enters the steps, down reaches the other variant', async () => { + const w = mountRecording() + press('ArrowRight') + await w.vm.$nextTick() + expect(w.emitted('move')?.[0]).toEqual(['s1', 'v1']) + press('ArrowDown') + await w.vm.$nextTick() + expect(w.emitted('moveRecording')?.[0]).toEqual(['v2']) + w.unmount() + }) +}) diff --git a/apps/web/src/widgets/capture-carousel/CaptureCarousel.vue b/apps/web/src/widgets/capture-carousel/CaptureCarousel.vue index 907fe08..7e986d1 100644 --- a/apps/web/src/widgets/capture-carousel/CaptureCarousel.vue +++ b/apps/web/src/widgets/capture-carousel/CaptureCarousel.vue @@ -19,6 +19,9 @@ const props = defineProps<{ comments: Comment[] stepId: string variantId: string + /** The recording view (ADR 0023): the player instead of a capture, the + * pair judging the video of this variant at the current edition. */ + recording?: boolean busy?: boolean }>() @@ -39,12 +42,20 @@ const emit = defineEmits<{ judge: [commentId: string, issueRefId: string, accept: boolean, remark: string, variantId: string] /** Empty variantId keeps the take-back ref-level (a refusal reopening). */ unjudge: [commentId: string, issueRefId: string, variantId: string] + moveRecording: [variantId: string] + judgeRecording: [recordingId: string, accept: boolean, remark: string] + unjudgeRecording: [recordingId: string] }>() /** Where the open step sits in the flow. The counter counts steps: the * horizontal walk is the flow, and the variant is a lens on it (#149). */ const stepIndex = computed(() => props.grid.steps.findIndex((s) => s.id === props.stepId)) +/** The video of this variant at the current edition (ADR 0023). */ +const rec = computed(() => + props.recording ? props.grid.recordings.find((r) => r.variantId === props.variantId) : undefined, +) + const step = computed(() => props.grid.steps.find((s) => s.id === props.stepId)) const variant = computed(() => props.grid.variants.find((v) => v.id === props.variantId)) const capture = computed(() => step.value?.captures.find((c) => c.variantId === props.variantId)) @@ -126,6 +137,11 @@ const contextRemark = computed( /** What the pair shows. The fix's judgment outranks the capture's own status: * when an issue is on this capture, the verdict is about the fix. */ const verdict = computed<'none' | 'accepted' | 'refused'>(() => { + if (props.recording) { + if (rec.value?.status === 'accepted') return 'accepted' + if (rec.value?.status === 'refused') return 'refused' + return 'none' + } if (toJudge.value || remarkToJudge.value) return 'none' if (acceptedRef.value) return 'accepted' if (refusedRef.value) return 'refused' @@ -142,6 +158,16 @@ const moved = computed(() => capture.value?.status === 'moved') /** Left and right walk the steps, keeping the variant; a step that lacks it * is skipped rather than switching the lens under the reviewer (#149). */ function go(delta: number) { + if (props.recording) { + // The video is the walk's first position: right enters the steps. + if (delta > 0) { + const first = props.grid.steps.find((s) => + s.captures.some((c) => c.variantId === props.variantId), + ) + if (first) emit('move', first.id, props.variantId) + } + return + } for (let i = stepIndex.value + delta; i >= 0 && i < props.grid.steps.length; i += delta) { const candidate = props.grid.steps[i] if (candidate.captures.some((c) => c.variantId === props.variantId)) { @@ -149,13 +175,24 @@ function go(delta: number) { return } } + // Left past the first step lands on the video, when there is one. + if (delta < 0 && props.grid.recordings.some((r) => r.variantId === props.variantId)) { + emit('moveRecording', props.variantId) + } } /** Up and down move to the same step's next variant. */ function goVariant(delta: number) { const here = props.grid.variants.findIndex((v) => v.id === props.variantId) const next = props.grid.variants[here + delta] - if (next && step.value?.captures.some((c) => c.variantId === next.id)) { + if (!next) return + if (props.recording) { + if (props.grid.recordings.some((r) => r.variantId === next.id)) { + emit('moveRecording', next.id) + } + return + } + if (step.value?.captures.some((c) => c.variantId === next.id)) { emit('move', props.stepId, next.id) } } @@ -197,9 +234,9 @@ function pickAll() { const canSend = computed(() => { if (remark.value.trim() === '') return false - // A fix's refusal needs no variants: the issue — or the delivered remark — - // already owns its own. - return toJudge.value || remarkToJudge.value ? true : chosen.value.length > 0 + // A fix's or a recording's refusal needs no variants: the thing being + // judged already owns its own. + return props.recording || toJudge.value || remarkToJudge.value ? true : chosen.value.length > 0 }) function openSheet() { @@ -224,6 +261,11 @@ function closeSheet() { function send() { if (!sheet.value || !canSend.value) return const body = remark.value.trim() + if (props.recording) { + if (rec.value) emit('judgeRecording', rec.value.id, false, body) + closeSheet() + return + } if (sheet.value.editing) { emit('edit', sheet.value.editing, body, [...chosen.value]) } else if (toJudge.value) { @@ -245,6 +287,14 @@ function send() { function onAccept() { if (props.busy || sheet.value) return + if (props.recording) { + if (!rec.value) return + // Accepted un-presses; refused or unjudged accepts — one gesture, the + // judgment lands on these bytes (ADR 0023). + if (rec.value.status === 'accepted') emit('unjudgeRecording', rec.value.id) + else emit('judgeRecording', rec.value.id, true, '') + return + } if (toJudge.value) { emit('judge', toJudge.value.comment.id, toJudge.value.ref.id, true, '', props.variantId) return @@ -272,6 +322,12 @@ function onAccept() { function onRefuse() { if (props.busy || sheet.value) return + if (props.recording) { + if (!rec.value) return + if (rec.value.status === 'refused') emit('unjudgeRecording', rec.value.id) + else openSheet() + return + } if (refusedRef.value) { emit('unjudge', refusedRef.value.comment.id, refusedRef.value.ref.id, '') return @@ -317,7 +373,9 @@ function onKey(event: KeyboardEvent) { goVariant(-1) break case ' ': - // Space plays accept — give or take back (ADR 0020). + // Space plays accept — give or take back (ADR 0020). On the video it + // belongs to the player: play and pause, the pair is clicked. + if (props.recording) return event.preventDefault() if (sheet.value) break onAccept() @@ -343,18 +401,21 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey)) class="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 bg-slate-50 px-3 py-2 font-mono text-mono text-slate-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400" > - {{ step?.name }} + {{ + recording ? 'recording' : step?.name + }} · {{ variant?.label }} - {{ stepIndex + 1 }} / {{ grid.steps.length }} · + step · variant · - space accept · + space + {{ recording ? 'play' : 'accept' }} · Esc close @@ -391,6 +452,16 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey)) class="max-h-full max-w-full border border-slate-300 bg-white dark:border-slate-600 dark:bg-slate-900" /> + + + + +

+ {{ rec.refusal }} +

-

+

window.removeEventListener('keydown', onKey)) -