Skip to content

Reports and Leases

c0dewhacker edited this page Apr 18, 2026 · 1 revision

Reports and Leases

Two workflows for operations teams sit outside the day-to-day booking flow:

  • Booking reports — a paginated, filterable view of every booking on the system, used for billing back to departments, finding who sat where, or investigating no-shows.
  • Building leases — a lightweight record of the commercial lease on each building, with document attachments (the signed PDF, the landlord certificate, floor-plan source files).

Both are SUPER_ADMIN-only today. There is no user-facing report; if you want to expose a slice of the data to floor managers, you'll need to wire it up.

Booking report

Admin → Reports → Bookings.

Backed by GET /api/v1/bookings/report. Paginated, indexed scan — safe to run over the whole history. Responds with booking rows plus hydrated user, asset, floor, building, and primary-zone.

Filters

Query param Type Effect
from ISO-8601 datetime Lower bound on startsAt. Inclusive.
to ISO-8601 datetime Upper bound on startsAt. Inclusive.
userId string Only this user's bookings.
assetId string Only bookings against this asset.
floorId string Only bookings on assets on this floor.
buildingId string Only bookings in this building. If both floorId and buildingId are set, buildingId wins (later override).
status CONFIRMED | CANCELLED | COMPLETED Single-status filter. Omit for all statuses.
page int ≥ 1 Page number (default 1).
limit int ≥ 1, ≤ 100 Page size (default 20, max 100).

Response shape

{
  "data": [
    {
      "id": "...",
      "startsAt": "2026-04-17T09:00:00.000Z",
      "endsAt":   "2026-04-17T17:00:00.000Z",
      "status":   "CONFIRMED",
      "notes":    "...",
      "user":  { "id": "...", "displayName": "...", "email": "..." },
      "asset": {
        "id": "...", "name": "A-01",
        "floor":      { "id": "...", "name": "L1", "building": { "id": "...", "name": "HQ" } },
        "primaryZone":{ "id": "...", "name": "Window Row" }
      }
    }
  ],
  "meta": { "page": 1, "limit": 20, "total": 1423, "totalPages": 72 }
}

Common uses

  • Monthly utilisation — filter from/to to the month, group by asset.floor.building.name client-side.
  • Finding a booker retrospectively — filter by assetId + from/to. Useful when cleaning up a desk after an incident or tracking lost property.
  • No-show audit — filter status=CANCELLED with a tight from/to; pair with the booking's updatedAt to see when the cancel happened.

There is no CSV export endpoint today. The admin UI pulls the JSON and renders a table; if you need a spreadsheet, paste from the table or hit the API directly (curl … | jq).

Permissions

GET /api/v1/bookings/report requires SUPER_ADMIN. Floor managers and building admins don't see the report menu item and can't hit the endpoint.

Building leases

Admin → Leases (under the selected building, or globally from the admin sidebar). Stored in the BuildingLease table.

A lease is a lightweight record of who rents the building from whom, for how long, at what price, plus a bundle of attached documents. It does not drive any automation in Roomer — nothing expires a booking when a lease ends, no alerting on renewal dates. It's a filing cabinet.

Fields

Field Required? Notes
buildingId ✓ Must reference an existing building.
name ✓ Up to 255 chars. Free-form ("HQ 2024-2027", "Annexe sub-lease").
startDate ✓ ISO-8601 datetime.
endDate ISO-8601 datetime. Omit for open-ended leases.
landlord Up to 255 chars. Free-form counterparty name.
rentAmount Positive number. Interpreted as an amount in currency, no period assumed (store monthly/annual in the notes).
currency 3-letter ISO code. Defaults to AUD.
notes Free-form markdown-ish text.

API summary

Method & path Purpose
GET /api/v1/leases?buildingId= List all leases, optionally filtered by building. Ordered newest startDate first.
POST /api/v1/leases Create a lease.
GET /api/v1/leases/:id Detail view, including the list of attached documents.
PUT /api/v1/leases/:id Partial update. Pass only the fields you want changed.
DELETE /api/v1/leases/:id Delete the lease and all attached documents (DB rows + disk files).
POST /api/v1/leases/:id/documents Upload one document (see below).
GET /api/v1/leases/:id/documents/:docId Stream the document back with Content-Disposition: attachment.
DELETE /api/v1/leases/:id/documents/:docId Remove a document (DB row + disk file).

All lease endpoints require SUPER_ADMIN.

Lease documents

Attachments are stored on disk under Roomer's configured storage directory (STORAGE_DIR, see Production Deployment) in the layout:

<STORAGE_DIR>/leases/<leaseId>/<epoch-millis>-<sanitised-filename>
  • epoch-millis is Date.now() at upload time — guarantees uniqueness even if the same filename is uploaded twice.
  • sanitised-filename replaces anything outside [a-zA-Z0-9._-] with _. The original filename is preserved in LeaseDocument.filename for display and the Content-Disposition header on download.

Allowed file types

The upload accepts only the following. Both the MIME type and the extension are checked, plus a magic-byte sniff via checkFileMagic() which rejects files whose bytes don't match the declared type (so a .pdf with a PNG header comes back as 400 INVALID_FILE_TYPE).

Extension MIME
.pdf application/pdf
.doc application/msword
.docx application/vnd.openxmlformats-officedocument.wordprocessingml.document
.png image/png
.jpg / .jpeg image/jpeg

There's no size limit enforced at the route level — if you want one, set it on the reverse proxy (NGINX client_max_body_size, Traefik body limit middleware).

Delete semantics

  • DELETE /api/v1/leases/:id/documents/:docId removes one document's DB row and its file on disk. A missing file is ignored (won't fail the request).
  • DELETE /api/v1/leases/:id loops through every attached document, unlinks each file from disk (silently skipping already-missing files), and then deletes the lease row. The Prisma cascade on LeaseDocument cleans up the DB-side document rows.

There is no soft-delete. A deleted lease is gone; keep the originals in your DMS if you need an audit trail.

Physical asset tracking (inventory side)

Non-bookable assets (isBookable = false) are tracked for inventory purposes alongside leases in the same admin area. They have:

  • Physical status — one of AVAILABLE, ASSIGNED, MAINTENANCE, RETIRED, DISABLED. Separate from the booking-eligibility bookingStatus used by bookable assets.
  • Purchase date and warranty expiry — purchaseDate, warrantyExpiry timestamps on the asset.
  • Serial number and asset tag — free-text identifiers. assetTag is unique system-wide.
  • Assignment ledger — the AssetAssignment table records temporary check-outs (assignedAt, optional returnedAt, notes, assignedById).

Assignment workflow

Admin → Assets → pick a non-bookable asset → Assign.

Endpoint Purpose
POST /api/v1/assets/:id/assign { userId, notes? }. Creates an AssetAssignment, flips asset.status to ASSIGNED. Rejects with CONFLICT if already ASSIGNED.
POST /api/v1/assets/:id/unassign Closes the open assignment (stamps returnedAt = now), flips asset.status back to AVAILABLE.
GET /api/v1/assets/:id/history Full assignment history ordered newest first.
GET /api/v1/assets/my-checkouts Current user's active check-outs (assets currently assigned to them with returnedAt = null).

SUPER_ADMIN only for the assign/unassign/history endpoints. The self-checkout view is available to any authenticated user.

Note: this is the physical assignment ledger (laptop checked out to Alice until Friday). It's different from AssetUserAssignment — the permanent "this desk belongs to Bob" model for bookable assets, covered in Zones and Assets.

Clone this wiki locally