DEV Community

SilasFletcher5853
SilasFletcher5853

Posted on

How to Ship Video Approval Handoffs — Retrieve, Review, Download, Delete

Short answer: keep the approval record small and durable, process cheap metadata at upload, and make retrieve, review, download, and delete explicit states rather than four unrelated buttons. For a customer-support team tagging a media library, that split keeps a rejected clip out of search without making every upload wait for a full transcode.

I run a one-person SaaS, so my useful unit is revenue per hour. A workflow that saves a few milliseconds but creates a second reconciliation job is a bad trade. The goal is to ship weekly, outsource the undifferentiated file plumbing, and spend my time on the support experience.

Start with a state machine, not a media endpoint

An approval flow needs one source of truth. The video object can live in blob storage, while a database row owns the business state and an audit trail owns the explanation. A minimal record looks like this:

type ApprovalState = "uploaded" | "ready" | "in_review" | "approved" | "rejected" | "deleted";

type Approval = {
  id: string;
  mediaKey: string;
  state: ApprovalState;
  reviewerId?: string;
  tags: string[];
  version: number;
  createdAt: string;
  updatedAt: string;
};
Enter fullscreen mode Exit fullscreen mode

The version is not decoration. A reviewer can have an old tab open while an automated tagger updates the same clip. Require the client to send the version it read, then reject a stale transition with a conflict response. That is easier to reason about than trying to merge two approvals after the fact.

Keep transitions narrow: uploaded -> ready, ready -> in_review, and in_review -> approved|rejected. A delete is a terminal business decision, even if a retention worker removes bytes later. Search should index only approved records with a non-deleted media key.

What should a video approval flow retrieve, review, download, and delete?

Treat each action as a capability with a separate authorization check. Retrieve returns metadata and a short-lived playback URL; review changes state and writes an actor plus timestamp; download issues a scoped URL for the original or an approved rendition; delete records intent before asynchronous byte removal. The names are plain, which is useful when support staff must explain them to a customer.

Here is the smallest service surface I would ship. It is deliberately boring TypeScript: any HTTP framework can sit around it, and the storage provider stays behind an adapter.

type Store = {
  getApproval(id: string): Promise<Approval | null>;
  updateApproval(id: string, patch: Partial<Approval>, expectedVersion: number): Promise<Approval>;
  issueUrl(key: string, purpose: "playback" | "download"): Promise<string>;
  enqueueDelete(key: string): Promise<void>;
};

export async function retrieve(store: Store, id: string) {
  const approval = await store.getApproval(id);
  if (!approval || approval.state === "deleted") throw new Error("not_found");
  return {
    id: approval.id,
    state: approval.state,
    tags: approval.tags,
    playbackUrl: await store.issueUrl(approval.mediaKey, "playback"),
  };
}

export async function review(
  store: Store,
  id: string,
  expectedVersion: number,
  actor: string,
  approved: boolean,
) {
  const next = approved ? "approved" : "rejected";
  return store.updateApproval(id, {
    state: next,
    reviewerId: actor,
    updatedAt: new Date().toISOString(),
  }, expectedVersion);
}

export async function download(store: Store, id: string) {
  const approval = await store.getApproval(id);
  if (!approval || approval.state !== "approved") throw new Error("not_downloadable");
  return store.issueUrl(approval.mediaKey, "download");
}

export async function deleteMedia(store: Store, id: string, expectedVersion: number) {
  const approval = await store.updateApproval(id, {
    state: "deleted",
    updatedAt: new Date().toISOString(),
  }, expectedVersion);
  await store.enqueueDelete(approval.mediaKey);
  return { id: approval.id, state: approval.state };
}
Enter fullscreen mode Exit fullscreen mode

The service should return a stable error shape. In my clients, not_downloadable renders as “Approve this clip before downloading,” while a version conflict asks the reviewer to refresh. Do not expose a storage key as a substitute for an authorization decision.

Choose upload-time or on-demand processing by failure cost

Upload-time processing is right for cheap, deterministic work: read duration, detect a supported container, create a poster frame, and extract a few candidate tags. It gives reviewers a useful card as soon as the upload finishes. The catch is that a large upload now has a longer critical path, and a transient worker failure needs a retry that cannot duplicate the approval row.

On-demand processing keeps upload fast. Start a job when a reviewer opens the clip, and cache its result by media version. This fits a library where most videos are never reviewed. It is not suitable when search must be complete immediately after upload, or when support agents expect tags before the first page load. Stick with upload-time metadata in that case, and defer only expensive renditions.

For the customer-support library, I would use a hybrid: validate and tag at upload, generate high-resolution review previews on demand, and never let preview generation change approval state. That boundary keeps the decision auditable. It also means a slow preview cannot make an approved clip disappear from search.

type ProcessingJob = {
  mediaKey: string;
  mediaVersion: number;
  kind: "metadata" | "preview";
};

function dedupeKey(job: ProcessingJob): string {
  return `${job.mediaKey}:${job.mediaVersion}:${job.kind}`;
}

async function onUpload(job: ProcessingJob, enqueue: (key: string, value: ProcessingJob) => Promise<void>) {
  // The queue is idempotent by key; retries cannot create a second tag set.
  await enqueue(dedupeKey(job), job);
}
Enter fullscreen mode Exit fullscreen mode

Measure the queue, not just the request. I track time from upload completion to usable metadata, preview hit rate, rejected transitions, and deletion lag. A 202 response only says the job was accepted; it says nothing about whether a reviewer can act on it yet. That distinction matters in a support queue: an agent who sees a spinner may retry the upload, creating a duplicate object and two approval records. I put the idempotency key in the upload response, persist it beside the media version, and show the agent the same pending record on a refresh. It is a small piece of plumbing, but it prevents a surprisingly expensive clean-up conversation.

Keep the queue visible.

Make download and deletion boring under pressure

Download links should be short-lived and scoped to one object. The API should check tenant, role, approval state, and requested rendition before issuing one. A browser can retry a link; it must not be able to turn a playback URL into an unrestricted archive URL.

Short links expire.

Deletion has two clocks. The business clock marks the record deleted immediately, so retrieval and search stop returning it. The storage clock removes original bytes, derivatives, thumbnails, and queue payloads under the retention policy. Keep a tombstone with the request actor and timestamp so an audit can answer what happened without keeping the media itself.

I would test the ugly paths first: a second review with an old version, a download for rejected, a delete followed by retrieve, and a retry after the worker has already removed the object. Those tests cost minutes and protect the support team from explaining contradictory buttons.

At a few thousand clips, one relational table and one queue are enough. At a much larger library, partition audit events by tenant, put a lifecycle policy on every derived object, and add a reconciliation scan that compares approved rows with searchable documents. Keep the public state machine unchanged while the internals grow. That is the scale change I would make first because it protects the same review contract while storage and search evolve behind it.

There is a real limit here. If legal hold, frame-level comments, or multi-party sign-off are requirements, this small flow is not suitable as-is; use a dedicated review model and keep the media service focused on bytes and authorization. Your mileage may vary on when that boundary arrives. I would rather add one measured component than buy a permanent operations tax on week one.

References

Top comments (0)