# Shootlo engineering notes

- **Status:** in development · bookings open across India · pilot planned for Mysuru during Dasara (11–21 October 2026)
- **Last updated:** 27 September 2026
- **Stack:** Next.js 16 (web) · Spring Boot 4 / Java 21 (API) · PostgreSQL · Google Cloud Run

Shootlo is TheRare Visuals' second product: book a verified photographer or videographer
near you, for the same day. This page is the working engineering document: the problems
we hit while building it, and how each one is solved today. It is updated as the product
changes, so anything marked *open* is genuinely still open.

---

## The shape of the problem

A same-day marketplace is three systems pretending to be one:

- a **matching** system that finds a person who can be somewhere within the hour,
- a **money** system that holds a customer's payment until the work is delivered,
- a **trust** system that lets two strangers meet safely and agree on what happened.

Every hard bug we've had so far lives in one of those three, or in the seam between them.

---

## 1. Matching when supply is scarce

**Problem.** Food delivery works because riders are everywhere. Photographers are not.
Broadcasting a job to everyone online produces a stampede for good jobs, silence for the
rest, and photographers who learn to ignore notifications.

**Solution.**

- Filter first: online, verified, offers that shoot type, and the place is inside their
  travel radius.
- Rank deterministically, no model in the loop: road ETA 40, rating and acceptance 24,
  fairness (time since last job) 20, customer preferences 16.
- Offer in waves of three with a 60-second window, up to five waves. A scheduled task in
  the API expires offers and sends the next wave.
- Accepting is one conditional update, so the race resolves in the database:

```sql
UPDATE bookings
   SET photographer_id = ?, status = 'ACCEPTED'
 WHERE id = ? AND status = 'MATCHING';   -- 1 row wins, everyone else gets 0
```

- Bookings are open anywhere in India. "Now" is offered only inside the inner ring of a city
  with a local team; everywhere else is later today or another day. The customer pays only
  after a photographer accepts, so a booking nobody takes costs them nothing.
- Photographers who travel choose **where they're working**. For later shoots, distance is
  measured from that place; for "now" shoots, from a GPS fix under 15 minutes old (falling
  back to the chosen place). A photographer going to another state next week can set it
  before they leave.
- If every wave lapses, the booking is flagged (`dispatch_exhausted`) and logged at WARN
  so the ops team assigns it by hand or refunds. A dead end is never silent.

**Why not a queue or a matching engine.** At pilot volume the whole thing is a few
hundred rows a day. A timer and a conditional update are easier to reason about than a
distributed scheduler, and they fail in ways we can debug from a log line.

::: widget dispatch-waves


---

## 2. Money that must never get stuck

**Problem.** The payment window gets closed halfway. The train enters a tunnel. The
callback never reaches the browser, but the money has left the customer's account.

**Solution.**

- The booking is created **unpaid** (`REQUESTED`). No photographer is contacted until the
  payment is confirmed, so we never promise work we haven't been paid for.
- Two paths lead to the same state: the browser's confirm call and Razorpay's webhook.
  Both verify a signature (the webhook checks the **raw request bytes**, not the parsed
  JSON), and both are idempotent, so whichever arrives second changes nothing.
- Amounts always come from the server's own quote. A browser-supplied amount is never
  trusted.
- Unpaid bookings expire after 30 minutes, so an abandoned checkout doesn't hold a slot.

**Paying photographers.** Weekly bank transfers, not a payment-gateway payout yet:

- The photographer enters their bank account or UPI ID; ops checks it once against the
  bank proof from their application. Account numbers are encrypted at rest (AES-256-GCM),
  and changing them sends the account back for checking. Ops is emailed when details
  change, and the photographer is emailed when they're verified or sent back.
- A shoot becomes payable when the customer confirms delivery, or 48 hours after delivery
  if they say nothing. A problem the customer reports holds it until ops marks it sorted.
- Ops pays from a weekly list (or a bank bulk-upload file) and records each transfer's
  reference number. Recording checks the amount is still the one ops saw, so a late tip
  can't slip through unpaid.

---

## 3. Cancellations, refunds and no-shows

**Problem.** "Can I cancel?" has a different answer every ten minutes of the booking's
life, and the answer has to be identical in the app, in the API, and in what support
tells the customer on the phone.

**Solution.** One policy object, called from every path:

