A Batch Endpoint Designed for Someone Syncing 100 Decisions From a Train
POST /decisions accepts either one verdict or an array of them, through the same handler and the same validation. The batch form exists so a review session with no signal doesn't turn into a hundred requests.
Going through a photo library is the kind of task that fills dead time — waiting rooms, commutes, a train with no signal for forty minutes. The Android client's offline queue is built for exactly that: swipe through a hundred photos with no connection, and have all hundred verdicts land on the server the moment the connection comes back. The endpoint that receives them was designed for this from the start, not bolted on afterward.
A file that left the library between the deck loading and reconnecting is 'skipped', not a failed request.
One controller method, two shapes of input
public function record(?int $fileId = null, ?string $verdict = null, ?array $verdicts = null): DataResponse {
$userId = $this->userId();
if ($verdicts !== null) {
return new DataResponse($this->decisionService->recordMany($userId, $verdicts));
}
if ($fileId === null || $verdict === null) {
throw new OCSBadRequestException('Provide either fileId and verdict, or a verdicts array');
}
// ... single-verdict path
}recordMany() isn't a special bulk-insert path with different validation — it's a loop over the exact same record() method the single-verdict form calls, one call per entry:
public function recordMany(string $userId, array $verdicts): array {
$recorded = 0;
$skipped = [];
foreach ($verdicts as $entry) {
$fileId = (int)($entry['fileId'] ?? 0);
$verdict = (string)($entry['verdict'] ?? '');
try {
$this->record($userId, $fileId, $verdict);
$recorded++;
} catch (\Throwable $e) {
// A file that left the library between the deck loading and the
// phone coming back online is expected, not an error.
$skipped[] = $fileId;
}
}
return ['recorded' => $recorded, 'skipped' => $skipped];
}That reuse matters more than it looks. There's no second code path to keep in sync with the first, no batch-specific bug class waiting to diverge from the single-item behavior. A verdict recorded through the batch form goes through identical validation, identical upsert semantics, identical handling of an unknown file id — because it's literally the same function call, just made a hundred times in a loop instead of once.
Partial failure is the expected case, not an edge case
The comment on the catch block names the actual scenario this handles: between the moment a deck was loaded on the phone and the moment it reconnects and syncs, some of those photos may have been deleted elsewhere — by the web UI, by another device, by the user themselves through Files. A verdict for a file that's no longer in the index isn't a bug in the sync; it's a race that offline-first design guarantees will happen sometimes.
The response shape reflects that directly: {recorded: 97, skipped: [1928, 4471, 5502]}. Ninety-seven verdicts land. Three don't, with their file ids named so the client can drop them from its own queue rather than retrying them forever. Nothing about a partial result treats the whole batch as failed — the caller doesn't have to guess which of a hundred submissions worked from an all-or-nothing status code.
Why this couldn't have been an afterthought
If the single-verdict endpoint had been built first and a batch mode added later under deadline pressure, the natural shortcut would be a bulk SQL insert that skips the per-item validation record() does — and skips its per-item failure handling along with it. That version would either fail the whole batch on one bad entry, or silently accept malformed ones. Neither is acceptable for a client that has to trust its queue got processed correctly enough to clear itself.
Designing the batch form as "the same operation, looped, with per-item try/catch" from the outset is what makes both properties free: correctness matches the single-item path by construction, and partial success is a normal outcome rather than a special case someone has to remember to handle.
Series: Photo Sweep.