ARCHIVED. Written when the Google Sheet was the inventory source; the sheet sync was retired in 0005 and the DB is canonical — see ../architecture/data-model.md and ../architecture/inventory-provenance.md. Nothing here is current — see docs/README.md for the live map.
This is the canonical schema for the rental inventory. It maps directly to the current Google Sheet inventory (one tab, 191 data rows, 208 physical assets as of 2026-05-23) and adds the operational columns the rental system needs (rates, restrictions, operational state).
The schema is split into:
- Items — the catalog of things we own.
- Assets — physical copies of an item, expanded out from the
sheet's
Quantitycolumn. (The table was namedunitsin the0001baseline and renamed toassetsin0002_assets_spaces_settings.sql.) - Bundles — pre-defined groupings, modeled as a parent
itemsrow plus abundle_itemsjoin to its componentitems(greenfield; not in the sheet today). A vestigialkitstable also ships — see §3. - Settings — system-wide constants, including the FX-rate snapshot.
The split matters: a "Sony FX3" is one Item; if we own three of them, they are three Assets, each with their own condition, location, and service history.
Sheet → schema column mapping
Status: inventory is DB-canonical now. The automated importer (
scripts/import-inventory.ts) was removed in migration0005along with the rest of the sheet-sync subsystem (see06-sync-architecture.md). This mapping is kept as the emergency re-import reference — the spec for rebuildingitems+assetsfrom the sheet by hand if the DB is ever lost. The present-tense "importer" descriptions below document how that removed script behaved, and how a supervised manual recovery should replicate it.
The Google Sheet Inventory tab was the original source of record for the
gear we already own; its CSV export resolves columns by header — note the
leading space in Model (normalized away) and the misspelling of Reciept.
Identity is the sheet's UUID column (O), not make/model. Each sheet
row is its own Item. Column O carries items.id; the importer
INSERT … ON CONFLICT (id) DO UPDATE upserts on that UUID, so a re-run
with unchanged data is a no-op. Rows with no UUID are new: Postgres mints
an items.id and the importer writes it back to column O inside the same
transaction. There is no (make, mpn) uniqueness on import; rows that
look like duplicates of the same make/model are deliberately distinct
Items (and may share a generated sku, which is not unique on
items at baseline — 0002 later added UNIQUE(sku); see §3).
Assets (the units/assets table) seed only on first import for a
given Item: if the Item already has any asset rows, the importer assumes
admin owns asset state and skips them.
| Sheet column | Maps to | Loader behavior |
|---|---|---|
A Model | items.name | Trimmed (the leading-space header is normalized; the value is trimmed). Required — blank Model rows are reported and skipped. |
B Make | items.manufacturer | Trimmed; (generic) → null. |
C Reference | items.mpn | Manufacturer part number (e.g. SEL50F12GM). Empty → null. Stored only; not used as an upsert key. |
D Serial Number | assets.serial | First expanded asset only — when Quantity > 1, only asset #1 takes the serial; the rest get null. |
E Category | items.category | Validated against the enum (see §4); an unknown value is reported and the row skipped. computer, workstation, phone import with reservable_online = false (written directly as bookable_online at import; see note). |
F Description | items.summary_en | Short tagline. Empty → null. |
G Included Accessories | items.included_accessories (text[]) | Split on ,, trim each, drop empties. |
H Remarks | assets.notes_internal | Per-asset free-text (e.g. 742 hours, water damage), joined with the receipt token (see N) as remarks | receipt:…. |
I Quantity | (loader directive) | Blank → 1. > 1 expands to N asset rows; = 0 creates one asset forced to condition = 'retired'. Non-integer/negative → reported and skipped. |
J Approximate Value | assets.acquired_cost_usd_cents only | This sheet value is the purchase price the owner paid, NOT a replacement value. It feeds the per-asset acquired cost and nothing else; the importer does not write items.replacement_value_usd_cents from it. Strip $, ,, whitespace; ?/blank → null; otherwise × 100. (The real, admin-maintained replacement value lives in items.replacement_value_usd_cents — see that row and ../operations/replacement-prices.md.) |
K Approximate Purchase Date | assets.acquired_at | Parse YYYY-Mon → first of month. Empty or unparseable → null (unparseable is also reported). |
L Location | assets.location | Airport-code physical location. Upper-cased; LAS/MDE pass through, anything else → other. Default (blank) → other. |
M Condition | assets.condition | new → like_new, normal wear → good, used → fair; unknown/blank → good (and reported). Quantity = 0 rows override to retired. |
N Reciept | assets.notes_internal (appended) | Receipt-on-file flag preserved as a receipt:<value> token appended to Remarks; no dedicated column. |
O UUID | items.id | Stable per-row key. Empty on new rows; the importer generates the id and writes it back here. |
The importer only writes these
itemscolumns:sku,name,manufacturer,mpn,category,summary_en,included_accessories, andbookable_online(the pre-0002name forreservable_online; the script predates the rename and still targets the old column name).
Fields with no source in the sheet (default or backfill required):
items.name_es, items.summary_es, items.description_*,
items.specs, items.cover_image, items.gallery, items.review_images,
items.tags, items.not_included, items.replacement_value_usd_cents
(+ items.replacement_value_updated_at), items.rate_day_usd_cents and
friends, items.requires_operator,
items.lead_time_days. items.sku is generated by the importer
(slugified make-model, with the manufacturer prefix dropped when the
model already starts with it). items.serialized is backfilled by a
migration (true when any asset of the SKU carries a serial), not by the
importer. Rental rates are the largest open item —
see 05-open-questions.md; replacement values are backfilled via
../operations/replacement-prices.md.
1. Items
A model/SKU level entity. One row per gear type, regardless of how many assets we own.
| field | type | required | notes |
|---|---|---|---|
id | uuid | yes | system-generated; equals the sheet's UUID column (O) |
sku | text | yes | short human code, e.g. sony-fx3. Stable, used in catalog URLs. Generated by the importer (slugified make-model, manufacturer prefix dropped when the model already starts with it); editable later. UNIQUE(sku) enforced as of 0002. |
name | text | yes | from sheet Model, e.g. "Sony FX3" |
name_es | text | no | Spanish name (defaults to name at render time) |
manufacturer | text | no | from sheet Make |
mpn | text | no | manufacturer part number, from sheet Reference |
category | rental_category enum | yes | see §4 |
tags | text[] | yes | searchable tags; default '{}' |
summary_en | text | no | one-line description (from sheet Description) |
summary_es | text | no | one-line description, ES |
description_en | text | no | longer marketing copy (markdown) |
description_es | text | no | longer marketing copy, ES |
specs | jsonb | yes | structured specs ({"sensor": "Full-frame", "mount": "E"}); default '{}' |
cover_image | text | no | hero image URL |
gallery | text[] | yes | extra images; default '{}' |
review_images | text[] | yes | detail-page "in use" feature images; default '{}' |
replacement_value_usd_cents | int | no | Admin-maintained real replacement (re-purchase) value in USD cents — what it would cost to buy the item new today. Drives rental deposits (the 100% replacement-value deposit is the sole loss-and-damage protection — there is no insurance or COI). Not written by the importer (the sheet's "Approximate Value" is the purchase price and feeds only assets.acquired_cost_usd_cents); the two can differ a lot. NULL until researched. Populate/refresh via the runbook: ../operations/replacement-prices.md. |
replacement_value_updated_at | timestamptz | no | When a real replacement_value_usd_cents was last set, stamped to now() by the apply helper. NULL = never researched; an old value flags a possibly-stale price to re-check. |
replacement_value_cop_cents | bigint | no | legacy/optional COP display value (kept from the baseline; COP is normally derived on the fly from settings.cop_per_usd_snapshot × replacement_value_usd_cents). |
rate_day_usd_cents | int | no | day-rate in USD cents — TBD across the catalog, see 05-open-questions.md |
rate_week_usd_cents | int | no | weekly rate (else multiplier from settings) |
rate_weekend_usd_cents | int | no | weekend rate (else day-rate) |
requires_operator | bool | yes | default false; e.g. drones. Not set by the importer. |
reservable_online | bool | yes | default true. Shown in the public catalog when true (subject to availability). Renamed from bookable_online in 0002. The importer sets it false for computer, workstation, phone (production-only by default) and true otherwise. |
availability | item_availability enum | yes | draft / available / private / sold / damaged; default available. The public catalog shows only available and sold (see src/lib/rentals.ts). |
published_at | timestamptz | yes | default now(). A future timestamp renders the listing as "coming soon"; past/now = live. |
serialized | bool | yes | default false. When true, every asset is expected to carry a serial and checkout must require one. Backfilled by 0002 from "does any asset of this SKU have a serial?"; refine per-item later. Enforcement lives in app code, not the schema. |
lead_time_days | int | no | min days notice required |
included_accessories | text[] | yes | what ships with every rental (from sheet Included Accessories, comma-split); default '{}' |
not_included | text[] | yes | clarifies what's separate; default '{}' |
notes_internal | text | no | desk-only notes |
created_at | timestamptz | yes | default now() |
updated_at | timestamptz | yes | auto-bumped on update |
There is no
items.slugcolumn — the catalog and detail routes key onsku. (Onlykitsandspacescarry aslug.) There is also nopublishedorproduction_lockedboolean; public visibility is governed byavailability+published_at+reservable_online.
Uniqueness: UNIQUE(sku) (items_sku_uniq, added in 0002 after a
de-dupe pass). The 0001 baseline had no unique constraint on
items — duplicate-SKU rows were expected under the legacy
"one items row per physical unit" model.
Indexes (baseline + 0002):
items_sku_idxonsku.items_availability_idxonavailability.items_published_at_idxonpublished_at.items_make_mpn_idxon(lower(manufacturer), lower(mpn))wherempn is not null.items_make_name_idxon(lower(manufacturer), lower(name)).
2. Assets
Physical copies of an Item. One row per copy. The table was named units
in the 0001 baseline and renamed to assets in 0002 (along with the
FK columns that referenced it: reservation_items.assigned_unit_ids →
assigned_asset_ids, item_blackouts.unit_id → asset_id).
| field | type | required | notes |
|---|---|---|---|
id | uuid | yes | |
item_id | uuid | yes | FK → items, ON DELETE RESTRICT |
serial | text | no | manufacturer serial; sparsely populated in the sheet, and on a multi-quantity row only the first expanded asset takes it |
location | location enum | yes | LAS / MDE / other. Default MDE. Current airport-code physical location, derived from sheet Location. LAS assets are private staging — never publicly rentable. |
acquired_at | date | no | from sheet Approximate Purchase Date, parsed as first of month |
acquired_cost_usd_cents | int | no | per-asset acquired/purchase cost in USD cents, from sheet Approximate Value |
condition | rental_condition enum | yes | like_new / good / fair / service / retired / lost. Default good. (lost = lost or stolen; non-serviceable.) |
condition_notes | text | no | cosmetic notes |
current_reservation_id | uuid | no | FK → reservations, ON DELETE SET NULL; set when checked out |
serviced_at | date | no | last service date |
notes_internal | text | no | from sheet Remarks plus appended receipt: token |
created_at | timestamptz | yes | |
updated_at | timestamptz | yes |
The baseline
unitstable also had anasset_tagcolumn (with a partial unique index) for an internal sticker/barcode. It was dropped in0002. Physical QR stickers now live in theshort_linkstable: a row keyed byshort_links.asset_idmaps aqr.studio.chat/<code>short code to the asset. Assets carry no tag/code column of their own; the office UI surfaces an asset's sticker code by joiningshort_links(seesrc/lib/office/inventory.ts).
Invariants:
- An Asset on
location = 'MDE'withcondition∈ {like_new,good,fair} is publicly rentable (subject to the Item'savailability/reservable_online). Whether it's currently out on rental is resolved at query time from the reservations table. Inferred design — not yet built; confirm. - An Asset with
current_reservation_idset is on an active reservation. condition = 'retired'is permanent; the row stays for audit.location = 'LAS'is private staging and never surfaces in the public catalog.
The baseline shipped a
published_unitsview (MDE + rentable condition + bookable, published Item) for the public-catalog read. It was dropped in0002as dead code — it had no callers. The public catalog now readsitemsdirectly via the service role and filters onavailability+published_at(src/lib/rentals.ts); it does not currently join asset/location state.
Acquisition / import-tax bookkeeping. The Colombian Amazon import
threshold (>$200 triggers per-shipment customs duty) is tracked in
assets.notes_internal as a free-text annotation for now — e.g.
amazon-import:2024-09 duty $42. We'll formalize a separate table only
when volume justifies it. Inferred design — not yet built; confirm.
Indexes: the baseline ships only assets_pkey. The
(item_id, condition) and (location, condition) dashboard indexes
described in earlier drafts were never created — add them if the
desk views need them.
3. Bundles
Pre-defined groupings sold at a bundle rate. No source data in the sheet — bundle definitions are greenfield.
The shipped bundle model (added in 0002) is a parent items row
with no Assets of its own, joined to its component items via the
bundle_items table. Availability derives from the components. (Example
seeded in 0002: a dzofilm-vespid2-prime-set parent whose six prime
lenses are the children.)
bundle_items:
| field | type | required | notes |
|---|---|---|---|
id | uuid | yes | default gen_random_uuid() |
bundle_item_id | uuid | yes | FK → items (the parent/bundle row), ON DELETE CASCADE |
child_item_id | uuid | yes | FK → items (a component), ON DELETE RESTRICT |
required | bool | yes | default true |
ord | smallint | yes | display order; default 0 |
created_at | timestamptz | yes | default now() |
Duplicate child rows are allowed (e.g. "2 batteries" = two rows), so each
slot carries its own required and ord. A bundle_items_no_self_loop
check forbids bundle_item_id = child_item_id. Indexed on
bundle_item_id and on child_item_id.
A bundle is "available" iff every required component has an available Asset on the requested dates. Inferred design — not yet built; confirm.
Vestigial
kitstable. The0001baseline also defines a separatekitstable (id,sku,slug,name_en,name_es,description_*,item_linesjsonb default'[]',rate_day_usd_cents,rate_week_usd_cents,replacement_value_usd_centsdefault 0,publisheddefault false,cover_image,gallery, timestamps; itstiercolumn was dropped in migration0005), withreservation_items.kit_idFK still pointing at it. This is the older jsonb-line bundle design that thebundle_itemsjoin superseded; it still ships (uniquesku/slugindexes, audit + updated-at triggers) but holds no data and is not the model new bundles should use. Treat it as legacy until it's removed.
4. Categories
Locked enum — matches the actual taxonomy in the sheet today. Use
tags for finer slicing.
camera body
camera body accessory
camera body stabilizer
camera lens
camera lens accessory
camera lens filter
camera monitor
camera monitor accessory
camera tripod
camera tripod accessory
light
light accessory
light modifier
light stand
light trigger
audio recorder
audio monitor
microphone
timecode generator
timecode generator accessory
video monitor
grip
battery
digital storage
digital storage accessory
storage
computer
workstation
phone
Import-time reservable_online by category:
computer,workstation,phone→false(production-only by default).- Everything else →
true.
These are written by the importer per row (against the bookable_online
column it still targets — see §1); the DB column default is simply
true. Admins can override per item afterward.
5. Settings
An append-only revision history (since 0002): each save inserts a
new row, and "current settings" is the row with the newest created_at.
The 0001 baseline modeled this as a mutated singleton (id = 1,
updated_at); 0002 swapped the singleton for per-revision rows
(uuid PK, provenance columns, no updated_at) and indexed
(created_at desc) for the current-settings read.
| field | type | notes |
|---|---|---|
id | uuid | per-revision PK; default gen_random_uuid() (was int = 1 at baseline) |
created_at | timestamptz | default now(); the newest row is the live settings |
created_by_user_id | uuid | FK → auth.users, ON DELETE SET NULL; who saved this revision |
note | text | free-text "why this change" |
cop_per_usd_snapshot | numeric(10,2) | editable FX rate; used to derive COP display values |
week_multiplier | numeric(4,2) | TBD |
weekend_multiplier | numeric(4,2) | TBD |
half_day_multiplier | numeric(4,2) | TBD |
late_grace_minutes | int | TBD |
late_per_day_factor | numeric(4,2) | TBD |
deposit_percent | numeric(4,2) | 1.00 (100% of replacement value) |
deposit_minimum_usd_cents | int | minimum deposit floor (baseline default was 50000 = $500) |
inventory_canonical_source | inventory_source enum | sheet or db — which side wins on sync (baseline default sheet) |
day_rate_floor_usd_cents | int | minimum day rate / line charge (covers checkout labor on low-value items) |
day_rate_round_usd_cents | int | rounding granularity for computed day rates |
iva_rate | numeric(4,3) | Colombian IVA applied to the quote total (baseline default 0.190) |
pricing_classes | jsonb | cost-recovery params per equipment class (life_years, residual_pct, utilization, maintenance/insurance/overhead/margin pct); category→class map in src/lib/office/pricing.ts |
0002dropped DEFAULTs on the data columns above (so a partial save fails loudly instead of silently using a stale baseline number), and dropped four baseline columns entirely:updated_at,cop_per_usd_updated_at(created_atcarries both now), and the never-enforcedfirst_time_renter_cap_usd_centsandblackout_default_daysplaceholders. There is noproduction_lockout_default_dayscolumn — that was a doc-only invention.
Currency notes:
- USD cents is the canonical store for every monetary field across items, assets, kits, reservations, and payments.
- COP is a display conversion computed from
settings.cop_per_usd_snapshotat the moment of quote or render. We snapshot the rate ontoreservations.fx_rate_snapshotat quote time so invoices stay reproducible. items.replacement_value_cop_cents(bigint) is a legacy/optional cache carried from the baseline; normally the UI computes COP on the fly from the USD value and the FX snapshot.
6. Audit log
Writes to items, assets, kits, and settings emit a row in
audit_log via per-table Postgres triggers (fn_emit_audit), not
application code, so anything that mutates those tables — admin UI,
importer, SQL console — is captured. The trigger fires on
INSERT/UPDATE/DELETE and records created/updated/deleted with the
full row payload (and the actor from app.actor_user_id when set). The
audit_log table is defined in 01-architecture.md.
Not every table carries this trigger.
bundle_items,short_links, andshort_link_scans(all added in0002) have RLS auto-enabled but no audit trigger; the soft-delete tables (reservations,customers,short_links) are audited explicitly by app code (writeAudit,source = 'user') rather than by a generic trigger.
7. Row-level security
Every table here has RLS enabled — the auto-applied ensure_rls event
trigger at CREATE TABLE turns it on for every new public table. Most
have no policy (so they are service-role-only); a few add an explicit
USING (false) admin-only policy (items, kits, settings,
assets/units, audit_log, sync_events, sync_errors). The public
catalog reads via the service role server-side (Next.js server
components); there's no anon read surface.
Tags (open-ended, free-text)
Standard tag conventions:
- mount:
pl-mount,e-mount,ef-mount,m4/3-mount - format:
super35,full-frame,medium-format - characteristics:
low-light,slow-mo,anamorphic,silent(no fan),daylight,tungsten - use case:
interview,event,narrative,bts,social - audio:
wireless,lavalier,boom,usb-c - source:
ours,subrental
Tags are not enumerated in the DB — they're plain text[].
Open data questions (still TBD)
These remain after the sheet import:
- Rental rates — the sheet has zero rate data. We need
rate_day_usd_centsper item before we can quote. See05-open-questions.md §pricing. - Bundle definitions — the sheet has no bundle data. Author the first
3–5 bundles manually via
bundle_items(the seededdzofilm-vespid2-prime-setis the only example so far). - Bilingual copy — sheet is English-only; ES summary/description need to be authored.
- Subrentals — the sheet implies we only have owned gear. If we start sub-renting from peers, we'd add a source flag + a vendor link (no such columns today).
- Per-asset pricing (e.g. a "B-cam" asset cheaper than the pristine
A asset). Not supported —
assetshas no rate columns; rates live only onitems. Would need new columns if pursued. - Receipt-on-file flag (
Recieptcolumn) — currently stuffed intonotes_internalas areceipt:<value>token. If we need to query/report on it, add a dedicatedreceipt_on_filebool.
See 05-open-questions.md for the full operational/policy list.