Skip to content

Core data model

Core’s schema is small on purpose. It holds who the members are, their points, the one-time codes that move them between touchpoints, the check-ins, and who on staff may use the admin console. Bookings, rewards and campaigns belong to partners and are not stored here.

flowchart LR
  classDef ours fill:#13686C,stroke:#0B4A4D,color:#ffffff
  classDef area fill:#E3F4F3,stroke:#13686C,color:#12191B

  subgraph members["Members"]
    customer["customer"]:::ours
  end
  subgraph access["Access and handoff"]
    qr["qr"]:::area
    check_in["check_in"]:::area
    handoff["handoff"]:::area
  end
  subgraph points["Points ledger"]
    points_account["points_account"]:::area
    points_txn["points_txn"]:::area
    points_entry["points_entry"]:::area
    points_event["points_event"]:::area
  end
  subgraph staffing["Staff access"]
    staff["staff"]:::area
    staff_event["staff_event"]:::area
  end

  customer --> qr --> check_in
  customer --> handoff
  customer --> points_account
  customer --> points_txn --> points_entry
  points_txn --> points_event
  staff --> staff_event
  staff -. "posts adjustments" .-> points_txn

The whole schema at a glance: ten tables, and how they relate.

erDiagram
  CUSTOMER ||--o{ QR : "is issued"
  QR |o--o{ CHECK_IN : "scanned as"
  CUSTOMER ||--o{ HANDOFF : "is issued"
  CUSTOMER ||--o| POINTS_ACCOUNT : "holds"
  CUSTOMER ||--o{ POINTS_TXN : "receives"
  POINTS_TXN ||--|{ POINTS_ENTRY : "balanced by"
  POINTS_ACCOUNT ||--o{ POINTS_ENTRY : "moves"
  POINTS_TXN |o--o| POINTS_EVENT : "announced as"
  STAFF |o--o{ POINTS_TXN : "adjusts"
  STAFF ||--o{ STAFF_EVENT : "audited by"
erDiagram
  CUSTOMER ||--o{ QR : "is issued"
  QR |o--o{ CHECK_IN : "scanned as"
  CUSTOMER |o--o{ CHECK_IN : "checks in"
  CUSTOMER ||--o{ HANDOFF : "is issued"

  CUSTOMER {
    bigint id PK
    varchar keycloak_sub UK "Keycloak subject"
    uuid ext_id UK "the external id partners see"
    varchar display_name "copy of Keycloak"
    varchar email "copy of Keycloak"
    timestamptz keycloak_deleted_at
  }
  QR {
    uuid id PK
    bigint customer_id FK
    bytea token_hash UK "SHA-256 only"
    timestamptz expires_at "issue + 60 s"
    timestamptz used_at "single use"
    varchar used_gate
  }
  CHECK_IN {
    bigint id PK
    uuid qr_id
    bigint customer_id
    varchar gate_id
    varchar result "GRANTED, DENIED"
    varchar reason "EXPIRED, USED, UNKNOWN, MALFORMED"
    varchar source "ONLINE, OFFLINE"
    timestamptz scanned_at
  }
  HANDOFF {
    uuid id PK
    bigint customer_id FK
    varchar audience "BOOKING, WIDGET"
    bytea code_hash UK
    varchar keycloak_sid "for sign-out"
    timestamptz expires_at "issue + 60 s"
    timestamptz used_at "single use"
  }
erDiagram
  CUSTOMER ||--o| POINTS_ACCOUNT : "holds"
  CUSTOMER ||--o{ POINTS_TXN : "receives"
  POINTS_TXN ||--|{ POINTS_ENTRY : "balanced by"
  POINTS_ACCOUNT ||--o{ POINTS_ENTRY : "moves"
  POINTS_TXN |o--o| POINTS_TXN : "refunds"
  POINTS_TXN |o--o| POINTS_EVENT : "announced as"

  POINTS_ACCOUNT {
    bigint id PK
    varchar kind "CUSTOMER, ISSUED, REDEEMED"
    bigint customer_id FK
    bigint balance ">= 0"
    bigint seq
  }
  POINTS_TXN {
    uuid id PK
    varchar kind "CREDIT, DEBIT, REFUND"
    bigint customer_id FK
    varchar client "PARTNER, STAFF, CORE"
    bigint staff_id
    varchar idempotency_key
    varchar reason
    varchar reference
    uuid refund_of FK
    date business_date
  }
  POINTS_ENTRY {
    bigint id PK
    uuid txn_id FK
    bigint account_id FK
    bigint amount
    bigint balance_after
  }
  POINTS_EVENT {
    bigint id PK
    xid8 xid
    varchar type
    bigint customer_id
    uuid txn_id
  }
erDiagram
  STAFF ||--o{ STAFF_EVENT : "audited by"
  STAFF |o--o{ POINTS_TXN : "adjusts"

  STAFF {
    bigint id PK
    varchar email UK
    varchar role "STAFF, ADMIN"
    boolean active
    varchar jumpcloud_sub UK "linked at first sign-in"
    int version "optimistic lock"
  }
  STAFF_EVENT {
    bigint id PK
    bigint staff_id
    varchar action "CREATED, UPDATED, DELETED, LINKED, UNLINKED, BOOTSTRAPPED"
    jsonb changes
    varchar actor_email
  }

Key fields only. Some links are deliberately not foreign keys: audit and check-in history must outlive what they point to.

Table Purpose Notes
customer One row per Keycloak user in realm finns Created by Keycloak sync or on the first app call. ext_id is the external id, the only id given to the engagement partner. Deleted accounts keep their row, marked deleted, so the ledger still adds up.
qr One-time check-in passes 60-second lifetime, single use, stored only as a hash.
check_in Every gate scan, granted or denied source = OFFLINE is reserved for gates that work without a connection.
handoff One-time codes for the booking site and widgets Bound to one audience. Booking codes carry name and email until used; widget codes carry no personal data. Purged an hour after expiry.
points_account Balances One per customer, plus two system accounts, ISSUED and REDEEMED.
points_txn One posting Records who posted (client, staff_id), why (reason, reference) and the idempotency key.
points_entry The two sides of a posting Always two entries summing to zero.
points_event Outbound feed Written in the same transaction as each change; partners poll it in commit order.
staff Who may use the admin console, and with which role Matched by email until first sign-in links the JumpCloud account.
staff_event Audit of staff access changes No foreign key, so history survives a deleted staff member.

Application bugs can’t break these, because PostgreSQL itself refuses the write.

Append-only ledger

Triggers reject any UPDATE or DELETE on transactions and entries. Mistakes are corrected with a new posting, never by editing history.

Every posting balances

A deferred constraint trigger checks at commit that each transaction’s entries sum to zero.

No negative balances

A CHECK keeps every customer balance at or above zero; a debit that would overdraw fails as insufficient_points.

Exactly once

Idempotency keys are unique per client (unique nulls not distinct), single-use codes are consumed by one conditional UPDATE, and a refund can happen only once per transaction.

Internal and confidential. Share partner sections only as partner builds (pnpm build:partner).