Skip to content

Database Schema Reference

This document defines every table in the Sooke Community App database, what each column is for, and why each design decision was made.


The schema has 9 tables, created in dependency order by goose migrations. Every table that references another table is created after the table it depends on.

roles
-> users
-> businesses (owner_id)
-> business_hours
-> menus
-> menu_items
-> events (submitted_by)
business_categories
-> businesses (category_id)
event_types
-> events (event_type_id)

Defines the permission tiers for the application. Created first because users references it.

CREATE TABLE roles (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ColumnTypeWhy
idBIGSERIALAuto-incrementing primary key. BIGSERIAL over SERIAL to avoid the 2.1B ceiling.
nameTEXT NOT NULL UNIQUEThe role name (general_user, business_owner, super_admin). UNIQUE prevents duplicates.
created_atTIMESTAMPTZWhen the row was created. TIMESTAMPTZ stores timezone-aware timestamps to avoid UTC bugs.
updated_atTIMESTAMPTZWhen the row was last modified.

Expected rows: general_user, business_owner, super_admin. These are seeded, not migrated.


Links a Clerk identity to app-specific data. Without this table, the API has no way to know who is making a request or what role they have.

CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
clerk_id TEXT NOT NULL UNIQUE,
role_id BIGINT NOT NULL REFERENCES roles(id),
email TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ColumnTypeWhy
clerk_idTEXT NOT NULL UNIQUEThe user’s ID from Clerk. Every authenticated API request looks up the user by this field. The UNIQUE constraint creates an implicit index for fast lookups.
role_idBIGINT NOT NULL REFERENCES roles(id)Links the user to their role. NOT NULL because every user must have a role.
emailTEXT NOT NULL UNIQUEThe user’s email from Clerk. UNIQUE prevents duplicate accounts.
display_nameTEXT NOT NULLWhat the user sees in the UI.

The curated tag list for businesses. A separate table (not a Postgres ENUM) so the Super Admin can add categories at runtime through the admin dashboard without requiring a database migration.

CREATE TABLE business_categories (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ColumnTypeWhy
nameTEXT NOT NULL UNIQUEWhat the user sees (“Restaurant”, “Cafe”, “Retail”).
slugTEXT NOT NULL UNIQUEWhat the API uses for filtering (/api/v1/businesses?category=restaurant).

The core entity. Everything in the app orbits around local businesses.

CREATE TABLE businesses (
id BIGSERIAL PRIMARY KEY,
owner_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
category_id BIGINT NOT NULL REFERENCES business_categories(id),
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
description TEXT,
phone TEXT,
email TEXT,
website TEXT,
address TEXT NOT NULL,
lat DOUBLE PRECISION NOT NULL,
lng DOUBLE PRECISION NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ColumnTypeWhy
owner_idBIGINT, nullable, ON DELETE SET NULLThe Super Admin creates the business first. The owner is assigned later when a real person is verified. Nullable because not every business has a claimed owner. SET NULL so the business survives if the user account is deleted.
category_idBIGINT NOT NULLEvery business needs a category for filtering to work.
slugTEXT NOT NULL UNIQUEURL-friendly identifier (joes-coffee-shop). Used in API responses and prepared for future deep linking.
descriptionTEXT, nullableOptional blurb. Some businesses will not have one initially.
phone, email, websiteTEXT, nullableNot every business has all three contact methods.
addressTEXT NOT NULLStreet address for display. Every business has a physical location.
lat, lngDOUBLE PRECISION NOT NULLMap pin coordinates. DOUBLE PRECISION gives sub-millimeter accuracy at city scale. PostGIS is overkill for pin-dropping. NOT NULL because every business needs a map pin.

A separate table instead of a JSON blob because hours are complex (per-day, seasonal, holidays) and need to be queryable (“what is open right now?”).

CREATE TABLE business_hours (
id BIGSERIAL PRIMARY KEY,
business_id BIGINT NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
day_of_week SMALLINT NOT NULL CHECK (day_of_week BETWEEN 0 AND 6),
open_time TIME NOT NULL,
close_time TIME NOT NULL,
is_closed BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (business_id, day_of_week)
);
ColumnTypeWhy
business_idBIGINT NOT NULL, ON DELETE CASCADEIf the business is removed, its hours go with it. No orphaned rows.
day_of_weekSMALLINT, CHECK 0—60 = Sunday, 6 = Saturday. The CHECK constraint prevents invalid values like 9.
open_time, close_timeTIME NOT NULLOpening and closing times for that day.
is_closedBOOLEAN, default FALSEHandles days the business is closed. When true, open_time/close_time are ignored by the application.
UNIQUE (business_id, day_of_week)ConstraintOne row per business per day. Prevents duplicate entries for the same day.

A named container for menu items. One business can have multiple menus (lunch, dinner, drinks).

CREATE TABLE menus (
id BIGSERIAL PRIMARY KEY,
business_id BIGINT NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ColumnTypeWhy
business_idBIGINT NOT NULL, ON DELETE CASCADEIf the business is deleted, its menus go with it.
nameTEXT NOT NULL”Lunch Menu”, “Dinner Menu”, “Drinks”, etc.

Individual items within a menu.

CREATE TABLE menu_items (
id BIGSERIAL PRIMARY KEY,
menu_id BIGINT NOT NULL REFERENCES menus(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
price NUMERIC(10,2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ColumnTypeWhy
menu_idBIGINT NOT NULL, ON DELETE CASCADEIf the menu is deleted, its items go with it. Also cascades transitively — if the business is deleted, menus are deleted, which deletes items.
descriptionTEXT, nullableOptional. Some items are self-explanatory (“Coffee”).
priceNUMERIC(10,2) NOT NULLExact decimal arithmetic. FLOAT would give $12.989999 instead of $12.99. Supports values up to $99,999,999.99.

The curated tag list for events. Same pattern as business_categories — a separate table so the Super Admin can manage event types at runtime.

CREATE TABLE event_types (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ColumnTypeWhy
nameTEXT NOT NULL UNIQUEWhat the user sees (“Live Music”, “Market”, “Workshop”).
slugTEXT NOT NULL UNIQUEWhat the API uses for filtering.

The second core entity. Events are submitted by users and require approval before they are visible.

CREATE TABLE events (
id BIGSERIAL PRIMARY KEY,
event_type_id BIGINT NOT NULL REFERENCES event_types(id),
submitted_by BIGINT NOT NULL REFERENCES users(id),
business_id BIGINT REFERENCES businesses(id) ON DELETE SET NULL,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
description TEXT,
lat DOUBLE PRECISION,
lng DOUBLE PRECISION,
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'pending_review', 'approved', 'rejected')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT event_location_check CHECK (
(business_id IS NOT NULL AND lat IS NULL AND lng IS NULL) OR
(business_id IS NULL AND lat IS NOT NULL AND lng IS NOT NULL)
)
);
ColumnTypeWhy
event_type_idBIGINT NOT NULLEvery event must have a type for filtering.
submitted_byBIGINT NOT NULLTracks who created the event. Needed for rate limiting (max 5 pending per user) and notifications.
business_idBIGINT, nullable, ON DELETE SET NULLNullable because events at public locations do not have a business. SET NULL so the event survives if the business is later deleted.
lat, lngDOUBLE PRECISION, nullableNullable because events at a business use the business’s coordinates instead.
starts_atTIMESTAMPTZ NOT NULLWhen the event starts. Required.
ends_atTIMESTAMPTZ, nullableWhen the event ends. Optional because some events do not have a fixed end time.
statusTEXT with CHECKUses a CHECK constraint instead of a Postgres ENUM. ENUMs are painful to modify (requires ALTER TYPE). A CHECK is easier to extend by dropping and re-adding the constraint.
event_location_checkConstraintThe XOR rule: an event has EITHER a business_id OR its own lat/lng, never both, never neither. This is a business rule enforced at the database level so no buggy API code can violate it.

DecisionWhy
BIGSERIAL over SERIALAvoids the 2.1 billion row ceiling. Costs nothing extra.
TIMESTAMPTZ over TIMESTAMPAlways stores timezone-aware timestamps. Prevents bugs when servers run in different timezones.
DOUBLE PRECISION over PostGISSub-millimeter accuracy at city scale. PostGIS adds complexity with no benefit for pin-dropping.
NUMERIC(10,2) over FLOATExact decimal arithmetic. $12.99 stays $12.99.
TEXT CHECK over ENUMEasier to modify. Adding a new status is a constraint swap, not an ALTER TYPE.
CASCADE on child tablesbusiness_hours, menus, menu_items are deleted when their parent is deleted. No orphaned rows.
SET NULL on optional referencesEvents survive business deletion. Businesses survive user deletion. Data is preserved.
Separate tables for categories/typesSuper Admin can add new tags at runtime without a migration.
Nullable owner_idBusinesses are created by the admin before any owner is assigned.