| When | Refund |
| --- | --- |
| Before payment | Nothing charged |
| Matching or confirmed | Full refund |
| Photographer on the way | Full refund minus a ₹299 travel fee |
| Photographer has arrived | Not cancellable in the app |
| Customer no-show (confirmed) | No refund |

The customer screen shows the exact amount before confirming. Ops can override with
`FULL`, `POLICY` or `NONE`, which is recorded with the reason. Refunds are requested
after the database transition, so a payment-provider outage leaves a visible
"paid, cancelled, refund pending" state rather than a lost cancellation.

::: widget refund-policy


---

## 4. Location from a browser

**Problem.** The photographer app is a web page for the pilot. Browser geolocation is
noisy, phones suspend background tabs, and a live location is sensitive data.

**Solution.**

- Every ping is suspect until checked: drop readings worse than 150 m accuracy, drop
  anything implying more than 150 km/h since the last fix, and store a trail point only
  every 25 m or 30 s.
- Cadence follows the job: every 60 s while idle and online (dispatch needs to know who
  is nearby), every 10 s while on the way (the customer's map should move).
- Privacy window: a customer can see the photographer's position only between *on the
  way* and *arrived*. Before and after, the tracking endpoint returns no position.
  Trails are deleted after 30 days.
- A screen wake lock is held while travelling, and the page says plainly that it must
  stay open.
- ETA comes from the Google Routes API (two-wheeler, traffic aware), cached for 60 s or
  300 m of movement, with a straight-line estimate as the fallback.

::: widget ping-filter


**iPhone.** Safari only shows the location prompt when the request comes straight from a
tap, so "Use my current location" calls the browser before any `await`, retries once at
low accuracy if GPS is slow, and explains how to switch location back on if it was denied.

**Open.** A React Native app with background location, which removes the "keep this page
open" compromise entirely.

---

## 5. Trust between two strangers

**Problem.** A stranger is going to meet another stranger in a public place, and money
depends on what happens there.

**Solution.**

- One state machine, no shortcuts:
  `REQUESTED → MATCHING → ACCEPTED → EN_ROUTE → ARRIVED → SHOOTING → PREVIEWS_SENT →
  DELIVERED → PAID_OUT`, with exits `CANCELLED`, `NO_SHOW`, `UNMATCHED`, `DISPUTED`.
  Every transition is recorded with who did it and when.
- The shoot only starts when the photographer types the customer's 4-digit code, so the
  paid clock can't start from a car park.
- A no-show needs proof of waiting: arrived, then 15 minutes, then the button appears.
  Timestamps decide, not whoever messages support first.
- Booking ids are guessable, so a booking answers only to its customer, its assigned
  photographer, and ops. Identity documents live in a separate private bucket behind
  links that expire in minutes.

---

## 6. Identity without passwords

**Problem.** Three kinds of people use the product: customers, photographers and the ops
team. None of them should have a password we store.

**Solution.** Firebase Auth issues the token; the API verifies it and derives the role:

- phone sign-in → `CUSTOMER`
- phone that matches an approved photographer → also `PARTNER`
- verified Google address on the ops allow-list → `OPS`

The web app never talks to the API directly: the browser calls `/backend/*` on the
Next.js server, which forwards to the API. In local development that proxy signs
requests as test users, so nothing sensitive reaches the browser and the whole flow
works without a Firebase project.

---

## 7. Background work on a serverless runtime

**Problem.** Offers expire on a timer, unpaid bookings expire, photographers go offline
when they stop reporting. Cloud Run throttles CPU between requests, so a plain
`@Scheduled` task simply stops running when the service is idle.

**Solution.** The timer moved out of the app. Cloud Scheduler calls
`/api/v1/internal/tick` every minute and `/api/v1/internal/daily` at night; the same code
runs either way, and locally a plain in-app timer drives it. The endpoints sit behind Cloud
Run IAM and a shared secret, and every step claims its work with a conditional update, so a
retry or an overlap is harmless.

That one change means no instance has to stay awake, which is most of the hosting bill at
pilot size. The API is still capped at one instance for now, because the booking assistant
keeps chat history in memory. Moving that to the database is what unlocks scaling out.

---

## 8. Delivering the photographs

**Problem.** The product ends at the photographs, and for a long time it did not: the
photographer pasted a Google Photos or Drive link and the customer chased it. That link can
be changed, revoked or shared with anyone, we cannot tell whether the delivery actually
happened, and a customer who is not signed in to the right Google account simply sees a
permission wall. The one part of the shoot people will keep for years was the part we did
not control.

**Solution.** Shootlo hosts the files itself, in a third private bucket kept apart from
portfolios and identity documents.

- The photographer's browser uploads straight to Cloud Storage with a signed URL, one file
  at a time. Nothing large passes through the API, and a dropped connection on mobile data
  costs one file rather than the whole batch.
- Every upload link is written down first as a pending ticket. When the browser reports the
  file has landed, the API checks the object is really in the bucket before it records a
  row, so a row never promises a file that is not there. Tickets nobody used are swept
  nightly, which is what stops orphaned objects accumulating.
- Customers read the gallery through links that are signed for a few hours, so nothing is
  public and no link outlives the page it was opened on. Downloads are signed with a
  content disposition, so files save under their own names.
- There is no server-side zip. "Download all" fetches the files one after another from the
  browser, which costs us no CPU and no temporary storage.
- Galleries are deleted 90 days after delivery, and the customer's screen says the date. A
  bucket lifecycle rule repeats the same deadline, so the files go even if the nightly task
  stops running.

Pasting a link still works, and stays the fallback for a photographer with a hundred RAW
files or a flaky connection. Previews are optional: a photographer can finish a shoot and
send everything at delivery.

**Delivered is not the end.** The customer is asked "Got all your photos?", with *Yes, I got
everything* or *Something's missing* and a note. Until they confirm, the photographer can
still add or change files. A confirmation makes the payout due at once; a reported problem
emails ops and the photographer and holds the payout; silence releases it after 48 hours.
Ops sees previews, final files and the customer's answer in the booking tracker.

---

## 9. Growing past one city

**Problem.** The first version hard-coded one city: a centre and three rings. Opening a second
city, pricing by state, and giving a local manager access to only their bookings all needed
the city to be data, not code.

**Solution.**

- Cities are rows: a centre, three rings and a switch for "now" bookings. Ops adds them from
  a map. Every booking stores its city and state when it's created.
- Prices can be overridden per state, and applied in the one place quotes are calculated,
  so the charge, the photographer's payout, the fee and GST all agree.
- Popular spots have a limit on shoots at the same time. When one is full, the customer is
  offered the next free slot. The check runs under a per-place lock, and again at accept.
- Staff get a role (manager, customer support, marketing) and optionally a state or city.
  Permissions are checked on every endpoint, and a booking outside someone's area answers
  404, as if it doesn't exist. Every change to money, prices, cities or people goes into
  an append-only audit log.

---

## 10. Telling people what happened

**Problem.** A marketplace is mostly waiting: for review, for payment, for files. Silence
reads as "nothing is happening".

**Solution.**

- Emails go out through an email provider's HTTPS API (never SMTP on Cloud Run; see
  [Lessons from production](/engineering/lessons-from-production)): application decisions
  with the reason, staff invites, payout details checked, transfers sent, delivery confirmed
  or a problem reported.
