Engineering
Database schema
Postgres 15 + PostGIS. All tables in the public schema with row-level security. Designed for read-heavy map workloads and write-batched ratings.
Entity relationship overview
users ──┬── energy_markers ──┬── marker_ratings
│ ├── marker_photos
│ ├── comments
│ └── visits
├── followers (self-join)
├── saved_locations ──── (M:M to markers via saved_list_items)
├── journal_entries
└── notificationsFull SQL schema
-- Enable extensions
create extension if not exists postgis;
create extension if not exists pg_trgm;
-- USERS
create table public.users (
id uuid primary key default gen_random_uuid(),
auth_user_id uuid unique not null references auth.users(id) on delete cascade,
username text unique not null check (length(username) between 3 and 30),
display_name text not null,
avatar_url text,
bio text check (length(bio) <= 280),
energy_preferences text[] default '{}',
xp int not null default 0,
level int not null default 1,
is_premium boolean not null default false,
created_at timestamptz not null default now()
);
-- ENERGY_MARKERS
create type energy_category as enum (
'healing','love','creativity','grounding','spiritual',
'adventure','abundance','focus','joy','transformation'
);
create table public.energy_markers (
id uuid primary key default gen_random_uuid(),
creator_id uuid not null references public.users(id) on delete cascade,
title text not null check (length(title) between 1 and 80),
description text check (length(description) <= 500),
category energy_category not null,
intensity smallint not null check (intensity between 1 and 10),
mood text not null,
location geography(Point, 4326) not null,
-- Denormalized aggregates (kept in sync via triggers / cron)
energy_score numeric(4,2) not null default 0,
ratings_count int not null default 0,
visits_count int not null default 0,
photos_count int not null default 0,
created_at timestamptz not null default now()
);
create index energy_markers_location_idx on public.energy_markers using gist(location);
create index energy_markers_category_idx on public.energy_markers(category);
create index energy_markers_score_idx on public.energy_markers(energy_score desc);
create index energy_markers_title_trgm on public.energy_markers using gin (title gin_trgm_ops);
-- MARKER_RATINGS (6 sub-scores)
create table public.marker_ratings (
id uuid primary key default gen_random_uuid(),
marker_id uuid not null references public.energy_markers(id) on delete cascade,
user_id uuid not null references public.users(id) on delete cascade,
calmness smallint not null check (calmness between 1 and 10),
inspiration smallint not null check (inspiration between 1 and 10),
creativity smallint not null check (creativity between 1 and 10),
emotional_impact smallint not null check (emotional_impact between 1 and 10),
focus smallint not null check (focus between 1 and 10),
overall smallint not null check (overall between 1 and 10),
created_at timestamptz not null default now(),
unique (marker_id, user_id)
);
-- MARKER_PHOTOS
create table public.marker_photos (
id uuid primary key default gen_random_uuid(),
marker_id uuid not null references public.energy_markers(id) on delete cascade,
uploader_id uuid not null references public.users(id) on delete set null,
storage_path text not null,
width int, height int,
created_at timestamptz not null default now()
);
-- COMMENTS
create table public.comments (
id uuid primary key default gen_random_uuid(),
marker_id uuid not null references public.energy_markers(id) on delete cascade,
user_id uuid not null references public.users(id) on delete cascade,
body text not null check (length(body) between 1 and 500),
created_at timestamptz not null default now()
);
-- LIKES
create table public.marker_likes (
marker_id uuid references public.energy_markers(id) on delete cascade,
user_id uuid references public.users(id) on delete cascade,
created_at timestamptz not null default now(),
primary key (marker_id, user_id)
);
-- FOLLOWERS (self-join)
create table public.followers (
follower_id uuid references public.users(id) on delete cascade,
followee_id uuid references public.users(id) on delete cascade,
created_at timestamptz not null default now(),
primary key (follower_id, followee_id),
check (follower_id <> followee_id)
);
-- SAVED LISTS + ITEMS
create table public.saved_lists (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references public.users(id) on delete cascade,
name text not null,
is_default boolean not null default false,
created_at timestamptz not null default now()
);
create table public.saved_list_items (
list_id uuid references public.saved_lists(id) on delete cascade,
marker_id uuid references public.energy_markers(id) on delete cascade,
saved_at timestamptz not null default now(),
primary key (list_id, marker_id)
);
-- VISITS (GPS-verified check-ins)
create table public.visits (
id uuid primary key default gen_random_uuid(),
marker_id uuid not null references public.energy_markers(id) on delete cascade,
user_id uuid not null references public.users(id) on delete cascade,
visited_at timestamptz not null default now(),
gps_accuracy_m numeric,
unique (marker_id, user_id, visited_at)
);
-- JOURNAL_ENTRIES (private by default)
create table public.journal_entries (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references public.users(id) on delete cascade,
marker_id uuid references public.energy_markers(id) on delete set null,
title text,
body text,
mood text,
photo_paths text[] default '{}',
is_private boolean not null default true,
entry_at timestamptz not null default now()
);
-- NOTIFICATIONS
create type notification_kind as enum ('follow','visit','like','comment','badge','system');
create table public.notifications (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references public.users(id) on delete cascade,
kind notification_kind not null,
payload jsonb not null default '{}',
is_read boolean not null default false,
created_at timestamptz not null default now()
);
create index notifications_user_unread_idx
on public.notifications(user_id, is_read, created_at desc);
-- BADGES
create table public.badges (
id text primary key, -- e.g. 'first_marker', 'globe_trotter'
name text not null,
description text not null,
icon_url text
);
create table public.user_badges (
user_id uuid references public.users(id) on delete cascade,
badge_id text references public.badges(id),
earned_at timestamptz not null default now(),
primary key (user_id, badge_id)
);
-- ============================================================
-- GRANTS (required for PostgREST access)
-- ============================================================
grant select, insert, update, delete on public.users to authenticated;
grant select on public.users to anon;
grant all on public.users to service_role;
grant select, insert, update, delete on public.energy_markers to authenticated;
grant select on public.energy_markers to anon;
grant all on public.energy_markers to service_role;
grant select, insert, update, delete on
public.marker_ratings, public.marker_photos, public.comments,
public.marker_likes, public.followers, public.saved_lists,
public.saved_list_items, public.visits, public.journal_entries,
public.notifications, public.user_badges
to authenticated;
grant select on
public.marker_ratings, public.marker_photos, public.comments,
public.marker_likes, public.followers, public.badges
to anon;
grant all on
public.marker_ratings, public.marker_photos, public.comments,
public.marker_likes, public.followers, public.saved_lists,
public.saved_list_items, public.visits, public.journal_entries,
public.notifications, public.badges, public.user_badges
to service_role;
-- ============================================================
-- ROW LEVEL SECURITY
-- ============================================================
alter table public.users enable row level security;
create policy "users readable to all"
on public.users for select using (true);
create policy "users update self"
on public.users for update using (auth.uid() = auth_user_id);
alter table public.energy_markers enable row level security;
create policy "markers readable to all"
on public.energy_markers for select using (true);
create policy "markers insert by owner"
on public.energy_markers for insert
with check (creator_id in (select id from public.users where auth_user_id = auth.uid()));
create policy "markers update by owner"
on public.energy_markers for update
using (creator_id in (select id from public.users where auth_user_id = auth.uid()));
alter table public.journal_entries enable row level security;
create policy "journal owner-only"
on public.journal_entries for all
using (user_id in (select id from public.users where auth_user_id = auth.uid()));Scoring formula (weighted)
energy_score = round(
0.25 * avg(overall) +
0.15 * avg(calmness) +
0.15 * avg(inspiration) +
0.15 * avg(creativity) +
0.15 * avg(emotional_impact) +
0.15 * avg(focus)
, 2)- Recomputed via pg_cron every 15 minutes per marker with new ratings.
- Cold-start: markers with < 3 ratings flagged as 'New' instead of scored.
- Weights tunable per cohort once we have data — stored in app_config table.
Anti-spam & integrity
- Unique constraint on (marker_id, user_id) in ratings — one rating per user per place.
- Edge function validates GPS within 100m before insert on energy_markers and visits.
- Velocity check: max 10 marker creations / 24h per user.
- Photo moderation via on-device NSFW classifier + server fallback (AWS Rekognition Phase 2).