diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..7fe08ed --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,61 @@ +name: Deploy + +on: + push: + branches: + - main + +concurrency: + group: deploy-production-main + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Build + run: npm run build + + - name: Set up Supabase CLI + uses: supabase/setup-cli@v1 + with: + version: 2.101.0 + + - name: Link Supabase project + env: + SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }} + SUPABASE_PROJECT_REF: ${{ secrets.SUPABASE_PROJECT_REF }} + SUPABASE_DB_PASSWORD: ${{ secrets.SUPABASE_DB_PASSWORD }} + run: supabase link --project-ref "$SUPABASE_PROJECT_REF" --password "$SUPABASE_DB_PASSWORD" + + - name: Apply pending migrations and seed baseline data + env: + SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }} + run: supabase db push --linked --include-seed + + - name: Set up Vercel CLI + run: npm install --global vercel@54.4.1 + + - name: Deploy to Vercel production + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + run: vercel deploy --prod --yes --token "$VERCEL_TOKEN" diff --git a/.gitignore b/.gitignore index 473d96c..ab59853 100644 --- a/.gitignore +++ b/.gitignore @@ -31,12 +31,17 @@ yarn-debug.log* yarn-error.log* .pnpm-debug.log* -# env files (can opt-in for committing if needed) -.env*.local +# env files .env +.env.* +!.env.example +!.env*.example +!.env*.sample +!.env*.template # vercel .vercel +supabase/.temp/ # typescript *.tsbuildinfo diff --git a/README.md b/README.md index 97f7659..df5513c 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ This repo currently stops at: - environment variable template - deployment-ready project structure -This repo does not yet include MVP feature pages, SQL schema, avatar logic, habits, streaks, or shop logic. +This repo does not yet include MVP feature pages, avatar logic, habits, streaks, or shop logic. ## Local setup @@ -59,11 +59,37 @@ Create a Supabase project, then copy the project URL and anon key from **Project The starter also supports Supabase's publishable key name, but this repo documents the anon key convention from the project plan. +### Database workflow + +- Schema migrations live under `supabase/migrations/`. +- Baseline catalog data lives in `supabase/seed.sql` and is safe to rerun during deploys because it uses idempotent upserts. +- Local CLI config lives in `supabase/config.toml` for `supabase start`, `supabase db reset`, and related commands. +- Production database sync runs from GitHub Actions on `main` using `supabase db push --linked --include-seed` before the app deploy relies on those changes. + ## Deploy -1. Push the repo to GitHub. -2. Import the repo into Vercel. -3. Add the same Supabase environment variables in Vercel. -4. Deploy. +This repo includes `.github/workflows/deploy.yml`, which runs on pushes to `main`. + +The workflow: + +- checks out the repo +- runs `npm ci`, `npm run lint`, and `npm run build` +- links the Supabase CLI to the production project +- applies pending database migrations and reruns `supabase/seed.sql` via `supabase db push --linked --include-seed` +- deploys the app to Vercel production only after the database sync succeeds + +### Required GitHub secrets + +- `SUPABASE_ACCESS_TOKEN` +- `SUPABASE_PROJECT_REF` +- `SUPABASE_DB_PASSWORD` +- `VERCEL_TOKEN` +- `VERCEL_ORG_ID` +- `VERCEL_PROJECT_ID` + +### Required Vercel environment variables + +- `NEXT_PUBLIC_SUPABASE_URL` +- `NEXT_PUBLIC_SUPABASE_ANON_KEY` Frontend and backend are both served from this Next.js app. Supabase provides auth and database services. diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 0000000..12ccd61 --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,48 @@ +project_id = "habit-pet" + +[api] +enabled = true +port = 54321 +schemas = ["public", "graphql_public"] +extra_search_path = ["public", "extensions"] +max_rows = 1000 + +[db] +port = 54322 +shadow_port = 54320 +major_version = 15 + +[db.seed] +enabled = true +sql_paths = ["./seed.sql"] + +[studio] +enabled = true +port = 54323 + +[inbucket] +enabled = true +port = 54324 + +[storage] +enabled = true +file_size_limit = "50MiB" + +[auth] +enabled = true +site_url = "http://127.0.0.1:3000" +additional_redirect_urls = ["http://127.0.0.1:3000/**", "http://localhost:3000/**"] +jwt_expiry = 3600 +enable_refresh_token_rotation = true +refresh_token_reuse_interval = 10 + +[auth.email] +enable_signup = true +double_confirm_changes = true +enable_confirmations = false + +[realtime] +enabled = true + +[analytics] +enabled = false diff --git a/supabase/migrations/20260523230000_add_public_rls_and_habit_log_guardrails.sql b/supabase/migrations/20260523230000_add_public_rls_and_habit_log_guardrails.sql new file mode 100644 index 0000000..b80cc24 --- /dev/null +++ b/supabase/migrations/20260523230000_add_public_rls_and_habit_log_guardrails.sql @@ -0,0 +1,76 @@ +alter table profiles enable row level security; +alter table habits enable row level security; +alter table habit_logs enable row level security; +alter table daily_entries enable row level security; +alter table avatar_state enable row level security; +alter table shop_items enable row level security; +alter table user_items enable row level security; + +alter table habits + add constraint habits_id_user_id_unique unique (id, user_id); + +alter table habit_logs + add constraint habit_logs_habit_id_user_id_fkey + foreign key (habit_id, user_id) + references habits (id, user_id) + on delete cascade; + +create policy "profiles_select_own" + on profiles + for select + to authenticated + using (id = auth.uid()); + +create policy "profiles_insert_own" + on profiles + for insert + to authenticated + with check (id = auth.uid()); + +create policy "profiles_update_own" + on profiles + for update + to authenticated + using (id = auth.uid()) + with check (id = auth.uid()); + +create policy "habits_manage_own" + on habits + for all + to authenticated + using (user_id = auth.uid()) + with check (user_id = auth.uid()); + +create policy "habit_logs_manage_own" + on habit_logs + for all + to authenticated + using (user_id = auth.uid()) + with check (user_id = auth.uid()); + +create policy "daily_entries_manage_own" + on daily_entries + for all + to authenticated + using (user_id = auth.uid()) + with check (user_id = auth.uid()); + +create policy "avatar_state_manage_own" + on avatar_state + for all + to authenticated + using (user_id = auth.uid()) + with check (user_id = auth.uid()); + +create policy "shop_items_read_all" + on shop_items + for select + to anon, authenticated + using (true); + +create policy "user_items_manage_own" + on user_items + for all + to authenticated + using (user_id = auth.uid()) + with check (user_id = auth.uid()); diff --git a/supabase/migrations/20260523_initial_schema.sql b/supabase/migrations/20260523_initial_schema.sql new file mode 100644 index 0000000..711268a --- /dev/null +++ b/supabase/migrations/20260523_initial_schema.sql @@ -0,0 +1,67 @@ +create extension if not exists pgcrypto; + +create table profiles ( + id uuid primary key references auth.users(id) on delete cascade, + display_name text, + focus_topics text[] default '{}', + onboarding_complete boolean default false, + created_at timestamptz default now() +); + +create table habits ( + id uuid primary key default gen_random_uuid(), + user_id uuid references auth.users(id) on delete cascade not null, + name text not null, + active boolean default true, + created_at timestamptz default now() +); + +create table habit_logs ( + id uuid primary key default gen_random_uuid(), + habit_id uuid references habits(id) on delete cascade not null, + user_id uuid references auth.users(id) on delete cascade not null, + completed_on date not null default current_date, + created_at timestamptz default now(), + unique (habit_id, completed_on) +); + +create table daily_entries ( + id uuid primary key default gen_random_uuid(), + user_id uuid references auth.users(id) on delete cascade not null, + entry_date date not null default current_date, + mood int check (mood between 1 and 5), + stress int check (stress between 1 and 5), + energy int check (energy between 1 and 5), + sleep_hours numeric, + sleep_quality int check (sleep_quality between 1 and 5), + journal text, + created_at timestamptz default now(), + unique (user_id, entry_date) +); + +create table avatar_state ( + user_id uuid primary key references auth.users(id) on delete cascade, + avatar_name text default 'Pixel Me', + mood_state text default 'neutral', + health int default 50, + energy int default 50, + coins int default 0, + streak int default 0, + equipped_item text default 'none', + updated_at timestamptz default now() +); + +create table shop_items ( + id text primary key, + name text not null, + type text not null, + price int not null, + image_path text not null +); + +create table user_items ( + user_id uuid references auth.users(id) on delete cascade, + item_id text references shop_items(id) on delete cascade, + purchased_at timestamptz default now(), + primary key (user_id, item_id) +); diff --git a/supabase/seed.sql b/supabase/seed.sql new file mode 100644 index 0000000..66eb8bb --- /dev/null +++ b/supabase/seed.sql @@ -0,0 +1,11 @@ +insert into shop_items (id, name, type, price, image_path) +values + ('hat_blue', 'Blue Hat', 'accessory', 20, '/items/hat-blue.png'), + ('glasses_round', 'Round Glasses', 'accessory', 30, '/items/glasses-round.png'), + ('room_sunset', 'Sunset Room', 'background', 40, '/backgrounds/room-sunset.png') +on conflict (id) do update +set + name = excluded.name, + type = excluded.type, + price = excluded.price, + image_path = excluded.image_path;