- Every message is logged with its status (sent, failed, not sent), so "did they get it?"
  is a lookup, not a guess. Without an API key nothing breaks; messages are logged as not sent.
- Ops can send announcements to photographers or customers, all India or one state, sent in
  small batches with no duplicates, so a refresh halfway can't email anyone twice.
- A photographer can be paused with a reason (they're emailed, and see it when they sign
  in), rejected with a reason, or removed by the owner.

---

## 11. What broke in production

A few failures taught us more than the features did. The full stories are in
[Building Shootlo: what broke](/engineering/building-shootlo-what-broke).

| Symptom | Cause | Fix |
| --- | --- | --- |
| Random 500s right after a migration | The connection pooler kept prepared-statement plans across deploys; `SELECT *` changed shape | `prepareThreshold=0` in the driver |
| Scheduler calls answered 401 | The scheduler's Google token was read as a user sign-in token | Internal paths skip the bearer resolver; IAM plus a shared secret protect them |
| AI application reviewer killed for memory | Full-size phone photos sent to the model as base64 | Decode subsampled, shrink to 1024 px, cap the JVM's share of memory |
| Announcements to photographers failed | `PHOTOGRAPHERS` is 13 characters in a 12-character column | Widened the column; ops screens show the real error |
| New photographers showed ★ 5.0 and 80% | Ranking defaults were displayed as real numbers | "New" until 3 ratings and 5 offers; the photographer sees their progress |

