feat(submit): give the upload lane a lifecycle — withdraw + admin delete (#76)

Nothing ever removed a submission: users could not retract a pending row, no
route deleted blobs or rows, and the reaper never touches uploads — so every
upload accumulated on the 5 GiB PVC forever and the only cleanup was SQL or
kubectl against the store.

- Blobs.Delete on both transports (local: RemoveAll of the id-namespaced dir,
  id re-validated at the boundary; S3: idempotent object DELETE).
- Store: DeleteSubmission (admin, any status) and DeletePendingSubmission
  (owner+pending CAS — a reviewed row can never be withdrawn out from under
  its build).
- Manager.Delete / Manager.Withdraw delete the ROW first (under the CAS for
  withdraw) and the blob after, so a live row can never point at a reaped
  blob; a cleanup failure names the orphan explicitly instead of failing mute.
- API: DELETE /me/submissions/{id} (withdraw, app tier) and
  DELETE /api/v1/submissions/{id} (admin) both return the row as it was;
  audit events submission.withdraw / submission.delete; openapi documents both
  paths; admin route pinned in the admin-only table.
- Panel: two-step withdraw on a pending row (frees the pending slot and the
  storage budget); two-step delete on every admin row; zh/en copy; wire tests.

Unit: submit (withdraw happy path / wrong owner / reviewed row / no transport /
blob-cleanup failure), local+S3 delete idempotence, api handlers (200/404/409/
503 + route tier); pgint: withdraw CAS + admin delete exactly-once.
go vet/go test/gofmt clean; panel vitest 120 + typecheck green.
This commit is contained in:
Lemon-miaow committed 2026-09-24 10:24:23 +08:00
1 parent ad4d256d8f
commit 854320ac3f
20 files changed
+802 -19

No files matched your search

+67
View File
@@ -4355,6 +4355,41 @@ paths:
'503':
$ref: '#/components/responses/ServiceUnavailable'
/api/v1/me/submissions/{id}:
delete:
tags: [submissions]
operationId: withdrawSubmission
summary: Withdraw your own pending submission (user side; user-directed lane over §16).
description: >-
Retracts the caller's own submission while it is still pending review:
the row and its uploaded build context are deleted, freeing the pending
slot and the per-user storage budget for a fresh submission. A reviewed
submission is frozen (409 — its build may already be consuming the
context), and a submission the caller does not own reads back as 404, so
this endpoint cannot probe or clear another user's uploads.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: The withdrawn submission, as it was before the deletion.
content:
application/json:
schema: { $ref: '#/components/schemas/Submission' }
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
'409':
description: Submission has already been reviewed and cannot be withdrawn.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'503':
$ref: '#/components/responses/ServiceUnavailable'
# ------------------------------------------------------ external: admin ----
/api/v1/servers/{name}:
patch:
@@ -4750,3 +4785,35 @@ paths:
schema: { $ref: '#/components/schemas/Error' }
'503':
$ref: '#/components/responses/ServiceUnavailable'
/api/v1/submissions/{id}:
delete:
tags: [submissions]
operationId: deleteSubmission
summary: Retire a submission outright — row and uploaded context (admin; user-directed lane over §16).
description: >-
Removes the submission and its uploaded build context, any status — the
lane's only lifecycle valve, and the path that reclaims a rejected or
consumed upload from the uploads PVC. The reviewer identity is recorded
in the audit event, not on the (now deleted) row. Deleting an approved
submission whose build is still running fails that build's context
fetch; the admin has explicitly chosen to retire the artifact.
x-felis-face: [external]
x-felis-tier: admin
security: [{ accessJWT: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: The deleted submission, as it was before the deletion.
content:
application/json:
schema: { $ref: '#/components/schemas/Submission' }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'503':
$ref: '#/components/responses/ServiceUnavailable'
+9
View File
@@ -518,6 +518,11 @@ func (a *API) externalAPIRoutes() []apiRoute {
// App-tier and owner-scoped (the id must belong to the principal), exactly
// like the create/list routes above.
{Method: "POST", Pattern: "/api/v1/me/submissions/{id}/context", h: a.handleUploadSubmissionContext},
// Withdraw the caller's OWN pending submission: the row and its uploaded
// context are deleted, freeing the pending slot and storage budget. Same
// owner-scoping as the upload route — a reviewed submission is frozen (409)
// and another user's id is invisible (404).
{Method: "DELETE", Pattern: "/api/v1/me/submissions/{id}", h: a.handleWithdrawSubmission},
// Admin (Zero-Trust) tier: create / mutate spec / image admission. These gate
// on Principal.IsAdmin() inside the handler via the adminOnly wrapper, so the
// boundary is exercised even where the body is a later-phase stub.
@@ -547,6 +552,10 @@ func (a *API) externalAPIRoutes() []apiRoute {
{Method: "GET", Pattern: "/api/v1/submissions", Admin: true, h: a.handleListSubmissions},
{Method: "POST", Pattern: "/api/v1/submissions/{id}/approve", Admin: true, h: a.handleApproveSubmission},
{Method: "POST", Pattern: "/api/v1/submissions/{id}/reject", Admin: true, h: a.handleRejectSubmission},
// Retire a submission outright (row + uploaded context), any status. The
// lane's lifecycle valve: without it, rejected/consumed uploads accumulated
// on the uploads PVC forever — there is no other delete path.
{Method: "DELETE", Pattern: "/api/v1/submissions/{id}", Admin: true, h: a.handleDeleteSubmission},
// The reviewer's read path to the uploaded blob: the executed Dockerfile
// lives inside it, so approval would otherwise be blind.
{Method: "GET", Pattern: "/api/v1/submissions/{id}/context", Admin: true, h: a.handleAdminSubmissionContext},
+49
View File
@@ -43,6 +43,13 @@ type SubmissionService interface {
// Reject is the admin's other verdict: pending_review -> rejected with a
// required reason; it starts no build.
Reject(ctx context.Context, id, reviewedBy, reason string) (*submit.Submission, error)
// Withdraw retracts the caller's OWN pending submission: the row and its
// uploaded context are deleted. A reviewed submission is frozen (409) and a
// submission the caller does not own reads back as 404, like the upload route.
Withdraw(ctx context.Context, id, submittedBy string) (*submit.Submission, error)
// Delete retires any submission outright (the admin lifecycle valve): the row
// and its uploaded context are removed, any status.
Delete(ctx context.Context, id string) (*submit.Submission, error)
// OpenContext returns the stored build-context blob for the internal
// context-fetch route: the build Pod's initContainer cannot mount the uploads
// PVC across namespaces and holds no object-store credentials, so it streams
@@ -294,6 +301,48 @@ func (a *API) handleRejectSubmission(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, sub)
}
// handleWithdrawSubmission retracts the caller's own pending submission
// (app-tier): the row and its uploaded context are deleted, freeing the pending
// slot and the storage budget for a fresh submission. The submitter is the
// principal, never the body; a submission the caller does not own is reported as
// 404, so this endpoint cannot probe or clear another user's uploads, and a
// reviewed submission is 409 (its build may already be consuming the context).
func (a *API) handleWithdrawSubmission(w http.ResponseWriter, r *http.Request) {
if a.Submissions == nil {
writeError(w, r, errSubmissionsUnavailable)
return
}
p := principalFromContext(r.Context())
sub, err := a.Submissions.Withdraw(r.Context(), r.PathValue("id"), p.UserID)
if err != nil {
writeSubmitError(w, r, err)
return
}
a.audit(r, p.Email, "submission.withdraw", sub.ID)
writeJSON(w, http.StatusOK, sub)
}
// handleDeleteSubmission retires any submission outright (admin-tier): the row
// and its uploaded context are removed, any status. This is the lane's lifecycle
// valve — the only path that reclaims a rejected or consumed upload from the
// uploads PVC. The reviewer identity goes to the audit event, not the (now
// nonexistent) row. Deleting an approved submission whose build is still running
// fails that build's context fetch; the admin has explicitly chosen to retire it.
func (a *API) handleDeleteSubmission(w http.ResponseWriter, r *http.Request) {
if a.Submissions == nil {
writeError(w, r, errSubmissionsUnavailable)
return
}
p := principalFromContext(r.Context())
sub, err := a.Submissions.Delete(r.Context(), r.PathValue("id"))
if err != nil {
writeSubmitError(w, r, err)
return
}
a.audit(r, p.Email, "submission.delete", sub.ID)
writeJSON(w, http.StatusOK, sub)
}
// errSubmissionsUnavailable is returned when the approval lane is not configured
// on this api instance (a nil Submissions service), so the admin/app boundary is
// still exercised before the subsystem is wired in.
+85
View File
@@ -38,6 +38,11 @@ type fakeSubmissions struct {
rejectedBy string
rejectReas string
rejectErr error
withdrawnID string
withdrawBy string
withdrawErr error
deletedID string
deleteErr error
openedID string
openBody string
openErr error
@@ -88,6 +93,22 @@ func (f *fakeSubmissions) Reject(_ context.Context, id, reviewedBy, reason strin
return &submit.Submission{ID: id, Status: submit.StatusRejected, ReviewedBy: reviewedBy, RejectReason: reason}, nil
}
func (f *fakeSubmissions) Withdraw(_ context.Context, id, submittedBy string) (*submit.Submission, error) {
f.withdrawnID, f.withdrawBy = id, submittedBy
if f.withdrawErr != nil {
return nil, f.withdrawErr
}
return &submit.Submission{ID: id, SubmittedBy: submittedBy, Status: submit.StatusPendingReview}, nil
}
func (f *fakeSubmissions) Delete(_ context.Context, id string) (*submit.Submission, error) {
f.deletedID = id
if f.deleteErr != nil {
return nil, f.deleteErr
}
return &submit.Submission{ID: id, Status: submit.StatusRejected}, nil
}
// openErr injects the OpenContext outcome; the body recorder lets the internal
// route test assert byte-exact streaming and the 404 mapping.
func (f *fakeSubmissions) OpenContext(_ context.Context, id string) (io.ReadCloser, error) {
@@ -249,6 +270,69 @@ func TestUploadSubmissionContextWithoutServiceIs503(t *testing.T) {
}
}
// Withdraw retracts the caller's OWN pending submission: the submitter is the
// principal (never the body), a reviewed submission is 409, and a foreign id is
// 404 — the same posture as the upload route.
func TestWithdrawSubmission(t *testing.T) {
t.Run("withdraws as the principal", func(t *testing.T) {
fs := &fakeSubmissions{}
w := do(appSubAPI(fs).ExternalHandler(), "DELETE", "/api/v1/me/submissions/sub-3", "", nil)
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if fs.withdrawnID != "sub-3" || fs.withdrawBy != "user-7" {
t.Fatalf("withdraw forwarded (%q, %q), want (sub-3, user-7)", fs.withdrawnID, fs.withdrawBy)
}
})
t.Run("reviewed submission is 409", func(t *testing.T) {
fs := &fakeSubmissions{withdrawErr: submit.ErrAlreadyReviewed}
w := do(appSubAPI(fs).ExternalHandler(), "DELETE", "/api/v1/me/submissions/sub-3", "", nil)
if w.Code != http.StatusConflict {
t.Fatalf("code = %d, want 409 (%s)", w.Code, w.Body.String())
}
if got := decodeErr(t, w); got != "already_reviewed" {
t.Errorf("error code = %q, want already_reviewed", got)
}
})
t.Run("foreign or unknown id is 404", func(t *testing.T) {
fs := &fakeSubmissions{withdrawErr: submit.ErrNotFound}
w := do(appSubAPI(fs).ExternalHandler(), "DELETE", "/api/v1/me/submissions/sub-x", "", nil)
if w.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404 (%s)", w.Code, w.Body.String())
}
})
t.Run("no service is 503", func(t *testing.T) {
app := appSubAPI(nil)
app.Submissions = nil
w := do(app.ExternalHandler(), "DELETE", "/api/v1/me/submissions/sub-3", "", nil)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("code = %d, want 503 (%s)", w.Code, w.Body.String())
}
})
}
// The admin delete retires any submission and maps the lane's 404; the route's
// admin gate itself is pinned by TestSubmissionAdminRoutesAreAdminOnly.
func TestDeleteSubmissionAdmin(t *testing.T) {
t.Run("deletes the named row", func(t *testing.T) {
fs := &fakeSubmissions{}
w := do(adminSubAPI(fs).ExternalHandler(), "DELETE", "/api/v1/submissions/sub-8", "", nil)
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if fs.deletedID != "sub-8" {
t.Fatalf("delete forwarded id %q, want sub-8", fs.deletedID)
}
})
t.Run("unknown is 404", func(t *testing.T) {
fs := &fakeSubmissions{deleteErr: submit.ErrNotFound}
w := do(adminSubAPI(fs).ExternalHandler(), "DELETE", "/api/v1/submissions/sub-x", "", nil)
if w.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404 (%s)", w.Code, w.Body.String())
}
})
}
// The "my uploads" list scopes strictly to the principal's id — there is no
// parameter that could widen it to another user's submissions.
func TestMySubmissionsScopesToPrincipal(t *testing.T) {
@@ -343,6 +427,7 @@ func TestSubmissionAdminRoutesAreAdminOnly(t *testing.T) {
{"GET", "/api/v1/submissions", ""},
{"POST", "/api/v1/submissions/sub-1/approve", ""},
{"POST", "/api/v1/submissions/sub-1/reject", `{"reason":"no"}`},
{"DELETE", "/api/v1/submissions/sub-1", ""},
{"GET", "/api/v1/submissions/sub-1/context", ""},
}
for _, c := range cases {
+30
View File
@@ -1034,6 +1034,36 @@ func TestSubmitStoreContract(t *testing.T) {
if got, _ = s.GetSubmission(ctx, id); got.BuildID == "" {
t.Fatal("LinkBuild must persist build_id")
}
// Lifecycle: the withdraw CAS deletes ONLY the owner's still-pending row — a
// wrong owner or a reviewed row can never delete through it — and the admin
// path deletes any status, exactly once.
id2 := "sub-w-" + suffix(t)
if err := s.CreateSubmission(ctx, &submit.Submission{
ID: id2, SubmittedBy: u.ID, DisplayName: "withdraw me",
ContextRef: "s3://bucket/" + id2 + "/context.tar.gz",
Status: submit.StatusPendingReview, CreatedAt: now,
}); err != nil {
t.Fatalf("CreateSubmission(2): %v", err)
}
if ok, err := s.DeletePendingSubmission(ctx, id2, "someone-else"); err != nil || ok {
t.Fatalf("withdraw by a non-owner = (%v, %v), want (false, nil)", ok, err)
}
if ok, err := s.DeletePendingSubmission(ctx, id, u.ID); err != nil || ok {
t.Fatalf("withdraw of a reviewed row = (%v, %v), want (false, nil)", ok, err)
}
if ok, err := s.DeletePendingSubmission(ctx, id2, u.ID); err != nil || !ok {
t.Fatalf("withdraw by the owner = (%v, %v), want (true, nil)", ok, err)
}
if _, err := s.GetSubmission(ctx, id2); !errors.Is(err, submit.ErrNotFound) {
t.Fatalf("withdrawn row still readable: %v", err)
}
if ok, err := s.DeleteSubmission(ctx, id); err != nil || !ok {
t.Fatalf("admin delete = (%v, %v), want (true, nil)", ok, err)
}
if ok, err := s.DeleteSubmission(ctx, id); err != nil || ok {
t.Fatalf("second admin delete = (%v, %v), want (false, nil)", ok, err)
}
}
// ---- builds --------------------------------------------------------------------
+14
View File
@@ -149,5 +149,19 @@ func (s *LocalContextStore) Size(_ context.Context, id string) (int64, bool, err
}
}
// Delete removes everything stored for id — the blob plus its id-namespaced
// directory (a stray temp file from an interrupted upload goes with it) — after
// the submission row is gone. Idempotent: nothing stored is success.
func (s *LocalContextStore) Delete(_ context.Context, id string) error {
dir, err := s.dir(id)
if err != nil {
return err
}
if err := os.RemoveAll(dir); err != nil {
return fmt.Errorf("submit: remove context blob: %w", err)
}
return nil
}
// Compile-time proof that the filesystem store satisfies the Blobs transport.
var _ Blobs = (*LocalContextStore)(nil)
+29
View File
@@ -109,6 +109,35 @@ func TestLocalContextStorePutOverwrites(t *testing.T) {
}
}
// Delete removes the blob and its id-namespaced directory, and is idempotent —
// the retry-safety the withdraw/delete cleanup depends on.
func TestLocalContextStoreDelete(t *testing.T) {
base := t.TempDir()
s := &LocalContextStore{Base: base}
ctx := context.Background()
if _, err := s.Put(ctx, "sub-abc", strings.NewReader("\x1f\x8bbytes")); err != nil {
t.Fatalf("Put: %v", err)
}
if err := s.Delete(ctx, "sub-abc"); err != nil {
t.Fatalf("Delete: %v", err)
}
if ok, err := s.Exists(ctx, "sub-abc"); err != nil || ok {
t.Fatalf("Exists after Delete = (%v, %v), want (false, nil)", ok, err)
}
if _, err := os.Stat(filepath.Join(base, "sub-abc")); !os.IsNotExist(err) {
t.Fatalf("per-submission dir still present after Delete (err=%v)", err)
}
// Idempotent: deleting nothing is success, so a retried cleanup cannot fail.
if err := s.Delete(ctx, "sub-abc"); err != nil {
t.Fatalf("second Delete = %v, want nil (idempotent)", err)
}
// The same path guard as Put/Open.
if err := s.Delete(ctx, "../etc"); err == nil {
t.Fatal("Delete must reject an unsafe id")
}
}
func TestLocalContextStoreRejectsUnsafeID(t *testing.T) {
base := t.TempDir()
s := &LocalContextStore{Base: base}
+25 -8
View File
@@ -57,14 +57,16 @@ func (s *PGStore) ListSubmissionsBy(ctx context.Context, submittedBy string) ([]
return s.querySubmissions(ctx, q, submittedBy)
}
// cas executes a compare-and-set UPDATE and reports whether THIS call moved the
// row. The `status = 'pending_review'` guard is the actual CAS predicate and is
// deliberately kept INLINE in each caller's query — it is security-visible, so a
// reader auditing "can a non-pending row be flipped?" must see it next to the SET.
// cas only folds the shared ExecContext + RowsAffected tail so the two reviewers
// (approve, reject) cannot drift in how they report a lost race or a RowsAffected
// error. n == 0 means a concurrent review already won the row — reported as
// won=false (never an error), which the Manager maps to ErrAlreadyReviewed.
// cas executes a single-statement compare-and-set — an UPDATE or DELETE whose
// WHERE clause is the predicate — and reports whether THIS call moved a row. The
// `status = 'pending_review'` guard is the actual CAS predicate in each caller's
// query and is deliberately kept INLINE — it is security-visible, so a reader
// auditing "can a non-pending row be flipped or deleted?" must see it next to the
// SET or DELETE. cas only folds the shared ExecContext + RowsAffected tail so
// the reviewers and deleters cannot drift in how they report a lost race or a
// RowsAffected error. n == 0 means a concurrent actor already won the row —
// reported as won=false (never an error), which the Manager maps to
// ErrAlreadyReviewed / ErrNotFound.
func (s *PGStore) cas(ctx context.Context, q string, args ...any) (bool, error) {
res, err := s.db.ExecContext(ctx, q, args...)
if err != nil {
@@ -107,6 +109,21 @@ func (s *PGStore) LinkBuild(ctx context.Context, id, buildID string) error {
return nil
}
// DeleteSubmission is the admin delete: any status, one row, no predicate beyond
// the id. False means the id was already gone (a concurrent delete won).
func (s *PGStore) DeleteSubmission(ctx context.Context, id string) (bool, error) {
const q = `DELETE FROM image_submissions WHERE id = $1`
return s.cas(ctx, q, id)
}
// DeletePendingSubmission is the withdraw CAS: owner + pending_review must both
// still hold, so a reviewed submission can never be deleted through this path.
func (s *PGStore) DeletePendingSubmission(ctx context.Context, id, submittedBy string) (bool, error) {
const q = `DELETE FROM image_submissions
WHERE id = $1 AND submitted_by = $2 AND status = 'pending_review'`
return s.cas(ctx, q, id, submittedBy)
}
func (s *PGStore) querySubmissions(ctx context.Context, q string, args ...any) ([]Submission, error) {
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
+15
View File
@@ -21,6 +21,7 @@ type s3Client interface {
PutObject(ctx context.Context, bucket, object string, reader io.Reader, size int64, opts minio.PutObjectOptions) (minio.UploadInfo, error)
StatObject(ctx context.Context, bucket, object string, opts minio.StatObjectOptions) (minio.ObjectInfo, error)
GetObject(ctx context.Context, bucket, object string, opts minio.GetObjectOptions) (s3Object, error)
RemoveObject(ctx context.Context, bucket, object string, opts minio.RemoveObjectOptions) error
}
// s3Object is the handle GetObject yields: a stream whose Stat performs the HEAD
@@ -205,6 +206,20 @@ func (s *S3ContextStore) Size(ctx context.Context, id string) (int64, bool, erro
return info.Size, true, nil
}
// Delete removes the stored object for the withdrawn/deleted submission. S3's
// DELETE is idempotent — removing an absent key succeeds — which is exactly the
// contract the cleanup path needs on a retry.
func (s *S3ContextStore) Delete(ctx context.Context, id string) error {
key, err := s.keyFor(id)
if err != nil {
return err
}
if err := s.client.RemoveObject(ctx, s.bucket, key, minio.RemoveObjectOptions{}); err != nil {
return fmt.Errorf("submit: remove context blob: %w", err)
}
return nil
}
// Open returns the stored context blob for id — the read side of the transport the
// build Pod's fetch initContainer uses. minio's GetObject returns only once the
// server answered with an object (it surfaces NoSuchKey up front), so a missing
+37
View File
@@ -18,6 +18,7 @@ type fakeS3 struct {
objects map[string][]byte
putErr error
statErr error // when set, StatObject returns it (e.g. auth rejected / bucket missing)
removeErr error
}
func (f *fakeS3) PutObject(_ context.Context, bucket, object string, r io.Reader, _ int64, _ minio.PutObjectOptions) (minio.UploadInfo, error) {
@@ -45,6 +46,16 @@ func (f *fakeS3) StatObject(_ context.Context, bucket, object string, _ minio.St
return minio.ObjectInfo{}, minio.ErrorResponse{Code: "NoSuchKey", StatusCode: http.StatusNotFound}
}
// RemoveObject mirrors S3's idempotent DELETE: removing a key (absent or not)
// succeeds unless removeErr injects a failure.
func (f *fakeS3) RemoveObject(_ context.Context, bucket, object string, _ minio.RemoveObjectOptions) error {
if f.removeErr != nil {
return f.removeErr
}
delete(f.objects, bucket+"/"+object)
return nil
}
// fakeS3Object is the object handle fakeS3.GetObject yields: Stat mirrors
// StatObject's not-found behaviour, Read serves the stored bytes.
type fakeS3Object struct {
@@ -142,6 +153,32 @@ func TestS3ContextStorePutAndExists(t *testing.T) {
}
}
// Delete removes the derived object and is idempotent (S3 DELETE of an absent
// key succeeds), so a retried cleanup after a partial failure cannot stick.
func TestS3ContextStoreDelete(t *testing.T) {
fake := &fakeS3{}
s := &S3ContextStore{client: fake, bucket: "felis-uploads", prefix: "builds"}
ctx := context.Background()
if _, err := s.Put(ctx, "sub-abc", strings.NewReader("\x1f\x8bbytes")); err != nil {
t.Fatalf("Put: %v", err)
}
if err := s.Delete(ctx, "sub-abc"); err != nil {
t.Fatalf("Delete: %v", err)
}
if ok, err := s.Exists(ctx, "sub-abc"); err != nil || ok {
t.Fatalf("Exists after Delete = (%v, %v), want (false, nil)", ok, err)
}
if err := s.Delete(ctx, "sub-abc"); err != nil {
t.Fatalf("second Delete = %v, want nil (idempotent)", err)
}
// A transport failure is surfaced, not swallowed.
fake.removeErr = errors.New("s3 unavailable")
if err := s.Delete(ctx, "sub-abc"); err == nil {
t.Fatal("Delete with a failing transport = nil, want error")
}
}
// Open serves the stored object's bytes and maps a missing key to ErrBlobNotFound
// (the internal fetch route's 404), eagerly — before the caller reads a byte.
func TestS3ContextStoreOpen(t *testing.T) {
+96
View File
@@ -203,6 +203,17 @@ type Store interface {
// (Approve surfaces that distinctly so remediation does not double-build; see
// the Approve ordering note and the package KNOWN-LIMITATION).
LinkBuild(ctx context.Context, id, buildID string) error
// DeleteSubmission removes a submission row outright — the admin delete path.
// Any status is deletable: the row is the business record and the caller has
// chosen to retire it. Reports whether a row was actually deleted; false
// means a concurrent delete won, which the Manager surfaces as ErrNotFound.
DeleteSubmission(ctx context.Context, id string) (bool, error)
// DeletePendingSubmission is the submitter's withdraw CAS: it deletes the row
// only while it is still owned by submittedBy AND still pending_review, so a
// concurrent approve/reject wins or loses cleanly and a reviewed submission
// can never be withdrawn out from under its build. Reports whether THIS call
// deleted the row.
DeletePendingSubmission(ctx context.Context, id, submittedBy string) (bool, error)
}
// Builds is the slice of the build subsystem the approval lane drives. An
@@ -235,6 +246,11 @@ type Blobs interface {
// store — never a recorded number that could drift from it (a re-upload
// supersedes the previous blob in place).
Size(ctx context.Context, id string) (int64, bool, error)
// Delete removes everything stored for id — the withdrawal/review-cleanup
// path. It is idempotent: deleting nothing is success, so a retried cleanup
// never fails on absence. Callers reap the blob only AFTER the row is gone
// (see Manager.deleteBlob), so a live row can never point at a reaped blob.
Delete(ctx context.Context, id string) error
// Open returns the stored blob's bytes for the internal context-fetch route
// the build Pod's initContainer dials (cmd/felis fetch-context). It returns an
// error wrapping ErrBlobNotFound when no blob exists, so the route can answer
@@ -711,6 +727,86 @@ func (m *Manager) Reject(ctx context.Context, id, reviewedBy, reason string) (*S
return sub, nil
}
// Withdraw retracts the submitter's own pending submission: the row is deleted
// (CAS-guarded on owner + pending_review, so a concurrent review can never be
// undercut) and its uploaded context is reaped, freeing both the user's pending
// slot and their storage budget for a fresh submission. Once reviewed the
// context is frozen — an approved build may already be consuming it — so a
// non-pending row reports ErrAlreadyReviewed, exactly like UploadContext, and
// another user's id stays invisible (ErrNotFound), like every other owner-scoped
// operation on the lane.
func (m *Manager) Withdraw(ctx context.Context, id, submittedBy string) (*Submission, error) {
if strings.TrimSpace(submittedBy) == "" {
return nil, invalidf("submitter identity is required")
}
sub, err := m.Store.GetSubmission(ctx, id)
if err != nil {
return nil, err
}
if sub.SubmittedBy != submittedBy {
return nil, ErrNotFound
}
if sub.Status != StatusPendingReview {
return nil, ErrAlreadyReviewed
}
won, err := m.Store.DeletePendingSubmission(ctx, id, submittedBy)
if err != nil {
return nil, err
}
if !won {
// A concurrent approve/reject (or delete) moved the row between the load
// and the CAS. The context must NOT be reaped: it belongs to the review
// outcome now.
return nil, ErrAlreadyReviewed
}
if err := m.deleteBlob(ctx, id); err != nil {
return nil, err
}
return sub, nil
}
// Delete retires any submission outright (the admin path): the row and its
// stored context are both removed, any status. The reviewer identity is recorded
// by the API's audit event, not on the row — the row no longer exists. Deleting
// an approved submission whose build is still running can fail that build (its
// context fetch answers 404); the admin has explicitly chosen to retire the
// artifact. A concurrent delete reports ErrNotFound, the same outcome the
// initial load would have given had it lost the race.
func (m *Manager) Delete(ctx context.Context, id string) (*Submission, error) {
sub, err := m.Store.GetSubmission(ctx, id)
if err != nil {
return nil, err
}
deleted, err := m.Store.DeleteSubmission(ctx, id)
if err != nil {
return nil, err
}
if !deleted {
return nil, ErrNotFound
}
if err := m.deleteBlob(ctx, id); err != nil {
return nil, err
}
return sub, nil
}
// deleteBlob reaps a submission's stored context after its row is gone. The row
// is deleted FIRST (for Withdraw, under the status CAS), so a cleanup failure
// here can never leave a live row pointing at a reaped blob — the failure mode
// is the other direction: the row is retired and the blob is orphaned on the
// uploads store, which the returned error names explicitly so the operator knows
// exactly what is left behind. A deployment with no upload transport (Blobs nil)
// has no blobs to reap.
func (m *Manager) deleteBlob(ctx context.Context, id string) error {
if m.Blobs == nil {
return nil
}
if err := m.Blobs.Delete(ctx, id); err != nil {
return fmt.Errorf("submit: submission removed, but its uploaded context could not be deleted (it may remain on the uploads store): %w", err)
}
return nil
}
// List returns every submission, newest first (the admin review queue).
func (m *Manager) List(ctx context.Context) ([]Submission, error) {
return m.Store.ListSubmissions(ctx)
+183
View File
@@ -25,6 +25,7 @@ type fakeBlobs struct {
putErr error
existsErr error
sizeErr error
deleteErr error
forceExists *bool // overrides the stored-map lookup for the approve-gate tests
}
@@ -65,6 +66,15 @@ func (f *fakeBlobs) Size(_ context.Context, id string) (int64, bool, error) {
return int64(len(b)), true, nil
}
// Delete mirrors the real stores: idempotent, nothing stored is success.
func (f *fakeBlobs) Delete(_ context.Context, id string) error {
if f.deleteErr != nil {
return f.deleteErr
}
delete(f.stored, id)
return nil
}
func (f *fakeBlobs) Open(_ context.Context, id string) (io.ReadCloser, error) {
b, ok := f.stored[id]
if !ok {
@@ -90,6 +100,7 @@ type fakeStore struct {
approveErr error
rejectErr error
linkErr error
deleteErr error
linked []string // "id=buildID" recorder
}
@@ -190,6 +201,29 @@ func (f *fakeStore) LinkBuild(_ context.Context, id, buildID string) error {
return nil
}
func (f *fakeStore) DeleteSubmission(_ context.Context, id string) (bool, error) {
if f.deleteErr != nil {
return false, f.deleteErr
}
if _, ok := f.subs[id]; !ok {
return false, nil // concurrent delete won / nonexistent
}
delete(f.subs, id)
return true, nil
}
func (f *fakeStore) DeletePendingSubmission(_ context.Context, id, by string) (bool, error) {
if f.deleteErr != nil {
return false, f.deleteErr
}
s, ok := f.subs[id]
if !ok || s.SubmittedBy != by || s.Status != StatusPendingReview {
return false, nil
}
delete(f.subs, id)
return true, nil
}
// fakeBuilds records Submit calls and can inject a failure.
type fakeBuilds struct {
submitErr error
@@ -819,6 +853,155 @@ func TestCreatePendingCountFailureSurfaces(t *testing.T) {
}
}
// Withdraw retracts the submitter's own pending submission: the row AND its
// uploaded context are gone — which is what frees the pending slot and the
// storage budget for a fresh submission.
func TestWithdrawDeletesRowAndBlob(t *testing.T) {
m, st, _ := newManager()
fb := newFakeBlobs()
m.Blobs = fb
ctx := context.Background()
seed, _ := m.Create(ctx, CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
if _, err := m.UploadContext(ctx, seed.ID, "user-1", strings.NewReader(gzBody("bytes"))); err != nil {
t.Fatalf("UploadContext: %v", err)
}
sub, err := m.Withdraw(ctx, seed.ID, "user-1")
if err != nil {
t.Fatalf("Withdraw: %v", err)
}
if sub.ID != seed.ID || sub.Status != StatusPendingReview {
t.Fatalf("withdrawn row = %+v, want the pending row back", sub)
}
if _, ok := st.subs[seed.ID]; ok {
t.Fatal("withdraw must delete the row")
}
if _, ok := fb.stored[seed.ID]; ok {
t.Fatal("withdraw must reap the uploaded context")
}
}
// Another user's id is invisible on the withdraw path (404, not 403), and
// nothing is touched.
func TestWithdrawNotOwnerIsNotFound(t *testing.T) {
m, st, _ := newManager()
fb := newFakeBlobs()
m.Blobs = fb
ctx := context.Background()
seed, _ := m.Create(ctx, CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
if _, err := m.UploadContext(ctx, seed.ID, "user-1", strings.NewReader(gzBody("x"))); err != nil {
t.Fatalf("UploadContext: %v", err)
}
if _, err := m.Withdraw(ctx, seed.ID, "user-2"); !errors.Is(err, ErrNotFound) {
t.Fatalf("err = %v, want ErrNotFound", err)
}
if _, ok := st.subs[seed.ID]; !ok {
t.Fatal("a non-owner withdraw must not delete the row")
}
if _, ok := fb.stored[seed.ID]; !ok {
t.Fatal("a non-owner withdraw must not reap the context")
}
}
// A reviewed submission is frozen: the withdraw CAS refuses it (409), keeping
// the context a running/queued build may still consume.
func TestWithdrawReviewedIsAlreadyReviewed(t *testing.T) {
m, st, _ := newManager()
fb := newFakeBlobs()
m.Blobs = fb
ctx := context.Background()
seed, _ := m.Create(ctx, CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
if _, err := m.UploadContext(ctx, seed.ID, "user-1", strings.NewReader(gzBody("x"))); err != nil {
t.Fatalf("UploadContext: %v", err)
}
if _, err := m.Reject(ctx, seed.ID, "[email protected]", "no"); err != nil {
t.Fatalf("Reject: %v", err)
}
if _, err := m.Withdraw(ctx, seed.ID, "user-1"); !errors.Is(err, ErrAlreadyReviewed) {
t.Fatalf("err = %v, want ErrAlreadyReviewed", err)
}
if _, ok := st.subs[seed.ID]; !ok {
t.Fatal("a reviewed row must survive a withdraw attempt")
}
if _, ok := fb.stored[seed.ID]; !ok {
t.Fatal("a reviewed row's context must survive a withdraw attempt")
}
}
// The admin delete retires ANY status, reaping the context with it.
func TestDeleteAnyStatusReapsContext(t *testing.T) {
m, st, _ := newManager()
fb := newFakeBlobs()
m.Blobs = fb
ctx := context.Background()
seed, _ := m.Create(ctx, CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
if _, err := m.UploadContext(ctx, seed.ID, "user-1", strings.NewReader(gzBody("x"))); err != nil {
t.Fatalf("UploadContext: %v", err)
}
if _, err := m.Reject(ctx, seed.ID, "[email protected]", "no"); err != nil {
t.Fatalf("Reject: %v", err)
}
sub, err := m.Delete(ctx, seed.ID)
if err != nil {
t.Fatalf("Delete: %v", err)
}
if sub.Status != StatusRejected {
t.Fatalf("returned status = %q, want the row as it was before the delete", sub.Status)
}
if _, ok := st.subs[seed.ID]; ok {
t.Fatal("delete must remove the row")
}
if _, ok := fb.stored[seed.ID]; ok {
t.Fatal("delete must reap the context")
}
}
// Deleting an unknown id — including the second delete of one already gone —
// is ErrNotFound (404), not a crash and not a silent success.
func TestDeleteUnknownIsNotFound(t *testing.T) {
m, _, _ := newManager()
m.Blobs = newFakeBlobs()
if _, err := m.Delete(context.Background(), "sub-nope"); !errors.Is(err, ErrNotFound) {
t.Fatalf("err = %v, want ErrNotFound", err)
}
}
// A deployment without an upload transport has no blobs to reap: delete still
// retires the row.
func TestDeleteWithoutTransport(t *testing.T) {
m, st, _ := newManager() // Blobs nil
ctx := context.Background()
seed, _ := m.Create(ctx, CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
if _, err := m.Delete(ctx, seed.ID); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, ok := st.subs[seed.ID]; ok {
t.Fatal("delete must remove the row even with no transport")
}
}
// A blob-cleanup failure after the row is gone must surface loudly (naming what
// was left behind), never masquerade as success.
func TestDeleteBlobCleanupFailureSurfaces(t *testing.T) {
m, st, _ := newManager()
fb := newFakeBlobs()
fb.deleteErr = errors.New("pvc read-only")
m.Blobs = fb
ctx := context.Background()
seed, _ := m.Create(ctx, CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
_, err := m.Delete(ctx, seed.ID)
if err == nil || !strings.Contains(err.Error(), "could not be deleted") {
t.Fatalf("err = %v, want the cleanup failure named", err)
}
if _, ok := st.subs[seed.ID]; ok {
t.Fatal("the row is deleted before the blob reap; it must stay deleted")
}
}
func TestApproveRefusesMissingContext(t *testing.T) {
// With a transport wired, approving a submission whose context was never
// uploaded fails BEFORE the CAS: the row stays pending and no build starts.
@@ -53,6 +53,9 @@
"table_display_name": "Name",
"approve_btn": "Approve",
"reject_btn": "Reject",
"delete_submission_btn": "Delete submission",
"delete_submission_confirm": "Delete",
"delete_submission_cancel": "Cancel",
"reject_reason_label": "Rejection Reason",
"reject_reason_placeholder": "Please provide a reason (max 1000 characters)",
"reject_dialog_title": "Reject Submission",
@@ -36,5 +36,9 @@
"field_context_ref": "Context Reference",
"field_image_ref": "Image Reference",
"clear_btn": "Clear",
"file_hint": "Supports .tar.gz (max 1GB)"
"file_hint": "Supports .tar.gz (max 1GB)",
"withdraw_btn": "Withdraw",
"withdraw_hint": "Withdrawing deletes this submission and its uploaded context, freeing your pending slot and storage budget.",
"withdraw_confirm": "Withdraw",
"withdraw_cancel": "Cancel"
}
@@ -53,6 +53,9 @@
"table_display_name": "名称",
"approve_btn": "通过",
"reject_btn": "驳回",
"delete_submission_btn": "删除提交",
"delete_submission_confirm": "确认删除",
"delete_submission_cancel": "取消",
"reject_reason_label": "驳回原因",
"reject_reason_placeholder": "请填写驳回原因(限 1000 字符)",
"reject_dialog_title": "驳回申请",
@@ -36,5 +36,9 @@
"field_context_ref": "构建上下文引用",
"field_image_ref": "目标镜像引用",
"clear_btn": "清除",
"file_hint": "支持 .tar.gz 格式 (最大 1GB)"
"file_hint": "支持 .tar.gz 格式 (最大 1GB)",
"withdraw_btn": "撤回提交",
"withdraw_hint": "撤回会删除该提交及其已上传的构建上下文,并释放你的待审核名额与存储配额。",
"withdraw_confirm": "确认撤回",
"withdraw_cancel": "取消"
}
+22
View File
@@ -530,6 +530,28 @@ describe("image whitelist and builds wire shapes", () => {
expect(humanizeError({ code: "submission_quota_exceeded" })).toMatch(/quota/i);
expect(humanizeError({ code: "submission_cooldown" })).toMatch(/try again/i);
});
it("withdrawSubmission DELETEs /me/submissions/{id}", async () => {
const sub = { id: "sub-4", status: "pending_review" };
const fetchSpy = fakeFetch(sub);
vi.stubGlobal("fetch", fetchSpy);
const res = await api.withdrawSubmission("sub-4");
expect(res).toEqual(sub);
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
expect(String(url)).toBe("/me/submissions/sub-4");
expect((opts as RequestInit).method).toBe("DELETE");
});
it("deleteSubmission DELETEs /submissions/{id}", async () => {
const sub = { id: "sub-5", status: "rejected" };
const fetchSpy = fakeFetch(sub);
vi.stubGlobal("fetch", fetchSpy);
const res = await api.deleteSubmission("sub-5");
expect(res).toEqual(sub);
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
expect(String(url)).toBe("/submissions/sub-5");
expect((opts as RequestInit).method).toBe("DELETE");
});
});
describe("updates maintenance window", () => {
+8
View File
@@ -474,6 +474,10 @@ export const api = {
rejectSubmission: (id: string, reason: string) =>
request<Submission>("POST", `/submissions/${id}/reject`, { reason }),
// Retire a submission outright (row + uploaded context) — the review queue's
// lifecycle valve, the only way an upload is reclaimed from the PVC.
deleteSubmission: (id: string) => request<Submission>("DELETE", `/submissions/${id}`),
// The reviewer's read path to the uploaded build context: the executed
// Dockerfile lives inside the tarball, so approving without this would be
// blind. The body is the attacker-supplied archive — download it, never
@@ -519,6 +523,10 @@ export const api = {
"Content-Type": "application/x-gzip",
}),
// Retract the caller's own pending submission (and its uploaded context), which
// frees their pending slot and storage budget. Reviewed submissions are frozen.
withdrawSubmission: (id: string) => request<Submission>("DELETE", `/me/submissions/${id}`),
getUpdateWindow: () => request<UpdateWindow>("GET", "/updates/window"),
setUpdateWindow: (window: UpdateWindow) => request<UpdateWindow>("PUT", "/updates/window", window),
+57
View File
@@ -11,6 +11,7 @@ import {
CheckCircle,
CircleSlash,
ClipboardCheck,
Trash2,
} from "lucide-react";
import { SearchInput } from "@/components/SearchInput";
import {
@@ -30,6 +31,7 @@ import {
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { MessageLine } from "@/components/MessageLine";
import { InlineConfirm } from "@/components/InlineConfirm";
import { Loading, ErrorState, EmptyState } from "@/components/States";
import { Pagination } from "@/components/Pagination";
import { StatCard } from "@/components/StatCard";
@@ -90,6 +92,12 @@ export function MySubmissionsPage() {
const [submitStep, setSubmitStep] = useState<"create" | "upload" | null>(null);
const [error, setError] = useState<string | null>(null);
// Row action state: withdraw arms a row (trash → confirm/cancel) before it
// fires, and a failure lands in actionError above the list.
const [confirmingWithdraw, setConfirmingWithdraw] = useState<string | null>(null);
const [withdrawing, setWithdrawing] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Search & Filtering State
@@ -225,6 +233,23 @@ export function MySubmissionsPage() {
}
};
// Withdraw retracts a still-pending submission and its uploaded context,
// freeing the pending slot and the storage budget for a fresh submission.
async function handleWithdraw(id: string) {
if (withdrawing) return;
setWithdrawing(id);
setActionError(null);
try {
await api.withdrawSubmission(id);
setConfirmingWithdraw(null);
reload();
} catch (err) {
setActionError(humanizeError(err));
} finally {
setWithdrawing(null);
}
}
return (
<div className="space-y-6">
{/* Header */}
@@ -250,6 +275,9 @@ export function MySubmissionsPage() {
className="mb-6"
/>
{/* Row-action error (withdraw) */}
{actionError && <MessageLine kind="error" message={actionError} compact />}
{/* Stats Cards Row */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<StatCard icon={ClipboardCheck} label={t("filter_all")} value={stats.total} />
@@ -410,6 +438,35 @@ export function MySubmissionsPage() {
)}
</div>
{sub.status === "pending_review" && (
<div className="pt-2 border-t border-border/20 mt-2 flex flex-wrap items-center justify-between gap-2">
<span className="text-[11px] text-muted-foreground/80">{t("withdraw_hint")}</span>
{confirmingWithdraw === sub.id ? (
<InlineConfirm
open={true}
confirming={withdrawing === sub.id}
onConfirm={() => handleWithdraw(sub.id)}
onCancel={() => setConfirmingWithdraw(null)}
confirmLabel={t("withdraw_confirm")}
cancelLabel={t("withdraw_cancel")}
className="flex shrink-0 items-center gap-1"
/>
) : (
<Button
variant="outline"
size="sm"
className="h-7 shrink-0 border-destructive/30 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => setConfirmingWithdraw(sub.id)}
disabled={withdrawing !== null}
title={t("withdraw_hint")}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
{t("withdraw_btn")}
</Button>
)}
</div>
)}
{sub.status !== "pending_review" && (
<div className="pt-1 border-t border-border/20 mt-2 flex flex-col gap-1.5">
{sub.reviewed_by && (
+60 -9
View File
@@ -1,5 +1,5 @@
import { useState, useMemo } from "react";
import { ClipboardCheck, CheckCircle2, CircleSlash, ChevronDown, ChevronUp, Check, X, Loader2, Download } from "lucide-react";
import { ClipboardCheck, CheckCircle2, CircleSlash, ChevronDown, ChevronUp, Check, X, Loader2, Download, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/components/ui/card";
import { StatCard } from "@/components/StatCard";
@@ -18,6 +18,7 @@ import {
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { MessageLine } from "@/components/MessageLine";
import { InlineConfirm } from "@/components/InlineConfirm";
import { Loading, ErrorState, EmptyState } from "@/components/States";
import { Pagination } from "@/components/Pagination";
import { api, humanizeError } from "@/lib/api";
@@ -43,8 +44,11 @@ export function SubmissionsPage() {
// Pending actions (for button spinners)
const [busyId, setBusyId] = useState<string | null>(null);
const [busyType, setBusyType] = useState<"approve" | "reject" | null>(null);
const [busyType, setBusyType] = useState<"approve" | "reject" | "delete" | null>(null);
const [downloadingId, setDownloadingId] = useState<string | null>(null);
// Delete arms the row (trash → confirm/cancel) before it fires; a row gone on
// one stray click would take its uploaded context with it.
const [confirmingDelete, setConfirmingDelete] = useState<string | null>(null);
// Search & Filtering State
const [search, setSearch] = useState("");
@@ -120,6 +124,26 @@ export function SubmissionsPage() {
setRejectDialogOpen(true);
}
// Delete retires the submission outright — any status — along with its
// uploaded context: the lane's only lifecycle valve, the path that reclaims
// a rejected or consumed upload from the uploads PVC.
async function handleDelete(id: string) {
if (busyId) return;
setBusyId(id);
setBusyType("delete");
setActionError(null);
try {
await api.deleteSubmission(id);
setConfirmingDelete(null);
reload();
} catch (err) {
setActionError(humanizeError(err));
} finally {
setBusyId(null);
setBusyType(null);
}
}
async function handleRejectSubmit(e?: React.FormEvent) {
e?.preventDefault();
if (!rejectingId || !rejectReason.trim()) return;
@@ -266,6 +290,7 @@ export function SubmissionsPage() {
const isExpanded = expandedId === sub.id;
const isBusyApprove = busyId === sub.id && busyType === "approve";
const isBusyReject = busyId === sub.id && busyType === "reject";
const isBusyDelete = busyId === sub.id && busyType === "delete";
return (
<div key={sub.id} className="flex flex-col">
<div
@@ -306,10 +331,13 @@ export function SubmissionsPage() {
{formatRelative(sub.created_at, now, locale)}
</div>
{/* Action buttons (only in table row if NOT pending, else show triggers) */}
{/* Action buttons: approve/reject on pending rows, plus the
two-step delete on every row — it is the only path that
reclaims an upload from the uploads PVC. */}
<div className="col-span-1 text-right" onClick={(e) => e.stopPropagation()}>
{sub.status === "pending_review" ? (
<div className="flex items-center justify-end gap-1.5">
{sub.status === "pending_review" && (
<>
<Button
size="icon"
variant="outline"
@@ -338,12 +366,35 @@ export function SubmissionsPage() {
<X className="h-3.5 w-3.5" />
)}
</Button>
</div>
) : (
<span className="text-[10px] text-muted-foreground/60 font-medium">
—
</span>
</>
)}
{confirmingDelete === sub.id ? (
<InlineConfirm
open={true}
confirming={isBusyDelete}
onConfirm={() => handleDelete(sub.id)}
onCancel={() => setConfirmingDelete(null)}
confirmLabel={t("delete_submission_confirm")}
cancelLabel={t("delete_submission_cancel")}
className="flex shrink-0 items-center gap-1"
/>
) : (
<Button
size="icon"
variant="outline"
className="h-7 w-7 text-destructive hover:text-destructive border-destructive/20 hover:bg-destructive/10 focus-visible:ring-destructive"
onClick={() => setConfirmingDelete(sub.id)}
disabled={!!busyId}
title={t("delete_submission_btn")}
>
{isBusyDelete ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</Button>
)}
</div>
</div>
</div>