Skip to content
MobileFull-Stack

OPIC - Nightlife App

OPIC is a nightlife platform: a consumer mobile app for discovering events, offers, and booking tables, paired with a Next.js venue dashboard where venues run their own events, ticket tiers, perks, table layouts, and staff. This build is a rebuild of an already-live predecessor system, migrating its real production data as it goes.

The Problem

Venues had no self-serve way to run events, price ticket tiers, manage table layouts, or track staff and offers; v1 had to work as a content and operations tool with a dormant commerce engine underneath, so venues could build out real inventory before any consumer-facing app or traffic existed. On the consumer side, that inventory already existed with no way for guests to discover, book, or buy against it. The mobile app exists to give guests a front door to it.

My Role

Lead full-stack developer on a two-person team. I own the data layer, the shared API contract, and the web server implementation end to end (schema → contract → procedures → real data), plus the venue dashboard; a second developer builds the mobile app's screens and design system against that same typed contract.

What I Built

  • The Drizzle schema and Postgres domain model for venues, events, ticket tiers, table bookings, orders/tickets/scans, offers/vouchers/coupons, followers, and settlement
  • A typed oRPC contract layer shared by both the web dashboard and the mobile app, so both consume one typed API with no REST/OpenAPI hop for the app itself
  • The venue-facing Next.js dashboard: event CRUD with publish states, multi-tier ticketing, table-layout management, staff roles, and a public venue page
  • Stripe-based ticket checkout, moving from the predecessor's merchant-of-record model toward Stripe Connect destination charges with mandatory venue verification before any priced sale
  • A legacy-data migration importer that moved the predecessor system's real production data into the new schema

Architecture

  1. 1

    Expo Router + Next.js

    Mobile app and venue dashboard

  2. 2

    oRPC contract

    One typed API, shared by both apps

  3. 3

    Better Auth

    Venues as orgs, staff as members

  4. 4

    Drizzle ORM + PostgreSQL

    Venues, events, bookings, tickets

  5. 5

    Stripe Connect

    Ticket payments, venue payouts

A Turborepo monorepo: apps/mobile (Expo Router, React Native, NativeWind) and apps/web (Next.js 16 with Drizzle + Postgres directly, no CMS), sharing packages/contracts (a Zod-typed oRPC contract with no DB access) and packages/db. apps/web/server is the only place holding a database handle and implements the shared contract; both apps call the same procedures through typed clients. Auth is Better Auth's organization plugin, mapping venues to orgs and staff to org members.

Engineering Challenge

A partial-unique-index approach to prevent double-booking a table broke in two ways once the schema grew: two events on the same table on the same night could still collide, and a booking with no event attached never registers as a collision under a standard unique index, silently offering zero protection. Nightlife also runs across midnight: an event opening Friday 11pm and closing Saturday 5am has to group as 'Friday' everywhere, but its clock timestamps straddle two calendar days. A background job for expiring stale ticket holds also turned out to cost roughly 87,600 invocations a month against a 50k free-tier cap, on a schedule with no per-run reason to fire that often.

Solution

The booking-conflict constraint is now keyed on (table, business date) instead of (table, event), with business date stored explicitly on every relevant row rather than derived from timestamps, so the midnight-crossing problem and the double-booking bug get fixed by the same change. Money movement was deliberately kept out of a hand-rolled ledger: rather than maintain a second set of numbers that could drift from Stripe's own records, settlement is tracked in a table keyed directly to Stripe objects, with Connect verification now mandatory before a venue can take a priced sale. The hold-expiry job was moved from a recurring cron to event-driven expiry.

Outcome

Migrated real production data from the predecessor system into the new schema (114 venues, 908 orders, 1,054 tickets, and 514 door scans) while the web dashboard shipped and moved into internal use.