---

## 12. Still open

| Gap | Why it matters | Plan |
| --- | --- | --- |
| Live notifications | Emails cover decisions and money, but a photographer only sees an offer while the page is open | Web push, plus chat between customer and photographer during an active booking; WhatsApp is built and waiting for approval |
| Masked calling | Customer and photographer can't call each other, by design, so bookings don't move off the platform | Number masking once chat is in; a support number meanwhile |
| Automatic payouts | Ops sends weekly bank transfers and records each reference | Payment-gateway payouts once volume justifies it |
| Chat memory | Keeps the API at one instance | Move to the database, then scale out |

---

## System design

```
Customer / photographer / ops browser
        │  HTTPS, managed certificate
        ▼
Cloud Run: web (Next.js)          public
        │  /backend/* proxy, adds a Google identity token
        ▼
Cloud Run: api (Spring Boot)      no public access: IAM invoker only
        │              │                │
        ▼              ▼                ▼
   Postgres       Cloud Storage    Vertex AI · Routes API · Razorpay · Firebase Auth
                  (3 private buckets)

Cloud Scheduler ──▶ /internal/tick  every minute
```

**Principles we keep coming back to**

1. Nothing reaches the API without a Google identity token: only the web app and the
   scheduler have one. Even Razorpay's webhook arrives through the web app's proxy, and the
   API still checks its signature.
2. Deterministic code decides; AI agents only suggest. Dispatch ranking, pricing and
   refunds are plain Java with unit tests. The Gemini agents draft messages, explain a
   ranking and pre-read applications, but a person still presses the button.
3. Boring beats clever at pilot scale: two containers and one Postgres, no queue, no
   cache tier, no load balancer, no Kubernetes. Everything that must survive a restart is a
   row, and the whole thing runs for a low four-figure rupee bill each month.
   Each piece of infrastructure we might add later (a load balancer with a WAF, Pub/Sub, Redis
   for live positions, a read replica) has a number written down that has to be crossed first.
4. Settings that differ between laptop and production are one switch each
   (`auth`, `payments`, `storage`), and production refuses to start if any of them is
   still on the local option or a secret is missing.

**Data, roughly.** `bookings` (+ `booking_events` for the audit trail), `payments` and
`refunds`, `dispatch_offers`, `gallery_items` with `pending_uploads`, `photographers` with
`photographer_documents` and `portfolio_items`, `applications`, `location_pings`,
`agent_runs`, `cities` and `state_prices`, `payout_accounts` and `payouts`, `staff`,
`messages` and `announcements`, `audit_log`.

**Testing.** The rules that cost money or trust are plain classes with unit tests:
pricing, dispatch ranking, cancellation policy, Razorpay signatures, upload rules,
location filters, role derivation, production-readiness checks, service areas and the
dispatch point, payout rules (when a shoot is due, held or paid), bank-detail validation,
image shrinking and spot rules. Everything else is
verified by a full click-through: book, pay, accept, travel, start, upload previews,
deliver the gallery, download, rate, cancel, refund.

---

## Changelog

- **27 Sep 2026**: customers confirm delivery (or report something missing); photographers
  can keep adding files until they do, and the payout follows the answer. Ops tracker shows
  previews and final files, and every status is clickable.
- **27 Sep 2026**: photographers are paid by weekly bank transfer; email for applications,
  payouts and deliveries; announcements; staff roles by state and city; pause, reject with a
  reason, remove. Fixed prepared-statement errors behind the connection pooler.
- **27 Sep 2026**: bookings open across India; photographers choose where they're working.
- **26 Sep 2026**: cities, state prices, shoot limits per spot, booking tracker and audit log;
  customers can book at their own location.

- **20 Sep 2026**: hosted galleries: photographers upload the photographs to Shootlo and
  customers download them without an account, kept for 90 days.
- **20 Sep 2026**: background timer moved to Cloud Scheduler; deployment simplified to Cloud Run
  with domain mapping, no load balancer.
- **20 Sep 2026**: first version of this document.
- **16 Sep 2026**: sign-in, payments, offer waves, tracking, uploads and the ops panel
  landed; deployment runbook written.
