Docs

Database

Full database schema for CraftlyNow — tables, columns, types, RLS policies and relationships.

Database

CraftlyNow uses Supabase (PostgreSQL). Two core tables power the entire v1 product.


Tables

courses

Stores each course created by a user.

create table courses (
  id            uuid primary key default gen_random_uuid(),
  user_id       uuid not null references auth.users(id) on delete cascade,
  title         text not null,
  description   text,
  is_published  boolean not null default false,
  share_token   text unique not null default gen_random_uuid()::text,
  created_at    timestamptz not null default now(),
  updated_at    timestamptz not null default now()
);
ColumnTypeNotes
iduuidPrimary key, auto-generated
user_iduuidOwner — references auth.users
titletextRequired
descriptiontextOptional course-level description
is_publishedbooleantrue = visible on public landing page
share_tokentextUnique token for private share URLs — auto-generated
created_attimestamptzAuto-set on insert
updated_attimestamptzUpdated manually on each save

course_videos

Stores each video inside a course, including all pulled metadata and user content.

create table course_videos (
  id                  uuid primary key default gen_random_uuid(),
  course_id           uuid not null references courses(id) on delete cascade,
  youtube_url         text not null,
  youtube_id          text not null,
  youtube_title       text,
  youtube_thumbnail   text,
  youtube_description text,
  transcript          text,
  notes               text,
  position            integer not null default 0,
  created_at          timestamptz not null default now(),
  updated_at          timestamptz not null default now()
);
ColumnTypeNotes
iduuidPrimary key, auto-generated
course_iduuidReferences courses(id) — cascade delete
youtube_urltextFull YouTube URL as entered by user
youtube_idtextExtracted video ID (e.g. dQw4w9WgXcQ)
youtube_titletextAuto-pulled from YouTube
youtube_thumbnailtextThumbnail URL auto-pulled from YouTube
youtube_descriptiontextAuto-pulled from YouTube
transcripttextPulled from YouTube — null if unavailable
notestextTipTap editor content (HTML or JSON) — AI-generated or manual
positionintegerOrder within the course, starts at 0
created_attimestamptzAuto-set on insert
updated_attimestamptzUpdated on each save

Relationships

auth.users
    │
    └── courses (user_id)
            │
            └── course_videos (course_id)
  • One user → many courses
  • One course → many videos
  • Deleting a course cascades to delete all its videos
  • Deleting a user cascades to delete all their courses

RLS Policies

Row Level Security is enabled on both tables.

courses

alter table courses enable row level security;

-- Anyone can read published courses
create policy "Public read published courses"
  on courses for select
  using (is_published = true);

-- Authenticated users can read all their own courses
create policy "Owner read own courses"
  on courses for select
  using (auth.uid() = user_id);

-- Authenticated users can insert their own courses
create policy "Owner insert courses"
  on courses for insert
  with check (auth.uid() = user_id);

-- Authenticated users can update their own courses
create policy "Owner update courses"
  on courses for update
  using (auth.uid() = user_id);

-- Authenticated users can delete their own courses
create policy "Owner delete courses"
  on courses for delete
  using (auth.uid() = user_id);

course_videos

alter table course_videos enable row level security;

-- Anyone can read videos in a published course
create policy "Public read videos in published course"
  on course_videos for select
  using (
    exists (
      select 1 from courses
      where courses.id = course_videos.course_id
      and courses.is_published = true
    )
  );

-- Owners can read videos in their own courses
create policy "Owner read own course videos"
  on course_videos for select
  using (
    exists (
      select 1 from courses
      where courses.id = course_videos.course_id
      and courses.user_id = auth.uid()
    )
  );

-- Owners can insert videos into their own courses
create policy "Owner insert course videos"
  on course_videos for insert
  with check (
    exists (
      select 1 from courses
      where courses.id = course_videos.course_id
      and courses.user_id = auth.uid()
    )
  );

-- Owners can update videos in their own courses
create policy "Owner update course videos"
  on course_videos for update
  using (
    exists (
      select 1 from courses
      where courses.id = course_videos.course_id
      and courses.user_id = auth.uid()
    )
  );

-- Owners can delete videos from their own courses
create policy "Owner delete course videos"
  on course_videos for delete
  using (
    exists (
      select 1 from courses
      where courses.id = course_videos.course_id
      and courses.user_id = auth.uid()
    )
  );

Private sharing

The share_token column enables private sharing without authentication.

A private share URL looks like:

craftlynow.com/c/[share_token]

When a visitor hits this URL, the app queries Supabase directly by share_token — bypassing the is_published RLS policy. This is handled in a server-side API route (Nuxt server route or Supabase edge function) using the service role key, never exposed to the client.


Indexes

-- Speed up lookups by user
create index on courses(user_id);

-- Speed up public listing
create index on courses(is_published);

-- Speed up private share lookups
create index on courses(share_token);

-- Speed up video ordering within a course
create index on course_videos(course_id, position);

Auto-update updated_at

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

create trigger set_updated_at_courses
  before update on courses
  for each row execute function update_updated_at();

create trigger set_updated_at_course_videos
  before update on course_videos
  for each row execute function update_updated_at();