ShipAddons

Database

PostgreSQL schema, users, subscriptions, and audit logs

ShipAddons uses Supabase's PostgreSQL database with Row Level Security (RLS) for data storage.

Schema Overview

The database consists of three tables:

TablePurpose
usersUser accounts linked to Google identity
subscriptionsStripe subscription state
audit_logsActivity tracking and debugging

Users Table

Stores user account information.

create table public.users (
  id uuid primary key default gen_random_uuid(),
  google_sub text unique not null,
  email text unique not null,
  name text,
  available_credits integer default 0,
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

Columns

ColumnTypeDescription
iduuidPrimary key, used in JWTs and RLS
google_subtextGoogle user ID from identity token
emailtextUser's email address
nametextDisplay name
available_creditsintegerCredit balance for usage-based features
created_attimestamptzAccount creation timestamp
updated_attimestamptzLast modification timestamp

User Creation

Users are created during first authentication:

// In /api/auth/google
const { data: user } = await supabaseAdmin
  .from("users")
  .upsert(
    {
      google_sub: payload.sub,
      email: payload.email,
      name: payload.name,
    },
    { onConflict: "google_sub" }
  )
  .select()
  .single();

The upsert ensures idempotent user creation—subsequent logins update the user's name if it changed.

Subscriptions Table

Tracks Stripe subscription state.

create table public.subscriptions (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references public.users(id) unique not null,
  stripe_customer_id text unique not null,
  stripe_subscription_id text unique,
  status text,
  plan_id text,
  current_period_start timestamptz,
  current_period_end timestamptz,
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

Columns

ColumnTypeDescription
user_iduuidForeign key to users table (unique constraint)
stripe_customer_idtextStripe customer ID (cus_xxx)
stripe_subscription_idtextStripe subscription ID (sub_xxx)
statustextSubscription status (active, canceled, etc.)
plan_idtextPackage ID from constants.ts
current_period_starttimestamptzBilling period start
current_period_endtimestamptzBilling period end

Status Values

StatusDescription
activeSubscription is paid and active
trialingIn trial period
past_duePayment failed, grace period
canceledSubscription ended
incompleteInitial payment pending
incomplete_expiredInitial payment failed

Subscription Updates

Subscriptions are updated via Stripe webhooks:

// checkout.session.completed webhook
await supabaseAdmin.from("subscriptions").upsert({
  user_id: userId,
  stripe_customer_id: customerId,
  stripe_subscription_id: subscriptionId,
  status: subscription.status,
  plan_id: planId,
});

// customer.subscription.updated webhook
await supabaseAdmin
  .from("subscriptions")
  .update({
    status: subscription.status,
    plan_id: newPlanId,
    current_period_end: new Date(subscription.current_period_end * 1000),
  })
  .eq("stripe_subscription_id", subscription.id);

Audit Logs Table

Records user activity for debugging and analytics.

create table public.audit_logs (
  id bigserial primary key,
  user_id uuid references public.users(id) not null,
  action text not null,
  context text,
  timestamp timestamptz default now()
);

Columns

ColumnTypeDescription
user_iduuidForeign key to users table
actiontextActivity type (enum in application code)
contexttextAdditional context (JSON string)
timestamptimestamptzWhen the action occurred

Activity Types

Defined in constants.ts:

export enum ActivityType {
  LOGIN = "LOGIN",
  CHECKOUT_STARTED = "CHECKOUT_STARTED",
  SUBSCRIPTION_CREATED = "SUBSCRIPTION_CREATED",
  SUBSCRIPTION_UPDATED = "SUBSCRIPTION_UPDATED",
  SUBSCRIPTION_CANCELED = "SUBSCRIPTION_CANCELED",
  CREDITS_USED = "CREDITS_USED",
  CREDITS_PURCHASED = "CREDITS_PURCHASED",
}

Logging Activity

await supabaseAdmin.from("audit_logs").insert({
  user_id: userId,
  action: ActivityType.SUBSCRIPTION_CREATED,
  context: JSON.stringify({ plan_id: "PRO" }),
});

Row Level Security

RLS policies ensure users can only access their own data.

Policy Definitions

-- Enable RLS
alter table public.users enable row level security;
alter table public.subscriptions enable row level security;
alter table public.audit_logs enable row level security;

-- Users table policies
create policy "Users can read own data" on public.users
  for select using (auth.uid() = id);

create policy "Users can update own data" on public.users
  for update using (auth.uid() = id);

-- Subscriptions table policies
create policy "Users can read own subscription" on public.subscriptions
  for select using (auth.uid() = user_id);

-- Audit logs policies
create policy "Users can read own audit logs" on public.audit_logs
  for select using (auth.uid() = user_id);

create policy "Users can insert own audit logs" on public.audit_logs
  for insert with check (auth.uid() = user_id);

How RLS Works

  1. The add-on client receives a JWT with the user's UUID as the sub claim
  2. Client makes requests to Supabase with this JWT in the Authorization header
  3. Supabase extracts auth.uid() from the JWT
  4. RLS policies compare auth.uid() against row ownership columns
// This query automatically filters to the authenticated user
const { data: user } = await supabaseClient
  .from("users")
  .select("*")
  .single();
// Returns only the user's own row, or null if not found

Bypassing RLS

The service key bypasses RLS for admin operations:

// Admin operations use supabaseAdmin (service key)
const { data } = await supabaseAdmin
  .from("users")
  .select("*");
// Returns ALL users - use with caution

Only use the admin client for:

  • User creation during authentication
  • Webhook handlers
  • Admin dashboards

Indexes

Performance indexes are created automatically:

-- Implicit indexes from unique constraints
-- users.google_sub, users.email
-- subscriptions.stripe_customer_id, subscriptions.stripe_subscription_id

-- Explicit indexes
create index users_google_sub_idx on public.users (google_sub);
create index subscriptions_user_id_idx on public.subscriptions (user_id);
create index audit_logs_user_id_idx on public.audit_logs (user_id);
create index audit_logs_timestamp_idx on public.audit_logs (timestamp);

Updated Timestamps

Automatic updated_at triggers:

create or replace function update_updated_at_column()
returns trigger as $$
begin
  new.updated_at = now();
  return new;
end;
$$ language plpgsql;

create trigger update_users_updated_at
  before update on public.users
  for each row execute function update_updated_at_column();

create trigger update_subscriptions_updated_at
  before update on public.subscriptions
  for each row execute function update_updated_at_column();

On this page