From 99b685f720dd101252da235fae7585d132f17f35 Mon Sep 17 00:00:00 2001 From: Armand Nzeyang Date: Mon, 15 Jun 2026 23:42:56 -0400 Subject: [PATCH] Add authenticated Supabase E2E fixtures --- .env.example | 15 + .github/workflows/quality.yml | 37 ++ .gitignore | 9 + README.md | 147 ++++++-- app/api/calculate-tde.ts | 36 -- app/auth/confirm/route.ts | 36 +- app/components/NavBar.tsx | 10 +- app/components/WeeklyProgress.tsx | 10 +- app/components/WorkoutTemplates.tsx | 8 +- app/forgot-password/action.ts | 30 ++ app/forgot-password/page.tsx | 32 ++ app/globals.css | 4 +- app/hooks/useWeekDates.ts | 15 +- app/layout.tsx | 12 +- app/lib/auth.ts | 32 ++ app/lib/client-database.ts | 56 +-- app/lib/database.types.ts | 100 +++++ app/lib/date.ts | 39 ++ app/lib/definitions.ts | 29 +- app/lib/generated-plans.ts | 67 ++++ app/lib/profile-options.ts | 34 ++ app/lib/profile.ts | 36 ++ app/lib/tde.ts | 54 ++- app/lib/workout-generator.ts | 153 ++++---- app/login/page.tsx | 5 + app/protected/profile/action.ts | 111 +++--- app/protected/profile/meal-plan/action.ts | 245 +++++------- app/protected/profile/meal-plan/page.tsx | 19 +- app/protected/profile/page.tsx | 317 ++++++++-------- app/reset-password/action.ts | 36 ++ app/reset-password/page.tsx | 41 ++ e2e/auth.setup.ts | 25 ++ e2e/protected-flow.spec.ts | 24 ++ e2e/public-auth.spec.ts | 50 +++ eslint.config.mjs | 1 + middleware.ts | 8 +- package-lock.json | 60 ++- package.json | 15 +- playwright.config.ts | 52 +++ supabase/config.toml | 24 ++ .../20260611155633_remote_baseline.sql | 0 ...0260611170000_reconcile_fitness_schema.sql | 357 ++++++++++++++++++ ...0260611170000_reconcile_fitness_schema.sql | 44 +++ supabase/seed.sql | 1 + tests/auth-schemas.test.ts | 68 ++++ tests/database-migration.test.ts | 62 +++ tests/date.test.ts | 56 +++ tests/e2e-harness.test.ts | 62 +++ tests/generated-plans.test.ts | 75 ++++ tests/password-recovery.test.ts | 42 +++ tests/profile-schema.test.ts | 53 +++ tests/supabase-client-contract.test.ts | 32 ++ tests/tde.test.ts | 54 +++ tsconfig.json | 2 +- tsconfig.test.json | 14 + utils/supabase/client.ts | 8 +- utils/supabase/middleware.ts | 30 +- utils/supabase/server.ts | 15 +- 58 files changed, 2323 insertions(+), 686 deletions(-) create mode 100644 .env.example create mode 100644 .github/workflows/quality.yml delete mode 100644 app/api/calculate-tde.ts create mode 100644 app/forgot-password/action.ts create mode 100644 app/forgot-password/page.tsx create mode 100644 app/lib/auth.ts create mode 100644 app/lib/database.types.ts create mode 100644 app/lib/date.ts create mode 100644 app/lib/generated-plans.ts create mode 100644 app/lib/profile-options.ts create mode 100644 app/lib/profile.ts create mode 100644 app/reset-password/action.ts create mode 100644 app/reset-password/page.tsx create mode 100644 e2e/auth.setup.ts create mode 100644 e2e/protected-flow.spec.ts create mode 100644 e2e/public-auth.spec.ts create mode 100644 playwright.config.ts create mode 100644 supabase/config.toml create mode 100644 supabase/migrations/20260611155633_remote_baseline.sql create mode 100644 supabase/migrations/20260611170000_reconcile_fitness_schema.sql create mode 100644 supabase/preflight/20260611170000_reconcile_fitness_schema.sql create mode 100644 supabase/seed.sql create mode 100644 tests/auth-schemas.test.ts create mode 100644 tests/database-migration.test.ts create mode 100644 tests/date.test.ts create mode 100644 tests/e2e-harness.test.ts create mode 100644 tests/generated-plans.test.ts create mode 100644 tests/password-recovery.test.ts create mode 100644 tests/profile-schema.test.ts create mode 100644 tests/supabase-client-contract.test.ts create mode 100644 tests/tde.test.ts create mode 100644 tsconfig.test.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c9c9a1e --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Public Supabase project configuration. The anon key is safe to expose to the browser +# only when Row Level Security policies are correctly configured in Supabase. +NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key + +# Public origin used for authentication email redirects. +NEXT_PUBLIC_SITE_URL=http://localhost:3000 + +# Server-only OpenAI credential. Never prefix this variable with NEXT_PUBLIC_. +NEXT_APP_OPENAI_API_KEY=your-openai-api-key + +# Optional Playwright credentials for authenticated protected-flow E2E tests. +# Use a dedicated non-production test user and never commit real values. +E2E_AUTH_EMAIL=e2e-user@example.com +E2E_AUTH_PASSWORD=replace-with-a-dedicated-test-password diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..ef61e65 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,37 @@ +name: Quality + +on: + pull_request: + push: + branches: [main] + +jobs: + quality: + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + NEXT_PUBLIC_SUPABASE_URL: https://example.supabase.co + NEXT_PUBLIC_SUPABASE_ANON_KEY: test-anon-key + NEXT_PUBLIC_SITE_URL: http://127.0.0.1:3000 + NEXT_APP_OPENAI_API_KEY: test-openai-key + E2E_AUTH_EMAIL: ${{ secrets.E2E_AUTH_EMAIL }} + E2E_AUTH_PASSWORD: ${{ secrets.E2E_AUTH_PASSWORD }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run check + - run: npm run build + - run: npx playwright install --with-deps chromium + - run: npm run test:e2e + - if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + playwright-report/ + test-results/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 5ef6a52..910c6dc 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,10 @@ # testing /coverage +/.test-dist +/playwright/.auth/ +/playwright-report/ +/test-results/ # next.js /.next/ @@ -32,6 +36,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel @@ -39,3 +44,7 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# Supabase CLI local state and generated schema comparison artifacts +/supabase/.temp/ +/supabase/remote-database.types.ts diff --git a/README.md b/README.md index d8f46f1..5f139e4 100644 --- a/README.md +++ b/README.md @@ -1,67 +1,140 @@ # FitTrack Pro -A comprehensive fitness tracking application built with modern web technologies to help users achieve their health and fitness goals. +FitTrack Pro is a Next.js fitness tracking application that combines Supabase authentication and persistence with AI-generated workout and meal plans. ## Features -- **Workout Tracking**: Log and monitor your exercise routines -- **Nutrition Management**: Track meals and calorie intake -- **Water Intake**: Monitor daily hydration levels -- **Progress Analytics**: Visualize your fitness journey with charts -- **AI-Powered Recommendations**: Get personalized workout and meal suggestions +- Email and password authentication with email verification +- Fitness profile and TDEE calculation +- Daily water intake tracking +- Personalized workout generation and completed-workout tracking +- Personalized daily meal-plan generation +- Weekly progress summaries ## Tech Stack -- **Frontend**: Next.js, TypeScript, Tailwind CSS -- **Backend**: Supabase (Authentication, Database) -- **AI Features**: OpenAI API integration +- Next.js 15, React 19, and TypeScript +- Tailwind CSS 4 +- Supabase Authentication, Database, and Realtime +- OpenAI API +- Node.js built-in test runner -## Getting Started +## Prerequisites -### Prerequisites +- Node.js 20 or newer +- A Supabase project with the tables expected by the application +- An OpenAI API key for workout and meal-plan generation -- Node.js (v18+) -- Supabase account -- OpenAI API key +The versioned Supabase schema in `supabase/migrations` defines the application tables, constraints, indexes, and Row Level Security policies. Browser database access relies on those policies being applied to every environment. -### Installation +## Local Setup -```bash -# Clone the repository -git clone https://github.com/yourusername/fitness-app.git -cd fitness-app +1. Install dependencies: + + ```bash + npm install + ``` + +2. Create a local environment file from the documented template: + + ```bash + cp .env.example .env.local + ``` + +3. Fill in `.env.local` with your Supabase project values, application URL, and server-only OpenAI API key. + +4. Start the development server: + + ```bash + npm run dev + ``` + +5. Open [http://localhost:3000](http://localhost:3000). + +## Environment Variables + +| Variable | Scope | Purpose | +| --- | --- | --- | +| `NEXT_PUBLIC_SUPABASE_URL` | Browser and server | Supabase project URL | +| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Browser and server | Supabase anonymous key; requires appropriate Row Level Security | +| `NEXT_PUBLIC_SITE_URL` | Browser and server | Application origin used for authentication redirects | +| `NEXT_APP_OPENAI_API_KEY` | Server only | OpenAI credential used to generate workouts and meal plans | +| `E2E_AUTH_EMAIL` | Local/CI test runner only | Optional dedicated Supabase test-user email for authenticated Playwright flows | +| `E2E_AUTH_PASSWORD` | Local/CI test runner only | Optional dedicated Supabase test-user password for authenticated Playwright flows | + +Never commit `.env.local` or real credentials. The checked-in `.env.example` contains placeholders only. + +## Authentication Redirects + +Add the deployed application origin and `/auth/confirm` callback to the Supabase Authentication URL configuration. Password recovery sends users through `/auth/confirm?next=/reset-password`; the callback verifies either PKCE codes or email OTP token hashes and only permits local redirect paths. + +## Supabase Database -# Install dependencies -npm install +The `supabase` directory contains the local project configuration and versioned database migrations. The empty `20260611155633_remote_baseline.sql` marker represents the existing dashboard-managed schema already recorded remotely. The following reconciliation migration safely hardens those existing tables, preserves `food_logs`, enables consistent Row Level Security, prevents duplicate daily records, and defines the atomic `save_profile_with_tde` database function. -# Set up environment variables -cp .env.example .env.local -# Edit .env.local with your API keys +Before applying the reconciliation migration, run `supabase/preflight/20260611170000_reconcile_fitness_schema.sql` in the linked project and verify every query returns no rows. Do not restore or push the removed empty-database initial migration against the existing project. + +Install the [Supabase CLI](https://supabase.com/docs/guides/local-development/cli/getting-started). This migration history is currently based on an existing dashboard-managed project: the empty baseline marker aligns remote history, while the reconciliation migration assumes the existing tables are present. Do not run `supabase db reset` against this history until the committed remote schema snapshot has been promoted into a reproducible baseline migration. + +After reviewing the pending reconciliation migration, inspect and apply it to the linked remote project with: + +```bash +supabase link --project-ref +supabase migration list --linked +supabase db push --dry-run +supabase db push ``` -### Configuration +After changing the database schema, generate a temporary contract from the linked project and compare it with the checked-in TypeScript database contract: + +```bash +supabase gen types typescript --linked > supabase/remote-database.types.ts +``` -Add the following to your `.env.local` file: +Windows PowerShell 5.1 writes redirected output as UTF-16, which causes ESLint to report that the generated TypeScript file appears to be binary. Use an explicit UTF-8 encoding instead: +```powershell +npx supabase gen types typescript --linked | Out-File -Encoding utf8 supabase/remote-database.types.ts ``` -NEXT_PUBLIC_SUPABASE_URL=your-supabase-url -NEXT_PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key -NEXT_PUBLIC_SITE_URL=http://localhost:3000 -NEXT_APP_OPENAI_API_KEY=your-openai-api-key + +The temporary `supabase/remote-database.types.ts` comparison artifact is ignored by Git and ESLint. The checked-in database types intentionally include more specific JSON-column shapes used by the current application, so review regenerated JSON fields before replacing `app/lib/database.types.ts` and keep application validation aligned with the database contract. + +## Quality Checks + +Run the complete local quality gate: + +```bash +npm run check +npm run build +npm run test:e2e ``` -### Development +Or run checks individually: ```bash -npm run dev +npm run lint +npm run typecheck +npm test ``` -Visit [http://localhost:3000](http://localhost:3000) to see the application. +The test command compiles the selected TypeScript source and tests into the ignored `.test-dist` directory, then runs them with Node's built-in test runner. The baseline suite covers authentication and profile schemas, TDEE calculations, generated workout and meal-plan validation, Supabase client contracts, and the required migration contract. + +Playwright always runs the public authentication journeys. Authenticated protected-flow tests are included automatically only when both `E2E_AUTH_EMAIL` and `E2E_AUTH_PASSWORD` are set. Use a dedicated non-production Supabase user, keep those values in `.env.local` or CI secrets, and never commit real credentials. The authenticated setup stores browser state under the ignored `playwright/.auth/` directory. -## Documentation +## Available Scripts -For detailed documentation on components and API usage, see the [docs folder](/docs). +| Command | Description | +| --- | --- | +| `npm run dev` | Start the development server with Turbopack | +| `npm run build` | Create an optimized production build | +| `npm run start` | Start a previously built production application | +| `npm run lint` | Lint the repository with ESLint | +| `npm run typecheck` | Run the TypeScript compiler without emitting files | +| `npm test` | Compile and run the baseline unit tests | +| `npm run check` | Run lint, type-checking, and unit tests | +| `npm run test:e2e` | Run Playwright public journeys and, when E2E credentials are configured, authenticated protected-flow checks in Chromium | +| `npm run ci` | Run checks, production build, and end-to-end tests | -## License +## Current Productionization Status -MIT \ No newline at end of file +This repository is being hardened incrementally. The current baseline includes deterministic builds, explicit quality scripts, validated authentication recovery, profile and TDEE domains, user-local daily tracking, versioned Supabase schema and Row Level Security policies, and documented environment setup. AI-generated workout and meal-plan output is now validated before persistence and regeneration is non-destructive. Public authentication journeys run in CI, and authenticated Supabase protected-flow smoke tests run whenever dedicated E2E credentials are configured. Upcoming work should introduce production observability, mocked AI generation E2E coverage, and automated deployment controls. diff --git a/app/api/calculate-tde.ts b/app/api/calculate-tde.ts deleted file mode 100644 index 93ec6ce..0000000 --- a/app/api/calculate-tde.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { createClient } from "@/utils/supabase/server"; -import { NextApiRequest, NextApiResponse } from "next"; -import { calculateTDE } from "../lib/tde"; - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method !== "POST") { return res.status(405).end(); } - - const { id, age, weight, height, gender, activity_level } = req.body; - - // Input validation - if (!age || !weight || !height || !gender || !activity_level) { - return res.status(400).json({ error: "Missing required parameters" }); - } - - if (typeof age !== 'number' || typeof weight !== 'number' || typeof height !== 'number' || - typeof gender !== 'string' || typeof activity_level !== 'number') { - return res.status(400).json({ error: "Invalid parameter types" }); - } - - const tde = calculateTDE(age, weight, height, String(gender), String(activity_level)); - - - const supabase = createClient(); - - const { error } = await (await supabase) - .from('tde_estimates') - .insert([{ id, tde_value: tde, method: 'Mifflin-St Jeor' }]) - - if(error) { - // Use a generic error message instead of returning the raw error object - return res.status(500).json({ error: "An internal server error occurred" }); - } - - res.status(200).json({ tde }) - -} diff --git a/app/auth/confirm/route.ts b/app/auth/confirm/route.ts index 2714da1..b274aa6 100644 --- a/app/auth/confirm/route.ts +++ b/app/auth/confirm/route.ts @@ -1,32 +1,26 @@ import { type EmailOtpType } from '@supabase/supabase-js' +import { redirect } from 'next/navigation' import { type NextRequest } from 'next/server' +import { getSafeRedirectPath } from '@/app/lib/auth' import { createClient } from '@/utils/supabase/server' -import { redirect } from 'next/navigation' export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url) - const token_hash = searchParams.get('token_hash') + const tokenHash = searchParams.get('token_hash') const type = searchParams.get('type') as EmailOtpType | null - const next = searchParams.get('next') ?? '/' + const code = searchParams.get('code') + const next = getSafeRedirectPath(searchParams.get('next')) + const supabase = await createClient() - if (token_hash && type) { - const supabase = await createClient() + const { error } = code + ? await supabase.auth.exchangeCodeForSession(code) + : tokenHash && type + ? await supabase.auth.verifyOtp({ type, token_hash: tokenHash }) + : { error: new Error('Missing authentication callback token') } - const { error } = await supabase.auth.verifyOtp({ - type, - token_hash, - }) - if (!error) { - // redirect user to specified redirect URL or root of app - return redirect(next); - } else { - // Log the error and redirect to error page - console.error('OTP verification failed:', error.message); - return redirect('/error'); - } - } + if (!error) redirect(next) - // redirect the user to an error page with some instructions - return redirect('/error'); -} \ No newline at end of file + console.error('Authentication callback failed:', error.message) + redirect('/error') +} diff --git a/app/components/NavBar.tsx b/app/components/NavBar.tsx index f409015..f3fb4fe 100644 --- a/app/components/NavBar.tsx +++ b/app/components/NavBar.tsx @@ -1,3 +1,4 @@ +import Image from "next/image"; import Link from "next/link"; import { SignOut } from "./SignOut"; import { User } from "@supabase/supabase-js"; @@ -13,10 +14,11 @@ export function NavBar({ user }: NavBarProps) {
- FitTrack Pro Logo FitTrack Pro diff --git a/app/components/WeeklyProgress.tsx b/app/components/WeeklyProgress.tsx index d938377..ea63072 100644 --- a/app/components/WeeklyProgress.tsx +++ b/app/components/WeeklyProgress.tsx @@ -27,7 +27,7 @@ export default function WeeklyProgress() { const { data: { user } } = await supabase.auth.getUser() if (!user) return - const { weekStart, weekEnd } = getWeekDates() + const { weekStart, weekEnd, startDateKey, endDateKey } = getWeekDates() // Get workouts this week const { data: workouts, error: workoutsError } = await supabase @@ -42,16 +42,16 @@ export default function WeeklyProgress() { .from('water_intake') .select('*') .eq('user_id', user.id) - .gte('date', weekStart.toISOString().split('T')[0]) - .lte('date', weekEnd.toISOString().split('T')[0]) + .gte('date', startDateKey) + .lte('date', endDateKey) // Get meal plans followed this week const { data: mealPlans, error: mealError } = await supabase .from('meal_plans') .select('*') .eq('user_id', user.id) - .gte('date', weekStart.toISOString().split('T')[0]) - .lte('date', weekEnd.toISOString().split('T')[0]) + .gte('date', startDateKey) + .lte('date', endDateKey) if (workoutsError || waterError || mealError) { setError('Failed to load some weekly data.') diff --git a/app/components/WorkoutTemplates.tsx b/app/components/WorkoutTemplates.tsx index cb1b874..08b377d 100644 --- a/app/components/WorkoutTemplates.tsx +++ b/app/components/WorkoutTemplates.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from 'react' import { saveWorkoutSession, getTodayWorkoutPlan } from '@/app/lib/client-database' import { generateWorkoutPlan, regenerateWorkoutPlan } from '@/app/lib/workout-generator' +import { getLocalDateKey } from '@/app/lib/date' type Exercise = { name: string; @@ -31,9 +32,10 @@ export default function WorkoutTemplates() { useEffect(() => { async function loadWorkout() { try { - let plan = await getTodayWorkoutPlan() + const today = getLocalDateKey() + let plan = await getTodayWorkoutPlan(today) if (!plan) { - plan = await generateWorkoutPlan(30) + plan = await generateWorkoutPlan(30, today) } setWorkoutPlan(plan) setError(null) // Clear any previous errors @@ -51,7 +53,7 @@ export default function WorkoutTemplates() { setRegenerating(true) setError(null) try { - const newPlan = await regenerateWorkoutPlan(30) + const newPlan = await regenerateWorkoutPlan(30, getLocalDateKey()) setWorkoutPlan(newPlan) setCompletedExercises([]) setSelectedExercise(null) diff --git a/app/forgot-password/action.ts b/app/forgot-password/action.ts new file mode 100644 index 0000000..06333f4 --- /dev/null +++ b/app/forgot-password/action.ts @@ -0,0 +1,30 @@ +'use server' + +import { PasswordRecoverySchema, getSiteUrl } from '@/app/lib/auth' +import { createClient } from '@/utils/supabase/server' + +export type PasswordRecoveryState = { + success?: boolean + message?: string + errors?: { email?: string[] } +} | undefined + +export async function requestPasswordReset( + _state: PasswordRecoveryState, + formData: FormData, +): Promise { + const parsed = PasswordRecoverySchema.safeParse({ email: formData.get('email') }) + if (!parsed.success) return { errors: parsed.error.flatten().fieldErrors } + + const supabase = await createClient() + const { error } = await supabase.auth.resetPasswordForEmail(parsed.data.email, { + redirectTo: `${getSiteUrl()}/auth/confirm?next=/reset-password`, + }) + + if (error) console.error('Password recovery request failed:', error.message) + + return { + success: true, + message: 'If an account exists for that email, a password reset link has been sent.', + } +} diff --git a/app/forgot-password/page.tsx b/app/forgot-password/page.tsx new file mode 100644 index 0000000..d6a151e --- /dev/null +++ b/app/forgot-password/page.tsx @@ -0,0 +1,32 @@ +'use client' + +import Link from 'next/link' +import { useActionState } from 'react' + +import { requestPasswordReset } from './action' + +export default function ForgotPasswordPage() { + const [state, action, pending] = useActionState(requestPasswordReset, undefined) + + return ( +
+
+

Reset your password

+

Enter your email and we'll send you a secure reset link.

+ + {state?.message &&

{state.message}

} + +
+
+ + + {state?.errors?.email &&

{state.errors.email[0]}

} +
+ +
+ + Back to login +
+
+ ) +} diff --git a/app/globals.css b/app/globals.css index 5c9d4fd..7c4d8f5 100644 --- a/app/globals.css +++ b/app/globals.css @@ -18,8 +18,8 @@ @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); + --font-sans: Arial, Helvetica, sans-serif; + --font-mono: "Courier New", Courier, monospace; } @media (prefers-color-scheme: dark) { diff --git a/app/hooks/useWeekDates.ts b/app/hooks/useWeekDates.ts index 4acb475..ebad2ed 100644 --- a/app/hooks/useWeekDates.ts +++ b/app/hooks/useWeekDates.ts @@ -1,14 +1 @@ -export function getWeekDates() { - const now = new Date() - const weekStart = new Date(now) - weekStart.setHours(0, 0, 0, 0) - // Set to the first day of the week (Sunday) - weekStart.setDate(now.getDate() - now.getDay()) - - const weekEnd = new Date(weekStart) - // Set to the last day of the week (Saturday) - weekEnd.setDate(weekStart.getDate() + 6) - weekEnd.setHours(23, 59, 59, 999) - - return { weekStart, weekEnd } -} +export { getLocalWeekRange as getWeekDates } from '@/app/lib/date' diff --git a/app/layout.tsx b/app/layout.tsx index c02498e..34eb84d 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,19 +1,11 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { createClient } from "@/utils/supabase/server"; import { NavBar } from "./components/NavBar"; import { Footer } from "./components/Footer"; -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); +export const dynamic = 'force-dynamic' export const metadata: Metadata = { title: "FitTrack Pro", @@ -45,7 +37,7 @@ export default async function RootLayout({ return ( - +
{children} diff --git a/app/lib/auth.ts b/app/lib/auth.ts new file mode 100644 index 0000000..060d477 --- /dev/null +++ b/app/lib/auth.ts @@ -0,0 +1,32 @@ +import { z } from 'zod' + +export const EmailSchema = z.string().trim().email('Please enter a valid email address.') + +export const PasswordSchema = z.string() + .min(8, 'Password must be at least 8 characters long.') + .regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/, { + message: 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character.', + }) + +export const PasswordRecoverySchema = z.object({ + email: EmailSchema, +}) + +export const PasswordResetSchema = z.object({ + password: PasswordSchema, + confirmPassword: z.string(), +}).refine(({ password, confirmPassword }) => password === confirmPassword, { + message: 'Passwords do not match.', + path: ['confirmPassword'], +}) + +export function getSiteUrl() { + const configuredUrl = process.env.NEXT_PUBLIC_SITE_URL + if (!configuredUrl) throw new Error('NEXT_PUBLIC_SITE_URL is required') + + return new URL(configuredUrl).origin +} + +export function getSafeRedirectPath(value: string | null, fallback = '/') { + return value?.startsWith('/') && !value.startsWith('//') ? value : fallback +} diff --git a/app/lib/client-database.ts b/app/lib/client-database.ts index b4e599b..b0dadf9 100644 --- a/app/lib/client-database.ts +++ b/app/lib/client-database.ts @@ -1,5 +1,6 @@ 'use client' +import { getLocalDateKey, parseDateKey } from "@/app/lib/date" import { createClient } from "@/utils/supabase/client" export async function updateWaterIntake(glasses: number) { @@ -8,46 +9,19 @@ export async function updateWaterIntake(glasses: number) { if (!user) throw new Error('Not authenticated') - const today = new Date().toISOString().split('T')[0] + const today = getLocalDateKey() - // First check if a record exists for today - const { data: existingData } = await supabase + const { data, error } = await supabase .from('water_intake') - .select('*') - .eq('user_id', user.id) - .eq('date', today) - .maybeSingle() - - let result - - if (existingData) { - // Update existing record - const { data, error } = await supabase - .from('water_intake') - .update({ glasses_consumed: glasses }) - .match({ id: existingData.id, user_id: user.id }) - .select() - .single() - - if (error) throw error - result = data - } else { - // Insert new record - const { data, error } = await supabase - .from('water_intake') - .insert({ - user_id: user.id, - date: today, - glasses_consumed: glasses - }) - .select() - .single() - - if (error) throw error - result = data - } - - return result + .upsert( + { user_id: user.id, date: today, glasses_consumed: glasses }, + { onConflict: 'user_id,date' }, + ) + .select() + .single() + + if (error) throw error + return data } export async function getWaterIntake(date?: string) { @@ -56,7 +30,7 @@ export async function getWaterIntake(date?: string) { if (!user) throw new Error('Not authenticated') - const targetDate = date || new Date().toISOString().split('T')[0] + const targetDate = date ? parseDateKey(date) : getLocalDateKey() const { data, error } = await supabase .from('water_intake') @@ -102,13 +76,13 @@ export async function saveWorkoutSession(workout: { return data } -export async function getTodayWorkoutPlan() { +export async function getTodayWorkoutPlan(date = getLocalDateKey()) { const supabase = createClient() const { data: { user } } = await supabase.auth.getUser() if (!user) throw new Error('Not authenticated') - const today = new Date().toISOString().split('T')[0] + const today = parseDateKey(date) const { data, error } = await supabase .from('workout_plans') diff --git a/app/lib/database.types.ts b/app/lib/database.types.ts new file mode 100644 index 0000000..c4b3023 --- /dev/null +++ b/app/lib/database.types.ts @@ -0,0 +1,100 @@ +// Generated-compatible Supabase database contract. Refresh after every schema migration. +export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[] + +export type ExerciseJson = { + name: string + sets?: number + reps?: string + duration?: string + instructions: string + rest?: string +} + +export type MealPlanJson = string | { [key: string]: Json | undefined } + +type UserRelationship = { + foreignKeyName: string + columns: ['user_id'] + isOneToOne: false + referencedRelation: 'users' + referencedColumns: ['id'] +} + +export interface Database { + public: { + Tables: { + profiles: { + Row: { + id: string + age: number + weight_kg: number + height_cm: number + gender: 'male' | 'female' + activity_level: 'sedentary' | 'lightly_active' | 'moderately_active' | 'very_active' | 'extra_active' + goal: 'lose_weight' | 'build_muscle' | 'stay_fit' + created_at: string + updated_at: string + } + Insert: { + id: string + age: number + weight_kg: number + height_cm: number + gender: 'male' | 'female' + activity_level: 'sedentary' | 'lightly_active' | 'moderately_active' | 'very_active' | 'extra_active' + goal: 'lose_weight' | 'build_muscle' | 'stay_fit' + created_at?: string + updated_at?: string + } + Update: Partial + Relationships: [] + } + tde_estimates: { + Row: { id: number; user_id: string; tde_value: number; method: 'Mifflin-St Jeor'; created_at: string } + Insert: { id?: number; user_id: string; tde_value: number; method: 'Mifflin-St Jeor'; created_at?: string } + Update: Partial + Relationships: [UserRelationship] + } + water_intake: { + Row: { id: number; user_id: string; date: string; glasses_consumed: number; goal: number; created_at: string; updated_at: string } + Insert: { id?: number; user_id: string; date: string; glasses_consumed?: number; goal?: number; created_at?: string; updated_at?: string } + Update: Partial + Relationships: [UserRelationship] + } + workout_plans: { + Row: { id: number; user_id: string; date: string; workout_type: 'cardio' | 'strength' | 'full_body' | 'flexibility'; duration_minutes: number; difficulty: 'beginner' | 'intermediate' | 'advanced'; exercises: ExerciseJson[]; created_at: string; updated_at: string } + Insert: { id?: number; user_id: string; date: string; workout_type: 'cardio' | 'strength' | 'full_body' | 'flexibility'; duration_minutes: number; difficulty: 'beginner' | 'intermediate' | 'advanced'; exercises: ExerciseJson[]; created_at?: string; updated_at?: string } + Update: Partial + Relationships: [UserRelationship] + } + workout_sessions: { + Row: { id: number; user_id: string; workout_name: string; duration_minutes: number; exercises: ExerciseJson[]; completed_at: string; created_at: string } + Insert: { id?: number; user_id: string; workout_name: string; duration_minutes: number; exercises: ExerciseJson[]; completed_at?: string; created_at?: string } + Update: Partial + Relationships: [UserRelationship] + } + meal_plans: { + Row: { id: number; user_id: string; date: string; goal: 'lose_weight' | 'build_muscle' | 'stay_fit'; calories_target: number; meals: MealPlanJson; created_at: string; updated_at: string } + Insert: { id?: number; user_id: string; date: string; goal: 'lose_weight' | 'build_muscle' | 'stay_fit'; calories_target: number; meals: MealPlanJson; created_at?: string; updated_at?: string } + Update: Partial + Relationships: [UserRelationship] + } + } + Views: Record + Functions: { + save_profile_with_tde: { + Args: { + profile_age: number + profile_weight_kg: number + profile_height_cm: number + profile_gender: 'male' | 'female' + profile_activity_level: 'sedentary' | 'lightly_active' | 'moderately_active' | 'very_active' | 'extra_active' + profile_goal: 'lose_weight' | 'build_muscle' | 'stay_fit' + } + Returns: undefined + } + } + Enums: Record + CompositeTypes: Record + } +} diff --git a/app/lib/date.ts b/app/lib/date.ts new file mode 100644 index 0000000..71c59b0 --- /dev/null +++ b/app/lib/date.ts @@ -0,0 +1,39 @@ +import { z } from 'zod' + +export const DateKeySchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must use YYYY-MM-DD.').refine((value) => { + const [year, month, day] = value.split('-').map(Number) + const date = new Date(Date.UTC(year, month - 1, day)) + return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day +}, 'Date must be a valid calendar date.') + +const pad = (value: number) => String(value).padStart(2, '0') + +export function formatLocalDate(date: Date) { + if (Number.isNaN(date.getTime())) throw new Error('Date must be valid') + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +} + +export function getLocalDateKey() { + return formatLocalDate(new Date()) +} + +export function parseDateKey(value: string) { + return DateKeySchema.parse(value) +} + +export function getLocalWeekRange(reference = new Date()) { + const weekStart = new Date(reference) + weekStart.setHours(0, 0, 0, 0) + weekStart.setDate(weekStart.getDate() - weekStart.getDay()) + + const weekEnd = new Date(weekStart) + weekEnd.setDate(weekStart.getDate() + 6) + weekEnd.setHours(23, 59, 59, 999) + + return { + weekStart, + weekEnd, + startDateKey: formatLocalDate(weekStart), + endDateKey: formatLocalDate(weekEnd), + } +} diff --git a/app/lib/definitions.ts b/app/lib/definitions.ts index e58d089..7e2d62b 100644 --- a/app/lib/definitions.ts +++ b/app/lib/definitions.ts @@ -1,20 +1,21 @@ -import zod, { z } from 'zod'; +import { z } from 'zod'; -export const SignUpSchema = zod.object({ +export const SignUpSchema = z.object({ name: z .string() - .min(2, {message: "Name must be at least 2 characters long."}) - .trim(), + .trim() + .min(2, {message: "Name must be at least 2 characters long."}), email: z .string() - .email({ message: "Please enter a valid email address." }).trim(), + .trim() + .email({ message: "Please enter a valid email address." }), password: z .string() + .trim() .min(8, {message: "Password must be at least 8 characters long."}) .regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/, { message: "Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character." - }) - .trim(), + }), // confirmPassword: z // .string() // .trim(), @@ -23,10 +24,11 @@ export const SignUpSchema = zod.object({ // path: ["confirmPassword"], }); -export const LoginSchema = zod.object({ +export const LoginSchema = z.object({ email: z .string() - .email({ message: "Please enter a valid email address." }).trim(), + .trim() + .email({ message: "Please enter a valid email address." }), password: z .string() .trim(), @@ -41,12 +43,3 @@ export type FormState = } } | undefined - -export type Profile = { - age: string - weight_kg: string - height_cm: string - gender: string - activity_level: string, - goal: string, -} \ No newline at end of file diff --git a/app/lib/generated-plans.ts b/app/lib/generated-plans.ts new file mode 100644 index 0000000..02d533c --- /dev/null +++ b/app/lib/generated-plans.ts @@ -0,0 +1,67 @@ +import { z } from 'zod' + +export const WORKOUT_TYPES = ['cardio', 'strength', 'full_body', 'flexibility'] as const +export const WORKOUT_DIFFICULTIES = ['beginner', 'intermediate', 'advanced'] as const + +const boundedText = (field: string, max: number) => + z.string().trim().min(1, `${field} is required.`).max(max, `${field} is too long.`) + +export const WorkoutDurationSchema = z.number() + .int('Workout duration must be a whole number.') + .min(10, 'Workout duration must be at least 10 minutes.') + .max(120, 'Workout duration must be at most 120 minutes.') + +export const ExerciseSchema = z.object({ + name: boundedText('Exercise name', 120), + sets: z.number().int().min(1).max(20).optional(), + reps: boundedText('Exercise reps', 60).optional(), + duration: boundedText('Exercise duration', 60).optional(), + instructions: boundedText('Exercise instructions', 600), + rest: boundedText('Exercise rest', 60).optional(), +}).strict() + +export const GeneratedWorkoutPlanSchema = z.object({ + workout_type: z.enum(WORKOUT_TYPES), + difficulty: z.enum(WORKOUT_DIFFICULTIES), + exercises: z.array(ExerciseSchema).min(1).max(30), +}).strict() + +export const MealPlanContentSchema = z.object({ + Meals: z.object({ + Breakfast: boundedText('Breakfast', 1000), + Lunch: boundedText('Lunch', 1000), + Dinner: boundedText('Dinner', 1000), + Snacks: boundedText('Snacks', 1000), + }).strict(), +}).strict() + +export type GeneratedWorkoutPlan = z.infer +export type MealPlanContent = z.infer + +export function parseGeneratedContent(content: string | null | undefined, schema: z.ZodType): T { + if (!content?.trim()) { + throw new Error('Generation returned no content') + } + + let parsed: unknown + try { + parsed = JSON.parse(content) + } catch { + throw new Error('Generation returned invalid JSON') + } + + const result = schema.safeParse(parsed) + if (!result.success) { + throw new Error('Generation returned an invalid plan structure') + } + + return result.data +} + +export function parseMealPlanContent(value: unknown): MealPlanContent { + const parsed = typeof value === 'string' + ? parseGeneratedContent(value, MealPlanContentSchema) + : MealPlanContentSchema.parse(value) + + return parsed +} diff --git a/app/lib/profile-options.ts b/app/lib/profile-options.ts new file mode 100644 index 0000000..85d862a --- /dev/null +++ b/app/lib/profile-options.ts @@ -0,0 +1,34 @@ +export const GENDERS = ['male', 'female'] as const +export const ACTIVITY_LEVELS = [ + 'sedentary', + 'lightly_active', + 'moderately_active', + 'very_active', + 'extra_active', +] as const +export const FITNESS_GOALS = ['lose_weight', 'build_muscle', 'stay_fit'] as const + +export type Gender = (typeof GENDERS)[number] +export type ActivityLevel = (typeof ACTIVITY_LEVELS)[number] +export type FitnessGoal = (typeof FITNESS_GOALS)[number] +export type ProfileField = 'age' | 'weight_kg' | 'height_cm' | 'gender' | 'activity_level' | 'goal' +export type ProfileFieldErrors = Partial> + +export const GENDER_OPTIONS: ReadonlyArray<{ value: Gender; label: string }> = [ + { value: 'male', label: 'Male' }, + { value: 'female', label: 'Female' }, +] + +export const ACTIVITY_LEVEL_OPTIONS: ReadonlyArray<{ value: ActivityLevel; label: string }> = [ + { value: 'sedentary', label: 'Sedentary (little or no exercise)' }, + { value: 'lightly_active', label: 'Lightly Active (light exercise 1-3 days/week)' }, + { value: 'moderately_active', label: 'Moderately Active (moderate exercise 3-5 days/week)' }, + { value: 'very_active', label: 'Very Active (hard exercise 6-7 days/week)' }, + { value: 'extra_active', label: 'Extra Active (very hard exercise & physical job)' }, +] + +export const FITNESS_GOAL_OPTIONS: ReadonlyArray<{ value: FitnessGoal; label: string }> = [ + { value: 'lose_weight', label: 'Lose Weight' }, + { value: 'build_muscle', label: 'Build Muscle' }, + { value: 'stay_fit', label: 'Stay Fit' }, +] diff --git a/app/lib/profile.ts b/app/lib/profile.ts new file mode 100644 index 0000000..9e83b55 --- /dev/null +++ b/app/lib/profile.ts @@ -0,0 +1,36 @@ +import { z } from 'zod' + +import { ACTIVITY_LEVELS, FITNESS_GOALS, GENDERS } from './profile-options' + +const numberFromForm = (schema: z.ZodNumber) => + z.preprocess((value) => { + if (typeof value === 'string' && value.trim() !== '') { + return Number(value) + } + + return value + }, schema) + +export const ProfileSchema = z.object({ + age: numberFromForm( + z.number({ invalid_type_error: 'Age must be a valid number.' }) + .int('Age must be a whole number.') + .min(1, 'Age must be at least 1.') + .max(120, 'Age must be at most 120.'), + ), + weight_kg: numberFromForm( + z.number({ invalid_type_error: 'Weight must be a valid number.' }) + .min(20, 'Weight must be at least 20 kg.') + .max(300, 'Weight must be at most 300 kg.'), + ), + height_cm: numberFromForm( + z.number({ invalid_type_error: 'Height must be a valid number.' }) + .min(50, 'Height must be at least 50 cm.') + .max(250, 'Height must be at most 250 cm.'), + ), + gender: z.enum(GENDERS, { message: 'Select a supported gender.' }), + activity_level: z.enum(ACTIVITY_LEVELS, { message: 'Select a supported activity level.' }), + goal: z.enum(FITNESS_GOALS, { message: 'Select a supported fitness goal.' }), +}) + +export type Profile = z.infer diff --git a/app/lib/tde.ts b/app/lib/tde.ts index ddfd8da..5b3e597 100644 --- a/app/lib/tde.ts +++ b/app/lib/tde.ts @@ -1,27 +1,41 @@ -export function calculateTDE(weight: number, height: number, age: number, gender: string, activity: string): number { - // Input validation - if (weight <= 0 || height <= 0 || age <= 0) { - throw new Error('Weight, height, and age must be positive numbers'); +import { ACTIVITY_LEVELS, GENDERS, type ActivityLevel, type Gender } from './profile-options' + +export interface TDEInput { + weightKg: number + heightCm: number + age: number + gender: Gender + activityLevel: ActivityLevel +} + +const activityMultipliers: Record = { + sedentary: 1.2, + lightly_active: 1.375, + moderately_active: 1.55, + very_active: 1.725, + extra_active: 1.9, +} + +export function calculateTDE({ + weightKg, + heightCm, + age, + gender, + activityLevel, +}: TDEInput): number { + if (![weightKg, heightCm, age].every(Number.isFinite) || weightKg <= 0 || heightCm <= 0 || age <= 0) { + throw new Error('Weight, height, and age must be positive finite numbers') } - if (gender !== 'male' && gender !== 'female') { - throw new Error('Gender must be either "male" or "female"'); + if (!GENDERS.includes(gender)) { + throw new Error('Gender must be either "male" or "female"') } - if (!['sedentary', 'lightly_active', 'moderately_active', 'very_active', 'extra_active'].includes(activity)) { - throw new Error('Invalid activity level'); + if (!ACTIVITY_LEVELS.includes(activityLevel)) { + throw new Error('Invalid activity level') } const bmr = gender === 'male' - ? 10 * weight + 6.25 * height - 5 * age + 5 - : 10 * weight + 6.25 * height - 5 * age - 161 - - - const activityMultiplier: Record = { - sedentary: 1.2, - lightly_active: 1.375, - moderately_active: 1.55, - very_active: 1.725, - extra_active: 1.9, - } + ? 10 * weightKg + 6.25 * heightCm - 5 * age + 5 + : 10 * weightKg + 6.25 * heightCm - 5 * age - 161 - return Math.round(bmr * (activityMultiplier[activity] || 1.2)) + return Math.round(bmr * activityMultipliers[activityLevel]) } diff --git a/app/lib/workout-generator.ts b/app/lib/workout-generator.ts index 25a64c1..d2ad59f 100644 --- a/app/lib/workout-generator.ts +++ b/app/lib/workout-generator.ts @@ -1,113 +1,92 @@ 'use server' -import { createClient } from "@/utils/supabase/server" -import OpenAI from "openai" +import OpenAI from 'openai' + +import { parseDateKey } from '@/app/lib/date' +import { GeneratedWorkoutPlanSchema, WorkoutDurationSchema, parseGeneratedContent } from '@/app/lib/generated-plans' +import { ProfileSchema } from '@/app/lib/profile' +import { createClient } from '@/utils/supabase/server' const openai = new OpenAI({ apiKey: process.env.NEXT_APP_OPENAI_API_KEY, + maxRetries: 1, + timeout: 30_000, }) -export async function generateWorkoutPlan(duration: number = 30) { +async function createAndSaveWorkoutPlan(durationInput: number, dateInput: string) { + const duration = WorkoutDurationSchema.parse(durationInput) + const date = parseDateKey(dateInput) const supabase = await createClient() const { data: { user } } = await supabase.auth.getUser() if (!user) throw new Error('Not logged in') - - const today = new Date().toISOString().split('T')[0] - - // Check if workout already exists for today - const { data: existing } = await supabase - .from('workout_plans') - .select('*') - .eq('user_id', user.id) - .eq('date', today) - .single() - - if (existing) return existing - - // Get user profile - const { data: profile } = await supabase + + const { data: profileData, error: profileError } = await supabase .from('profiles') .select('*') .eq('id', user.id) .single() - - const prompt = `Create a personalized ${duration}-minute workout plan based on: - -User Profile: -- Goal: ${profile?.goal || 'stay_fit'} -- Gender: ${profile?.gender || 'male'} -- Age: ${profile?.age || 30} -- Activity Level: ${profile?.activity_level || 'moderately_active'} - -Return ONLY a valid JSON object: -{ - "workout_type": "cardio|strength|full_body|flexibility", - "difficulty": "beginner|intermediate|advanced", - "exercises": [ - { - "name": "Exercise name", - "sets": 3, - "reps": "10-12", - "duration": "30 seconds", - "rest": "30 seconds", - "instructions": "Brief how-to" - } - ] -} - -Make it bodyweight-only, realistic for the duration, and match the user's fitness goal.` - try { - const response = await openai.chat.completions.create({ - model: "gpt-4", - messages: [{ role: "system", content: prompt }], - response_format: { type: "text" }, - temperature: 0.7 - }) - - const workoutData = JSON.parse(response.choices[0].message.content!) - - console.log('Generated workout data:', workoutData) - - const { data: newPlan, error } = await supabase - .from('workout_plans') - .insert({ - user_id: user.id, - date: today, - workout_type: workoutData.workout_type, - duration_minutes: duration, - difficulty: workoutData.difficulty, - exercises: workoutData.exercises - }) - .select() - .single() - - if (error) throw error - return newPlan - } catch (error) { - console.error('Error generating workout plan:', error) - throw new Error('Failed to generate workout plan') + if (profileError || !profileData) { + throw new Error('Complete your profile before generating a workout plan') } + + const profile = ProfileSchema.parse(profileData) + const prompt = `Create a personalized ${duration}-minute bodyweight workout plan for a user with goal ${profile.goal}, gender ${profile.gender}, age ${profile.age}, and activity level ${profile.activity_level}. Return a JSON object with workout_type, difficulty, and a non-empty exercises array. Each exercise must include name and instructions, and may include sets, reps, duration, and rest.` + + const response = await openai.chat.completions.create({ + model: 'gpt-4.1', + messages: [{ role: 'system', content: prompt }], + response_format: { type: 'json_object' }, + temperature: 0.7, + }) + + const workoutData = parseGeneratedContent( + response.choices[0]?.message.content, + GeneratedWorkoutPlanSchema, + ) + + const { data: plan, error } = await supabase + .from('workout_plans') + .upsert({ + user_id: user.id, + date, + workout_type: workoutData.workout_type, + duration_minutes: duration, + difficulty: workoutData.difficulty, + exercises: workoutData.exercises, + }, { onConflict: 'user_id,date' }) + .select() + .single() + + if (error) throw error + return plan } -export async function regenerateWorkoutPlan(duration: number = 30) { +export async function generateWorkoutPlan(duration: number, dateInput: string) { + const validatedDuration = WorkoutDurationSchema.parse(duration) + const date = parseDateKey(dateInput) const supabase = await createClient() const { data: { user } } = await supabase.auth.getUser() if (!user) throw new Error('Not logged in') - - const today = new Date().toISOString().split('T')[0] - + + const { data: existing, error } = await supabase + .from('workout_plans') + .select('*') + .eq('user_id', user.id) + .eq('date', date) + .maybeSingle() + + if (error) throw new Error('Failed to load workout plan') + if (existing) return existing + + return createAndSaveWorkoutPlan(validatedDuration, date) +} + +export async function regenerateWorkoutPlan(duration: number, dateInput: string) { try { - // Delete existing plan - await supabase - .from('workout_plans') - .delete() - .eq('user_id', user.id) - .eq('date', today) - - return await generateWorkoutPlan(duration) + return await createAndSaveWorkoutPlan(duration, parseDateKey(dateInput)) } catch (error) { console.error('Error regenerating workout plan:', error) throw new Error('Failed to regenerate workout plan') } -} \ No newline at end of file +} diff --git a/app/login/page.tsx b/app/login/page.tsx index 5c88a5f..2628830 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -90,6 +90,11 @@ export default function LoginForm() { {state?.errors?.password && (

{state.errors.password}

)} +
+ + Forgot your password? + +
diff --git a/app/protected/profile/action.ts b/app/protected/profile/action.ts index 0db9a28..7e6227e 100644 --- a/app/protected/profile/action.ts +++ b/app/protected/profile/action.ts @@ -1,66 +1,53 @@ 'use server' -import { calculateTDE } from "@/app/lib/tde"; -import { createClient } from "@/utils/supabase/server"; -import { redirect } from "next/navigation"; - -export async function createProfile(formData: FormData) { - const supabase = await createClient(); - - const age = formData.get('age') as string; - const weight_kg = formData.get('weight_kg') as string; - const height_cm = formData.get('height_cm') as string; - const gender = formData.get('gender') as string; - const activity_level = formData.get('activity_level') as string; - const goal = formData.get('goal') as string; - - // Validate numeric fields - const ageNum = Number(age); - const weightNum = Number(weight_kg); - const heightNum = Number(height_cm); - - if (isNaN(ageNum) || isNaN(weightNum) || isNaN(heightNum)) { - return { error: "Age, weight, and height must be valid numbers" }; - } - - // Additional validation if needed (e.g., positive values) - if (ageNum <= 0 || weightNum <= 0 || heightNum <= 0) { - return { error: "Age, weight, and height must be positive values" }; - } - - const { data: { user } } = await supabase.auth.getUser(); - - if(!user) { - return { error: "User not found" }; - } - - const { error: profileError } = await supabase - .from('profiles') - .upsert({ - id: user.id, - age: Number(age), - weight_kg: Number(weight_kg), - height_cm: Number(height_cm), - gender, - activity_level, - goal, - }).select(); - - if(profileError) { - return { error: profileError.message }; +import { type ProfileFieldErrors } from '@/app/lib/profile-options' +import { ProfileSchema } from '@/app/lib/profile' +import { createClient } from '@/utils/supabase/server' +import { redirect } from 'next/navigation' + +export type ProfileActionResult = + | { success: false; message: string; fieldErrors?: ProfileFieldErrors } + | { success: true } + +export async function createProfile(formData: FormData): Promise { + const parsedProfile = ProfileSchema.safeParse({ + age: formData.get('age'), + weight_kg: formData.get('weight_kg'), + height_cm: formData.get('height_cm'), + gender: formData.get('gender'), + activity_level: formData.get('activity_level'), + goal: formData.get('goal'), + }) + + if (!parsedProfile.success) { + return { + success: false, + message: 'Review the highlighted profile fields and try again.', + fieldErrors: parsedProfile.error.flatten().fieldErrors, } - - - const tde = calculateTDE(Number(weight_kg), Number(height_cm), Number(age), gender, activity_level); - - const { error: tdeError } = await supabase - .from('tde_estimates') - .insert({ user_id: user.id, tde_value: tde, method: 'Mifflin-St Jeor' }) - .select(); - - if(tdeError) { - return { error: tdeError.message }; - } - - redirect('/protected'); + } + + const profile = parsedProfile.data + const supabase = await createClient() + const { data: { user }, error: userError } = await supabase.auth.getUser() + + if (userError || !user) { + return { success: false, message: 'You must be signed in to update your profile.' } + } + + const { error: saveError } = await supabase.rpc('save_profile_with_tde', { + profile_age: profile.age, + profile_weight_kg: profile.weight_kg, + profile_height_cm: profile.height_cm, + profile_gender: profile.gender, + profile_activity_level: profile.activity_level, + profile_goal: profile.goal, + }) + + if (saveError) { + console.error('Failed to save profile and TDEE estimate:', saveError.message) + return { success: false, message: 'We could not save your profile. Please try again.' } + } + + redirect('/protected') } diff --git a/app/protected/profile/meal-plan/action.ts b/app/protected/profile/meal-plan/action.ts index 351da53..da09990 100644 --- a/app/protected/profile/meal-plan/action.ts +++ b/app/protected/profile/meal-plan/action.ts @@ -1,157 +1,108 @@ 'use server' -import { createClient } from "@/utils/supabase/server" -import OpenAI from "openai"; +import OpenAI from 'openai' + +import { parseDateKey } from '@/app/lib/date' +import { MealPlanContentSchema, parseGeneratedContent } from '@/app/lib/generated-plans' +import { ProfileSchema } from '@/app/lib/profile' +import { createClient } from '@/utils/supabase/server' const openai = new OpenAI({ apiKey: process.env.NEXT_APP_OPENAI_API_KEY, -}); - - -export async function regenerateMealPlan() { - const supabase = await createClient(); - const { data: { user } } = await supabase.auth.getUser(); - if (!user) throw new Error('Not logged in'); - - const today = new Date().toISOString().split('T')[0] - - // Delete existing meal plan for today - const { error: deleteError } = await supabase - .from('meal_plans') - .delete() - .eq('user_id', user.id) - .eq('date', today) - - // Handle potential deletion error - if (deleteError) { - console.error('Error deleting existing meal plan:', deleteError) - throw new Error('Failed to reset meal plan') - } - - // Generate new meal plan - return getMeal() + maxRetries: 1, + timeout: 30_000, +}) + +async function createAndSaveMealPlan(dateInput: string) { + const date = parseDateKey(dateInput) + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) throw new Error('Not logged in') + + const { data: profileData, error: profileError } = await supabase + .from('profiles') + .select('*') + .eq('id', user.id) + .single() + + if (profileError || !profileData) { + throw new Error('Complete your profile before generating a meal plan') + } + const profile = ProfileSchema.parse(profileData) + + const { data: tdeEstimate, error: tdeError } = await supabase + .from('tde_estimates') + .select('*') + .eq('user_id', user.id) + .order('created_at', { ascending: false }) + .limit(1) + .single() + + if (tdeError || !tdeEstimate || tdeEstimate.tde_value <= 0) { + throw new Error('A valid TDEE estimate is required to create a meal plan') + } + + const prompt = `Create a balanced, realistic one-day meal plan for a user with goal ${profile.goal}, gender ${profile.gender}, weight ${profile.weight_kg} kg, height ${profile.height_cm} cm, activity level ${profile.activity_level}, and TDEE ${tdeEstimate.tde_value} calories. Return a JSON object with a Meals object containing non-empty Breakfast, Lunch, Dinner, and Snacks strings with specific portions.` + + const response = await openai.chat.completions.create({ + model: 'gpt-4.1', + messages: [{ role: 'system', content: prompt }], + response_format: { type: 'json_object' }, + temperature: 0.7, + }) + + const meals = parseGeneratedContent(response.choices[0]?.message.content, MealPlanContentSchema) + const caloriesTarget = profile.goal === 'lose_weight' + ? tdeEstimate.tde_value - 500 + : profile.goal === 'build_muscle' + ? tdeEstimate.tde_value + 300 + : tdeEstimate.tde_value + + const { data: mealPlan, error: insertError } = await supabase + .from('meal_plans') + .upsert({ + user_id: user.id, + date, + goal: profile.goal, + calories_target: caloriesTarget, + meals, + }, { onConflict: 'user_id,date' }) + .select() + .single() + + if (insertError) throw new Error('Error creating meal plan') + return mealPlan } -export async function getMeal() { - - - // Removed unused dummy meals - - const supabase = await createClient(); - - const { data: { user } } = await supabase.auth.getUser(); - if (!user) { - throw new Error('Not logged in'); - } - - const today = new Date().toISOString().split('T')[0] - - try { - const { data: meal_plans, error } = await supabase - .from('meal_plans') - .select('*') - .eq('user_id', user.id) - .eq('date', today) - .single() - - if (!meal_plans) { - // No plan today — generate one - const { data: profile, error: profileError } = await supabase - .from('profiles') - .select('*') - .eq('id', user.id) - .single() - - if (profileError) { - throw new Error('Error fetching profile'); - } - - const { data: tde_estimates, error: tdeError } = await supabase - .from('tde_estimates') - .select('*') - .eq('user_id', user.id) - .order('id', { ascending: false }) - .limit(1) - .single() - - if (tdeError) { - - throw new Error('Error fetching TDE data'); - } - - const tdeeValue = tde_estimates?.tde_value || null; - const userGoal = profile?.goal || 'stay_fit'; - - const prompt = `Create a personalized one-day meal plan based on the following profile: - -User Profile: -- Goal: ${profile?.goal} -- Gender: ${profile?.gender} -- Weight: ${profile?.weight_kg} kg -- Height: ${profile?.height_cm} cm -- Activity Level: ${profile?.activity_level} -- TDEE: ${tdeeValue} calories - -Return ONLY a valid JSON object with this exact structure: -{ - "Meals": { - "Breakfast": "specific meal with portions", - "Lunch": "specific meal with portions", - "Dinner": "specific meal with portions", - "Snacks": "specific snacks with portions" +export async function regenerateMealPlan(dateInput: string) { + try { + return await createAndSaveMealPlan(dateInput) + } catch (error) { + console.error('Meal plan regeneration error:', error) + throw new Error('Failed to regenerate meal plan') } } -Ensure meals are balanced, realistic, and align with the user's fitness goal and caloric needs.` - - const response = await openai.chat.completions.create({ - model: "gpt-4.1", - messages : [ - { - role: "system", - content: prompt - } - ], - - response_format: { - "type": "text" - }, - temperature: 1, - top_p: 1, - frequency_penalty: 0, - presence_penalty: 0 - }); - const calories_target = userGoal === 'lose_weight' ? tdeeValue - 500 : - userGoal === 'build_muscle' ? tdeeValue + 300 : tdeeValue; - - console.log(response.choices[0].message.content) - - const { data: newMealPlan, error: insertError } = await supabase - .from('meal_plans') - .insert({ - user_id: user.id, - date: today, - goal: userGoal, - calories_target: calories_target, - meals: response.choices[0].message.content - }) - .select() - .single() - - if (insertError) { - throw new Error('Error creating meal plan'); - } - - return newMealPlan; - } - - if (error) { - throw new Error('Error fetching meal plan'); - } - - return meal_plans; - } catch (err) { - console.error('Meal plan error:', err); - throw err; - } -} \ No newline at end of file +export async function getMeal(dateInput: string) { + const date = parseDateKey(dateInput) + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) throw new Error('Not logged in') + + const { data: mealPlan, error } = await supabase + .from('meal_plans') + .select('*') + .eq('user_id', user.id) + .eq('date', date) + .maybeSingle() + + if (error) throw new Error('Error fetching meal plan') + if (mealPlan) return mealPlan + + try { + return await createAndSaveMealPlan(date) + } catch (generationError) { + console.error('Meal plan generation error:', generationError) + throw new Error('Failed to generate meal plan') + } +} diff --git a/app/protected/profile/meal-plan/page.tsx b/app/protected/profile/meal-plan/page.tsx index eb3de02..ff57497 100644 --- a/app/protected/profile/meal-plan/page.tsx +++ b/app/protected/profile/meal-plan/page.tsx @@ -3,6 +3,8 @@ import { useEffect, useState } from 'react' import { getMeal, regenerateMealPlan } from './action' import Link from 'next/link' +import { getLocalDateKey } from '@/app/lib/date' +import { parseMealPlanContent } from '@/app/lib/generated-plans' export default function MealPlanPage() { const [plan, setPlan] = useState<{ @@ -18,7 +20,7 @@ export default function MealPlanPage() { useEffect(() => { async function fetchMealPlan() { try { - const mealPlan = await getMeal() + const mealPlan = await getMeal(getLocalDateKey()) setPlan(mealPlan) } catch (err) { setError('Failed to load meal plan') @@ -34,7 +36,7 @@ export default function MealPlanPage() { const handleRegenerate = async () => { setRegenerating(true) try { - const newPlan = await regenerateMealPlan() + const newPlan = await regenerateMealPlan(getLocalDateKey()) setPlan(newPlan) } catch (error) { setError('Failed to regenerate meal plan') @@ -64,9 +66,16 @@ export default function MealPlanPage() {
) - // Parse meals if it's stored as a JSON string - const mealsData = typeof plan.meals === 'string' ? JSON.parse(plan.meals) : plan.meals - const meals = mealsData?.Meals || mealsData + let meals + try { + meals = parseMealPlanContent(plan.meals).Meals + } catch { + return ( +
+

The saved meal plan is invalid. Generate a new plan to continue.

+
+ ) + } const mealIcons = { Breakfast: "🍳", diff --git a/app/protected/profile/page.tsx b/app/protected/profile/page.tsx index 5d91ac9..3c7e735 100644 --- a/app/protected/profile/page.tsx +++ b/app/protected/profile/page.tsx @@ -1,156 +1,175 @@ 'use client' -import { useState } from "react" -import { createProfile } from "./action" -import Link from "next/link" +import Link from 'next/link' +import { useState } from 'react' + +import { + ACTIVITY_LEVEL_OPTIONS, + FITNESS_GOAL_OPTIONS, + GENDER_OPTIONS, + type ProfileField, + type ProfileFieldErrors, +} from '@/app/lib/profile-options' +import { createProfile } from './action' + +function FieldError({ field, errors }: { field: ProfileField; errors: ProfileFieldErrors }) { + const message = errors[field]?.[0] + + if (!message) return null + + return

{message}

+} export default function ProfileSetup() { - const [isLoading, setIsLoading] = useState(false) - const [errorMessage, setError] = useState(null) - const [success, setSuccess] = useState(false) - - const handleSubmit = async (formData: FormData) => { - setIsLoading(true) - setError(null) - setSuccess(false) - try { - const result = await createProfile(formData) - if (result?.error) { - setError(result.error) - } else { - setSuccess(true) - } - } catch { - setError("Error creating profile. Please try again.") - } finally { - setIsLoading(false) - } + const [isLoading, setIsLoading] = useState(false) + const [errorMessage, setErrorMessage] = useState(null) + const [fieldErrors, setFieldErrors] = useState({}) + + const handleSubmit = async (formData: FormData) => { + setIsLoading(true) + setErrorMessage(null) + setFieldErrors({}) + + try { + const result = await createProfile(formData) + if (!result.success) { + setErrorMessage(result.message) + setFieldErrors(result.fieldErrors ?? {}) + } + } catch { + setErrorMessage('Error creating profile. Please try again.') + } finally { + setIsLoading(false) } + } + + const fieldProps = (field: ProfileField) => ({ + 'aria-describedby': fieldErrors[field] ? `${field}-error` : undefined, + 'aria-invalid': fieldErrors[field] ? true : undefined, + }) + + return ( +
+
+

Set Up Your Profile

+ + Back to Dashboard + +
+ + {errorMessage && ( +
+ {errorMessage} +
+ )} + +
+
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + Skip for now + - return ( -
-
-

Set Up Your Profile

- - Back to Dashboard - -
- - {errorMessage && ( -
- {errorMessage} -
- )} - - {success && ( -
- Profile updated successfully! - - View your meal plan - -
- )} - - -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - Skip for now - - - -
- +
- ) -} \ No newline at end of file + +
+ ) +} diff --git a/app/reset-password/action.ts b/app/reset-password/action.ts new file mode 100644 index 0000000..c9eb445 --- /dev/null +++ b/app/reset-password/action.ts @@ -0,0 +1,36 @@ +'use server' + +import { PasswordResetSchema } from '@/app/lib/auth' +import { createClient } from '@/utils/supabase/server' + +export type PasswordResetState = { + success?: boolean + message?: string + errors?: { password?: string[]; confirmPassword?: string[] } +} | undefined + +export async function updatePassword( + _state: PasswordResetState, + formData: FormData, +): Promise { + const parsed = PasswordResetSchema.safeParse({ + password: formData.get('password'), + confirmPassword: formData.get('confirmPassword'), + }) + if (!parsed.success) return { errors: parsed.error.flatten().fieldErrors } + + const supabase = await createClient() + const { data: { user }, error: userError } = await supabase.auth.getUser() + if (userError || !user) { + return { message: 'This password reset link is invalid or has expired. Request a new link.' } + } + + const { error } = await supabase.auth.updateUser({ password: parsed.data.password }) + if (error) { + console.error('Password update failed:', error.message) + return { message: 'We could not update your password. Request a new reset link and try again.' } + } + + await supabase.auth.signOut() + return { success: true, message: 'Your password has been updated. You can now log in.' } +} diff --git a/app/reset-password/page.tsx b/app/reset-password/page.tsx new file mode 100644 index 0000000..11c8469 --- /dev/null +++ b/app/reset-password/page.tsx @@ -0,0 +1,41 @@ +'use client' + +import Link from 'next/link' +import { useActionState } from 'react' + +import { updatePassword } from './action' + +export default function ResetPasswordPage() { + const [state, action, pending] = useActionState(updatePassword, undefined) + + return ( +
+
+

Choose a new password

+

Use at least eight characters with uppercase, lowercase, number, and special character.

+ + {state?.message &&

{state.message}

} + + {state?.success ? ( + Continue to login + ) : ( +
+
+ + + {state?.errors?.password &&

{state.errors.password[0]}

} +
+
+ + + {state?.errors?.confirmPassword &&

{state.errors.confirmPassword[0]}

} +
+ +
+ )} + + {!state?.success && Request a new reset link} +
+
+ ) +} diff --git a/e2e/auth.setup.ts b/e2e/auth.setup.ts new file mode 100644 index 0000000..238d1e9 --- /dev/null +++ b/e2e/auth.setup.ts @@ -0,0 +1,25 @@ +import { mkdirSync } from 'node:fs' +import { dirname } from 'node:path' + +import { expect, test } from '@playwright/test' + +const authFile = 'playwright/.auth/user.json' +const email = process.env.E2E_AUTH_EMAIL +const password = process.env.E2E_AUTH_PASSWORD + +test('authenticate as the dedicated E2E user', async ({ page }) => { + if (!email || !password) { + throw new Error('E2E_AUTH_EMAIL and E2E_AUTH_PASSWORD are required for authenticated E2E setup.') + } + + await page.goto('/login') + await page.getByLabel('Email address').fill(email) + await page.getByLabel('Password', { exact: true }).fill(password) + await page.getByRole('button', { name: 'Log in' }).click() + + await expect(page).toHaveURL(/\/protected$/) + await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible() + + mkdirSync(dirname(authFile), { recursive: true }) + await page.context().storageState({ path: authFile }) +}) diff --git a/e2e/protected-flow.spec.ts b/e2e/protected-flow.spec.ts new file mode 100644 index 0000000..f9823bd --- /dev/null +++ b/e2e/protected-flow.spec.ts @@ -0,0 +1,24 @@ +import { expect, test } from '@playwright/test' + +test.describe('authenticated protected flows', () => { + test('loads the protected dashboard with authenticated storage', async ({ page }) => { + await page.goto('/protected') + + await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Overview' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Workouts' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Progress' })).toBeVisible() + await expect(page.getByRole('link', { name: 'Profile' }).first()).toHaveAttribute('href', '/protected/profile') + }) + + test('opens the protected profile setup form', async ({ page }) => { + await page.goto('/protected/profile') + + await expect(page.getByRole('heading', { name: 'Set Up Your Profile' })).toBeVisible() + await expect(page.getByLabel('Age')).toBeVisible() + await expect(page.getByLabel('Weight (kg)')).toBeVisible() + await expect(page.getByLabel('Height (cm)')).toBeVisible() + await expect(page.getByLabel('Activity Level')).toBeVisible() + await expect(page.getByRole('button', { name: 'Save Profile' })).toBeVisible() + }) +}) diff --git a/e2e/public-auth.spec.ts b/e2e/public-auth.spec.ts new file mode 100644 index 0000000..0c33522 --- /dev/null +++ b/e2e/public-auth.spec.ts @@ -0,0 +1,50 @@ +import { expect, test } from '@playwright/test' + +test.describe('public authentication journeys', () => { + test('navigates from the home page to signup and login', async ({ page }) => { + await page.goto('/') + await expect(page.getByRole('heading', { name: 'Transform Your Fitness Journey' })).toBeVisible() + + await expect(page.getByRole('link', { name: 'Get started' })).toHaveAttribute('href', '/signup') + await page.goto('/signup') + await expect(page.getByRole('heading', { name: 'Create your account' })).toBeVisible() + + await expect(page.getByRole('link', { name: 'Log in', exact: true })).toHaveAttribute('href', '/login') + await page.goto('/login') + await expect(page.getByRole('heading', { name: 'Welcome back' })).toBeVisible() + }) + + test('opens password recovery from login and validates email', async ({ page }) => { + await page.goto('/login') + await expect(page.getByRole('link', { name: 'Forgot your password?' })).toHaveAttribute('href', '/forgot-password') + await page.goto('/forgot-password') + await expect(page.getByRole('heading', { name: 'Reset your password' })).toBeVisible() + + await page.locator('form').evaluate((form) => { form.noValidate = true }) + await page.getByLabel('Email address').fill('invalid-email') + await page.getByRole('button', { name: 'Send reset link' }).click() + await expect(page.getByRole('alert').filter({ hasText: 'valid email address' })).toBeVisible() + }) + + test('validates replacement password strength and confirmation', async ({ page }) => { + await page.goto('/reset-password') + await page.getByLabel('New password', { exact: true }).fill('weak') + await page.getByLabel('Confirm new password', { exact: true }).fill('different') + await page.getByRole('button', { name: 'Update password' }).click() + + await expect(page.getByText('Password must be at least 8 characters long.')).toBeVisible() + await expect(page.getByText('Passwords do not match.')).toBeVisible() + }) + + test('shows signup validation without calling external services', async ({ page }) => { + await page.goto('/signup') + await page.locator('form').evaluate((form) => { form.noValidate = true }) + await page.getByLabel('Full name').fill('A') + await page.getByLabel('Email address').fill('invalid-email') + await page.getByLabel('Password').fill('weak') + await page.getByRole('button', { name: 'Create account' }).click() + + await expect(page.getByText('Name must be at least 2 characters long.')).toBeVisible() + await expect(page.getByText('Please enter a valid email address.')).toBeVisible() + }) +}) diff --git a/eslint.config.mjs b/eslint.config.mjs index c85fb67..dffadf3 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -10,6 +10,7 @@ const compat = new FlatCompat({ }); const eslintConfig = [ + { ignores: [".next/**", ".test-dist/**", "supabase/remote-database.types.ts"] }, ...compat.extends("next/core-web-vitals", "next/typescript"), ]; diff --git a/middleware.ts b/middleware.ts index 1d1734a..d621624 100644 --- a/middleware.ts +++ b/middleware.ts @@ -8,9 +8,11 @@ export async function middleware(request: NextRequest) { } catch (error) { console.error('Session update error:', error) - // Return the original request to continue the middleware chain - // This allows the application to function even if session update fails - return request.nextUrl ? NextResponse.redirect(request.nextUrl) : NextResponse.next() + // Fail closed for protected routes, while public routes remain available. + if (request.nextUrl.pathname.startsWith("/protected")) { + return NextResponse.redirect(new URL("/login", request.url)) + } + return NextResponse.next() } } diff --git a/package-lock.json b/package-lock.json index 97f0dfb..6110b57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,8 @@ "name": "fitness-app", "version": "0.1.0", "dependencies": { - "@supabase/ssr": "^0.6.1", - "@supabase/supabase-js": "^2.49.4", + "@supabase/ssr": "0.6.1", + "@supabase/supabase-js": "2.49.4", "dotenv": "^16.5.0", "next": "15.3.1", "openai": "^5.2.0", @@ -26,7 +26,8 @@ "eslint": "^9.25.1", "eslint-config-next": "15.3.1", "tailwindcss": "^4", - "typescript": "^5.8.3" + "typescript": "^5.8.3", + "@playwright/test": "1.55.0" } }, "node_modules/@alloc/quick-lru": { @@ -5651,6 +5652,59 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/@playwright/test": { + "version": "1.55.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.55.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright": { + "version": "1.55.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.55.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.55.0", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } } } } diff --git a/package.json b/package.json index 1f04324..7b877ea 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,17 @@ "dev": "next dev --turbopack", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "eslint .", + "typecheck": "tsc --noEmit", + "test": "rm -rf .test-dist && tsc -p tsconfig.test.json && node --test .test-dist/tests/*.test.js", + "check": "npm run lint && npm run typecheck && npm test", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "ci": "npm run check && npm run build && npm run test:e2e" }, "dependencies": { - "@supabase/ssr": "^0.6.1", - "@supabase/supabase-js": "^2.49.4", + "@supabase/ssr": "0.6.1", + "@supabase/supabase-js": "2.49.4", "dotenv": "^16.5.0", "next": "15.3.1", "openai": "^5.2.0", @@ -27,6 +33,7 @@ "eslint": "^9.25.1", "eslint-config-next": "15.3.1", "tailwindcss": "^4", - "typescript": "^5.8.3" + "typescript": "^5.8.3", + "@playwright/test": "1.55.0" } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..c3783c4 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,52 @@ +import { defineConfig, devices, type PlaywrightTestConfig } from '@playwright/test' + +const port = Number(process.env.PORT ?? 3000) +const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${port}` +const authFile = 'playwright/.auth/user.json' +const hasAuthenticatedE2ECredentials = Boolean(process.env.E2E_AUTH_EMAIL && process.env.E2E_AUTH_PASSWORD) + +const projects: PlaywrightTestConfig['projects'] = [ + { + name: 'public-auth', + testMatch: /public-auth\.spec\.ts/, + use: { ...devices['Desktop Chrome'] }, + }, +] + +if (hasAuthenticatedE2ECredentials) { + projects.push( + { + name: 'authenticated setup', + testMatch: /auth\.setup\.ts/, + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'authenticated', + testMatch: /protected-flow\.spec\.ts/, + dependencies: ['authenticated setup'], + use: { ...devices['Desktop Chrome'], storageState: authFile }, + }, + ) +} + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'list', + use: { + baseURL, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + projects, + webServer: process.env.PLAYWRIGHT_BASE_URL ? undefined : { + command: `npm run dev -- --hostname 127.0.0.1 --port ${port}`, + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}) diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 0000000..4b7490a --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,24 @@ +project_id = "fitness-app" + +[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 + +[studio] +enabled = true +port = 54323 + +[auth] +enabled = true +site_url = "http://localhost:3000" +additional_redirect_urls = ["http://localhost:3000/auth/confirm"] +jwt_expiry = 3600 +enable_signup = true diff --git a/supabase/migrations/20260611155633_remote_baseline.sql b/supabase/migrations/20260611155633_remote_baseline.sql new file mode 100644 index 0000000..e69de29 diff --git a/supabase/migrations/20260611170000_reconcile_fitness_schema.sql b/supabase/migrations/20260611170000_reconcile_fitness_schema.sql new file mode 100644 index 0000000..24b2aac --- /dev/null +++ b/supabase/migrations/20260611170000_reconcile_fitness_schema.sql @@ -0,0 +1,357 @@ +-- Reconcile the existing fitness-app database with the application contract. +-- Preflight data-quality queries must return no rows before this migration is applied. +-- The existing food_logs table is intentionally preserved and left unchanged. + +create or replace function public.set_updated_at() +returns trigger +language plpgsql +set search_path = '' +as $$ +begin + new.updated_at = now(); + return new; +end; +$$; + +-- Profiles +alter table public.profiles + add column if not exists created_at timestamptz not null default now(), + add column if not exists updated_at timestamptz not null default now(); + +alter table public.profiles + alter column weight_kg type numeric(6, 2) using weight_kg::numeric, + alter column height_cm type numeric(6, 2) using height_cm::numeric, + alter column age set not null, + alter column weight_kg set not null, + alter column height_cm set not null, + alter column gender set not null, + alter column activity_level set not null, + alter column goal set not null; + +alter table public.profiles + drop constraint if exists profiles_age_check, + drop constraint if exists profiles_weight_kg_check, + drop constraint if exists profiles_height_cm_check, + drop constraint if exists profiles_gender_check, + drop constraint if exists profiles_activity_level_check, + drop constraint if exists profiles_goal_check, + add constraint profiles_age_check check (age between 1 and 120), + add constraint profiles_weight_kg_check check (weight_kg between 20 and 300), + add constraint profiles_height_cm_check check (height_cm between 50 and 250), + add constraint profiles_gender_check check (gender in ('male', 'female')), + add constraint profiles_activity_level_check check (activity_level in ('sedentary', 'lightly_active', 'moderately_active', 'very_active', 'extra_active')), + add constraint profiles_goal_check check (goal in ('lose_weight', 'build_muscle', 'stay_fit')); + +-- TDEE history: the existing database used the misspelled create_at column. +do $$ +begin + if exists ( + select 1 from information_schema.columns + where table_schema = 'public' and table_name = 'tde_estimates' and column_name = 'create_at' + ) and not exists ( + select 1 from information_schema.columns + where table_schema = 'public' and table_name = 'tde_estimates' and column_name = 'created_at' + ) then + alter table public.tde_estimates rename column create_at to created_at; + end if; +end; +$$; + +alter table public.tde_estimates + add column if not exists created_at timestamptz default now(); + +update public.tde_estimates set created_at = now() where created_at is null; + +alter table public.tde_estimates + alter column user_id set not null, + alter column tde_value set not null, + alter column method set not null, + alter column created_at type timestamptz using created_at at time zone 'UTC', + alter column created_at set default now(), + alter column created_at set not null; + +alter table public.tde_estimates + drop constraint if exists tde_estimates_tde_value_check, + drop constraint if exists tde_estimates_method_check, + add constraint tde_estimates_tde_value_check check (tde_value > 0), + add constraint tde_estimates_method_check check (method in ('Mifflin-St Jeor')); + +-- Daily water intake +alter table public.water_intake + add column if not exists created_at timestamptz not null default now(), + add column if not exists updated_at timestamptz not null default now(); + +alter table public.water_intake + alter column user_id set not null, + alter column date set not null, + alter column glasses_consumed set default 0, + alter column glasses_consumed set not null, + alter column goal set default 8, + alter column goal set not null; + +alter table public.water_intake + drop constraint if exists water_intake_glasses_consumed_check, + drop constraint if exists water_intake_goal_check, + add constraint water_intake_glasses_consumed_check check (glasses_consumed >= 0), + add constraint water_intake_goal_check check (goal > 0); + +create unique index if not exists water_intake_user_date_unique_idx + on public.water_intake (user_id, date); + +-- Daily workout plans +alter table public.workout_plans + add column if not exists updated_at timestamptz not null default now(); + +update public.workout_plans set created_at = now() where created_at is null; + +alter table public.workout_plans + alter column created_at set default now(), + alter column created_at set not null, + alter column user_id set not null, + alter column date set not null, + alter column workout_type set not null, + alter column duration_minutes set not null, + alter column difficulty set not null, + alter column exercises set not null; + +alter table public.workout_plans + drop constraint if exists workout_plans_user_id_date_workout_type_key, + drop constraint if exists workout_plans_workout_type_check, + drop constraint if exists workout_plans_duration_minutes_check, + drop constraint if exists workout_plans_difficulty_check, + drop constraint if exists workout_plans_exercises_check, + add constraint workout_plans_workout_type_check check (workout_type in ('cardio', 'strength', 'full_body', 'flexibility')), + add constraint workout_plans_duration_minutes_check check (duration_minutes > 0), + add constraint workout_plans_difficulty_check check (difficulty in ('beginner', 'intermediate', 'advanced')), + add constraint workout_plans_exercises_check check (jsonb_typeof(exercises) = 'array'); + +create unique index if not exists workout_plans_user_date_unique_idx + on public.workout_plans (user_id, date); + +-- Completed workout history +update public.workout_sessions set created_at = now() where created_at is null; + +alter table public.workout_sessions + alter column created_at set default now(), + alter column created_at set not null, + alter column user_id set not null, + alter column workout_name set not null, + alter column duration_minutes set not null, + alter column exercises set not null, + alter column completed_at set default now(), + alter column completed_at set not null; + +alter table public.workout_sessions + drop constraint if exists workout_sessions_workout_name_check, + drop constraint if exists workout_sessions_duration_minutes_check, + drop constraint if exists workout_sessions_exercises_check, + add constraint workout_sessions_workout_name_check check (length(trim(workout_name)) > 0), + add constraint workout_sessions_duration_minutes_check check (duration_minutes > 0), + add constraint workout_sessions_exercises_check check (jsonb_typeof(exercises) = 'array'); + +-- Daily meal plans +alter table public.meal_plans + add column if not exists updated_at timestamptz not null default now(); + +update public.meal_plans set created_at = now() where created_at is null; + +alter table public.meal_plans + alter column created_at set default now(), + alter column created_at set not null, + alter column user_id set not null, + alter column date set not null, + alter column goal set not null, + alter column calories_target set not null, + alter column meals set not null; + +alter table public.meal_plans + drop constraint if exists meal_plans_goal_check, + drop constraint if exists meal_plans_calories_target_check, + add constraint meal_plans_goal_check check (goal in ('lose_weight', 'build_muscle', 'stay_fit')), + add constraint meal_plans_calories_target_check check (calories_target > 0); + +create unique index if not exists meal_plans_user_date_unique_idx + on public.meal_plans (user_id, date); + +-- Replace user foreign keys with cascading ownership. Preflight verifies there are no orphan rows. +do $$ +declare + target record; + existing_fk record; +begin + for target in + select * from (values + ('profiles', 'id', 'profiles_id_fkey'), + ('tde_estimates', 'user_id', 'tde_estimates_user_id_fkey'), + ('water_intake', 'user_id', 'water_intake_user_id_fkey'), + ('workout_plans', 'user_id', 'workout_plans_user_id_fkey'), + ('workout_sessions', 'user_id', 'workout_sessions_user_id_fkey'), + ('meal_plans', 'user_id', 'meal_plans_user_id_fkey') + ) as ownership(table_name, column_name, constraint_name) + loop + for existing_fk in + select conname + from pg_constraint + where conrelid = format('public.%I', target.table_name)::regclass + and confrelid = 'auth.users'::regclass + and contype = 'f' + loop + execute format('alter table public.%I drop constraint %I', target.table_name, existing_fk.conname); + end loop; + + execute format( + 'alter table public.%I add constraint %I foreign key (%I) references auth.users(id) on delete cascade', + target.table_name, + target.constraint_name, + target.column_name + ); + end loop; +end; +$$; + +create index if not exists tde_estimates_user_created_at_idx + on public.tde_estimates (user_id, created_at desc); +create index if not exists workout_sessions_user_completed_at_idx + on public.workout_sessions (user_id, completed_at desc); + +-- Keep mutable-row timestamps current. +drop trigger if exists profiles_set_updated_at on public.profiles; +create trigger profiles_set_updated_at before update on public.profiles +for each row execute function public.set_updated_at(); + +drop trigger if exists water_intake_set_updated_at on public.water_intake; +create trigger water_intake_set_updated_at before update on public.water_intake +for each row execute function public.set_updated_at(); + +drop trigger if exists workout_plans_set_updated_at on public.workout_plans; +create trigger workout_plans_set_updated_at before update on public.workout_plans +for each row execute function public.set_updated_at(); + +drop trigger if exists meal_plans_set_updated_at on public.meal_plans; +create trigger meal_plans_set_updated_at before update on public.meal_plans +for each row execute function public.set_updated_at(); + +-- Reconcile policies so overlapping legacy policies cannot broaden access. +do $$ +declare + target_table text; + existing_policy record; +begin + foreach target_table in array array[ + 'profiles', 'tde_estimates', 'water_intake', + 'workout_plans', 'workout_sessions', 'meal_plans' + ] + loop + execute format('alter table public.%I enable row level security', target_table); + + for existing_policy in + select policyname from pg_policies + where schemaname = 'public' and tablename = target_table + loop + execute format('drop policy %I on public.%I', existing_policy.policyname, target_table); + end loop; + end loop; +end; +$$; + +create policy "Users can read their profile" on public.profiles for select to authenticated using ((select auth.uid()) = id); +create policy "Users can create their profile" on public.profiles for insert to authenticated with check ((select auth.uid()) = id); +create policy "Users can update their profile" on public.profiles for update to authenticated using ((select auth.uid()) = id) with check ((select auth.uid()) = id); +create policy "Users can delete their profile" on public.profiles for delete to authenticated using ((select auth.uid()) = id); + +create policy "Users can read their TDEE estimates" on public.tde_estimates for select to authenticated using ((select auth.uid()) = user_id); +create policy "Users can create their TDEE estimates" on public.tde_estimates for insert to authenticated with check ((select auth.uid()) = user_id); +create policy "Users can delete their TDEE estimates" on public.tde_estimates for delete to authenticated using ((select auth.uid()) = user_id); + +create policy "Users can read their water intake" on public.water_intake for select to authenticated using ((select auth.uid()) = user_id); +create policy "Users can create their water intake" on public.water_intake for insert to authenticated with check ((select auth.uid()) = user_id); +create policy "Users can update their water intake" on public.water_intake for update to authenticated using ((select auth.uid()) = user_id) with check ((select auth.uid()) = user_id); +create policy "Users can delete their water intake" on public.water_intake for delete to authenticated using ((select auth.uid()) = user_id); + +create policy "Users can read their workout plans" on public.workout_plans for select to authenticated using ((select auth.uid()) = user_id); +create policy "Users can create their workout plans" on public.workout_plans for insert to authenticated with check ((select auth.uid()) = user_id); +create policy "Users can update their workout plans" on public.workout_plans for update to authenticated using ((select auth.uid()) = user_id) with check ((select auth.uid()) = user_id); +create policy "Users can delete their workout plans" on public.workout_plans for delete to authenticated using ((select auth.uid()) = user_id); + +create policy "Users can read their workout sessions" on public.workout_sessions for select to authenticated using ((select auth.uid()) = user_id); +create policy "Users can create their workout sessions" on public.workout_sessions for insert to authenticated with check ((select auth.uid()) = user_id); +create policy "Users can update their workout sessions" on public.workout_sessions for update to authenticated using ((select auth.uid()) = user_id) with check ((select auth.uid()) = user_id); +create policy "Users can delete their workout sessions" on public.workout_sessions for delete to authenticated using ((select auth.uid()) = user_id); + +create policy "Users can read their meal plans" on public.meal_plans for select to authenticated using ((select auth.uid()) = user_id); +create policy "Users can create their meal plans" on public.meal_plans for insert to authenticated with check ((select auth.uid()) = user_id); +create policy "Users can update their meal plans" on public.meal_plans for update to authenticated using ((select auth.uid()) = user_id) with check ((select auth.uid()) = user_id); +create policy "Users can delete their meal plans" on public.meal_plans for delete to authenticated using ((select auth.uid()) = user_id); + +-- Authenticated atomic profile save and TDEE calculation. +create or replace function public.save_profile_with_tde( + profile_age integer, + profile_weight_kg numeric, + profile_height_cm numeric, + profile_gender text, + profile_activity_level text, + profile_goal text +) +returns void +language plpgsql +security definer +set search_path = '' +as $$ +declare + current_user_id uuid := auth.uid(); + calculated_tde integer; + activity_multiplier numeric; +begin + if current_user_id is null then + raise exception 'Authentication required' using errcode = '42501'; + end if; + + insert into public.profiles (id, age, weight_kg, height_cm, gender, activity_level, goal) + values (current_user_id, profile_age, profile_weight_kg, profile_height_cm, profile_gender, profile_activity_level, profile_goal) + on conflict (id) do update set + age = excluded.age, + weight_kg = excluded.weight_kg, + height_cm = excluded.height_cm, + gender = excluded.gender, + activity_level = excluded.activity_level, + goal = excluded.goal; + + activity_multiplier := case profile_activity_level + when 'sedentary' then 1.2 + when 'lightly_active' then 1.375 + when 'moderately_active' then 1.55 + when 'very_active' then 1.725 + when 'extra_active' then 1.9 + end; + + calculated_tde := round((case profile_gender + when 'male' then 10 * profile_weight_kg + 6.25 * profile_height_cm - 5 * profile_age + 5 + when 'female' then 10 * profile_weight_kg + 6.25 * profile_height_cm - 5 * profile_age - 161 + end) * activity_multiplier)::integer; + + insert into public.tde_estimates (user_id, tde_value, method) + values (current_user_id, calculated_tde, 'Mifflin-St Jeor'); +end; +$$; + +revoke all on function public.save_profile_with_tde(integer, numeric, numeric, text, text, text) from public; +grant execute on function public.save_profile_with_tde(integer, numeric, numeric, text, text, text) to authenticated; + +-- Add dashboard tables to Realtime only when they are not already members. +do $$ +declare + target_table text; +begin + foreach target_table in array array['water_intake', 'workout_sessions', 'meal_plans'] + loop + if not exists ( + select 1 from pg_publication_tables + where pubname = 'supabase_realtime' + and schemaname = 'public' + and tablename = target_table + ) then + execute format('alter publication supabase_realtime add table public.%I', target_table); + end if; + end loop; +end; +$$; diff --git a/supabase/preflight/20260611170000_reconcile_fitness_schema.sql b/supabase/preflight/20260611170000_reconcile_fitness_schema.sql new file mode 100644 index 0000000..71bdef0 --- /dev/null +++ b/supabase/preflight/20260611170000_reconcile_fitness_schema.sql @@ -0,0 +1,44 @@ +-- Run this read-only preflight before applying 20260611170000_reconcile_fitness_schema.sql. +-- Every query must return zero rows. + +select * from public.profiles +where age is null or weight_kg is null or height_cm is null or gender is null + or activity_level is null or goal is null + or age not between 1 and 120 + or weight_kg not between 20 and 300 + or height_cm not between 50 and 250 + or gender not in ('male', 'female') + or activity_level not in ('sedentary', 'lightly_active', 'moderately_active', 'very_active', 'extra_active') + or goal not in ('lose_weight', 'build_muscle', 'stay_fit'); + +select user_id, date, count(*) from public.meal_plans + group by user_id, date having count(*) > 1; +select user_id, date, count(*) from public.workout_plans + group by user_id, date having count(*) > 1; +select user_id, date, count(*) from public.water_intake + group by user_id, date having count(*) > 1; + +select * from public.water_intake +where user_id is null or date is null or glasses_consumed is null + or glasses_consumed < 0 or goal is null or goal <= 0; +select * from public.workout_plans +where user_id is null or date is null or workout_type is null or duration_minutes is null + or duration_minutes <= 0 or difficulty is null or exercises is null + or jsonb_typeof(exercises) <> 'array'; +select * from public.workout_sessions +where user_id is null or workout_name is null or length(trim(workout_name)) = 0 + or duration_minutes is null or duration_minutes <= 0 or exercises is null + or jsonb_typeof(exercises) <> 'array' or completed_at is null; +select * from public.meal_plans +where user_id is null or date is null or goal is null or calories_target is null + or calories_target <= 0 or meals is null; +select * from public.tde_estimates +where user_id is null or tde_value is null or tde_value <= 0 or method is null; + +select 'profiles' as table_name, p.id as orphan_user_id +from public.profiles p left join auth.users u on u.id = p.id where u.id is null +union all select 'tde_estimates', t.user_id from public.tde_estimates t left join auth.users u on u.id = t.user_id where u.id is null +union all select 'water_intake', w.user_id from public.water_intake w left join auth.users u on u.id = w.user_id where u.id is null +union all select 'workout_plans', w.user_id from public.workout_plans w left join auth.users u on u.id = w.user_id where u.id is null +union all select 'workout_sessions', w.user_id from public.workout_sessions w left join auth.users u on u.id = w.user_id where u.id is null +union all select 'meal_plans', m.user_id from public.meal_plans m left join auth.users u on u.id = m.user_id where u.id is null; diff --git a/supabase/seed.sql b/supabase/seed.sql new file mode 100644 index 0000000..6467aa2 --- /dev/null +++ b/supabase/seed.sql @@ -0,0 +1 @@ +-- Application data belongs to authenticated users, so no shared seed data is required. diff --git a/tests/auth-schemas.test.ts b/tests/auth-schemas.test.ts new file mode 100644 index 0000000..0793a89 --- /dev/null +++ b/tests/auth-schemas.test.ts @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { LoginSchema, SignUpSchema } from '../app/lib/definitions' + +describe('SignUpSchema', () => { + it('accepts and trims valid signup data', () => { + const result = SignUpSchema.safeParse({ + name: ' Taylor ', + email: ' taylor@example.com ', + password: 'Strong1!', + }) + + assert.equal(result.success, true) + if (result.success) { + assert.deepEqual(result.data, { + name: 'Taylor', + email: 'taylor@example.com', + password: 'Strong1!', + }) + } + }) + + it('rejects weak passwords and invalid identity fields', () => { + const result = SignUpSchema.safeParse({ + name: 'A', + email: 'not-an-email', + password: 'password', + }) + + assert.equal(result.success, false) + if (!result.success) { + const errors = result.error.flatten().fieldErrors + assert.ok(errors.name) + assert.ok(errors.email) + assert.ok(errors.password) + } + }) +}) + +describe('LoginSchema', () => { + it('accepts and trims valid login data', () => { + const result = LoginSchema.safeParse({ + email: ' taylor@example.com ', + password: ' Strong1! ', + }) + + assert.equal(result.success, true) + if (result.success) { + assert.deepEqual(result.data, { + email: 'taylor@example.com', + password: 'Strong1!', + }) + } + }) + + it('rejects invalid email addresses', () => { + const result = LoginSchema.safeParse({ + email: 'invalid', + password: 'Strong1!', + }) + + assert.equal(result.success, false) + if (!result.success) { + assert.ok(result.error.flatten().fieldErrors.email) + } + }) +}) diff --git a/tests/database-migration.test.ts b/tests/database-migration.test.ts new file mode 100644 index 0000000..71f8789 --- /dev/null +++ b/tests/database-migration.test.ts @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { describe, it } from 'node:test' +import { join } from 'node:path' + +const baseline = readFileSync( + join(process.cwd(), 'supabase/migrations/20260611155633_remote_baseline.sql'), + 'utf8', +) +const reconciliation = readFileSync( + join(process.cwd(), 'supabase/migrations/20260611170000_reconcile_fitness_schema.sql'), + 'utf8', +) + +const userOwnedTables = [ + 'profiles', + 'tde_estimates', + 'water_intake', + 'workout_plans', + 'workout_sessions', + 'meal_plans', +] + +describe('Supabase reconciliation migration contract', () => { + it('keeps an empty marker for the already-applied remote baseline', () => { + assert.equal(baseline, '') + }) + + it('alters existing tables instead of recreating them', () => { + assert.doesNotMatch(reconciliation, /create table public\./) + for (const table of userOwnedTables) { + assert.match(reconciliation, new RegExp(`alter table public\\.${table}`)) + } + }) + + it('preserves the existing food logs table', () => { + assert.doesNotMatch(reconciliation, /(?:drop|alter|truncate) table public\.food_logs/) + }) + + it('reconciles daily uniqueness, user ownership, and RLS', () => { + assert.match(reconciliation, /water_intake_user_date_unique_idx/) + assert.match(reconciliation, /workout_plans_user_date_unique_idx/) + assert.match(reconciliation, /meal_plans_user_date_unique_idx/) + assert.match(reconciliation, /references auth\.users\(id\) on delete cascade/) + for (const table of userOwnedTables) { + assert.match(reconciliation, new RegExp(`alter table public\\.%I enable row level security`)) + assert.match(reconciliation, new RegExp(`on public\\.${table}`)) + } + }) + + it('defines the authenticated atomic profile and TDEE function', () => { + assert.match(reconciliation, /create or replace function public\.save_profile_with_tde/) + assert.match(reconciliation, /current_user_id uuid := auth\.uid\(\)/) + assert.match(reconciliation, /grant execute on function public\.save_profile_with_tde[\s\S]+to authenticated;/) + }) + + it('indexes history queries and safely reconciles Realtime membership', () => { + assert.match(reconciliation, /tde_estimates_user_created_at_idx/) + assert.match(reconciliation, /workout_sessions_user_completed_at_idx/) + assert.match(reconciliation, /pg_publication_tables/) + }) +}) diff --git a/tests/date.test.ts b/tests/date.test.ts new file mode 100644 index 0000000..610330e --- /dev/null +++ b/tests/date.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { describe, it } from 'node:test' + +import { DateKeySchema, formatLocalDate, getLocalWeekRange, parseDateKey } from '../app/lib/date' + +describe('local date domain', () => { + it('formats dates from local calendar fields', () => { + assert.equal(formatLocalDate(new Date(2026, 0, 2, 23, 30)), '2026-01-02') + assert.equal(formatLocalDate(new Date(2026, 10, 9, 0, 5)), '2026-11-09') + }) + + it('accepts real ISO date keys and rejects impossible dates', () => { + assert.equal(parseDateKey('2024-02-29'), '2024-02-29') + assert.equal(DateKeySchema.safeParse('2025-02-29').success, false) + assert.equal(DateKeySchema.safeParse('2026-13-01').success, false) + assert.equal(DateKeySchema.safeParse('06/14/2026').success, false) + }) + + it('returns local Sunday-through-Saturday boundaries and keys', () => { + const range = getLocalWeekRange(new Date(2026, 5, 17, 12)) + assert.equal(formatLocalDate(range.weekStart), '2026-06-14') + assert.equal(formatLocalDate(range.weekEnd), '2026-06-20') + assert.equal(range.startDateKey, '2026-06-14') + assert.equal(range.endDateKey, '2026-06-20') + assert.equal(range.weekStart.getHours(), 0) + assert.equal(range.weekEnd.getHours(), 23) + }) +}) + +describe('local date usage contract', () => { + it('does not derive daily database keys from UTC serialization', () => { + const sources = [ + 'app/lib/client-database.ts', + 'app/lib/workout-generator.ts', + 'app/protected/profile/meal-plan/action.ts', + 'app/components/WeeklyProgress.tsx', + ].map((path) => readFileSync(path, 'utf8')) + + for (const source of sources) { + assert.doesNotMatch(source, /toISOString\(\)\.split\(['"]T['"]\)\[0\]/) + } + }) + + it('passes validated browser-local date keys into generation actions', () => { + const workoutUi = readFileSync('app/components/WorkoutTemplates.tsx', 'utf8') + const mealUi = readFileSync('app/protected/profile/meal-plan/page.tsx', 'utf8') + const workoutAction = readFileSync('app/lib/workout-generator.ts', 'utf8') + const mealAction = readFileSync('app/protected/profile/meal-plan/action.ts', 'utf8') + + assert.match(workoutUi, /getLocalDateKey\(\)/) + assert.match(mealUi, /getLocalDateKey\(\)/) + assert.match(workoutAction, /parseDateKey\(dateInput\)/) + assert.match(mealAction, /parseDateKey\(dateInput\)/) + }) +}) diff --git a/tests/e2e-harness.test.ts b/tests/e2e-harness.test.ts new file mode 100644 index 0000000..5b7d204 --- /dev/null +++ b/tests/e2e-harness.test.ts @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { describe, it } from 'node:test' + +const packageJson = JSON.parse(readFileSync('package.json', 'utf8')) +const workflow = readFileSync('.github/workflows/quality.yml', 'utf8') +const playwrightConfig = readFileSync('playwright.config.ts', 'utf8') +const publicAuthSpec = readFileSync('e2e/public-auth.spec.ts', 'utf8') +const authSetup = readFileSync('e2e/auth.setup.ts', 'utf8') +const protectedFlowSpec = readFileSync('e2e/protected-flow.spec.ts', 'utf8') +const middlewareHelper = readFileSync('utils/supabase/middleware.ts', 'utf8') +const middleware = readFileSync('middleware.ts', 'utf8') +const gitignore = readFileSync('.gitignore', 'utf8') + +describe('end-to-end harness contract', () => { + it('defines deterministic Playwright scripts and failure artifacts', () => { + assert.equal(packageJson.scripts['test:e2e'], 'playwright test') + assert.match(playwrightConfig, /screenshot: 'only-on-failure'/) + assert.match(playwrightConfig, /trace: 'on-first-retry'/) + assert.match(playwrightConfig, /video: 'retain-on-failure'/) + }) + + it('covers public authentication journeys without real credentials', () => { + for (const path of ['/', '/login', '/signup', '/reset-password']) { + assert.match(publicAuthSpec, new RegExp(path.replace('/', '\\/'))) + } + assert.doesNotMatch(publicAuthSpec, /process\.env\.(?:E2E|TEST)_/) + }) + + it('uses unambiguous accessible locators for repeated auth labels and alerts', () => { + assert.match(publicAuthSpec, /name: 'Log in', exact: true/) + assert.match(publicAuthSpec, /getByLabel\('New password', \{ exact: true \}\)/) + assert.match(publicAuthSpec, /getByRole\('alert'\)\.filter\(\{ hasText:/) + }) + + it('adds authenticated protected-flow coverage only when E2E credentials are configured', () => { + assert.match(playwrightConfig, /hasAuthenticatedE2ECredentials/) + assert.match(playwrightConfig, /E2E_AUTH_EMAIL/) + assert.match(playwrightConfig, /E2E_AUTH_PASSWORD/) + assert.match(playwrightConfig, /name: 'authenticated setup'/) + assert.match(playwrightConfig, /name: 'authenticated'/) + assert.match(playwrightConfig, /storageState: authFile/) + assert.match(authSetup, /storageState\(\{ path: authFile \}\)/) + assert.match(protectedFlowSpec, /page\.goto\('\/protected'\)/) + assert.match(protectedFlowSpec, /page\.goto\('\/protected\/profile'\)/) + assert.match(gitignore, /\/playwright\/\.auth\//) + }) + + it('runs checks, build, and Playwright in CI and uploads failures', () => { + assert.match(workflow, /npm run check/) + assert.match(workflow, /npm run build/) + assert.match(workflow, /npm run test:e2e/) + assert.match(workflow, /actions\/upload-artifact@v4/) + }) + + it('keeps public routes available without remote auth checks and fails protected routes closed', () => { + assert.match(middlewareHelper, /if \(!requiresAuthentication && !redirectsAuthenticatedUser\)/) + assert.match(middlewareHelper, /redirectsAuthenticatedUser && !hasAuthCookie/) + assert.match(middleware, /pathname\.startsWith\("\/protected"\)/) + assert.match(middleware, /NextResponse\.redirect\(new URL\("\/login"/) + }) +}) diff --git a/tests/generated-plans.test.ts b/tests/generated-plans.test.ts new file mode 100644 index 0000000..10dd9d4 --- /dev/null +++ b/tests/generated-plans.test.ts @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { + GeneratedWorkoutPlanSchema, + MealPlanContentSchema, + WorkoutDurationSchema, + parseGeneratedContent, + parseMealPlanContent, +} from '../app/lib/generated-plans' + +const validWorkout = { + workout_type: 'strength', + difficulty: 'beginner', + exercises: [{ name: 'Squat', sets: 3, reps: '10', instructions: 'Sit back and stand.' }], +} + +const validMeals = { + Meals: { + Breakfast: 'Oats with berries', + Lunch: 'Chicken salad', + Dinner: 'Salmon with rice', + Snacks: 'Apple and yogurt', + }, +} + +describe('generated plan validation', () => { + it('accepts valid workout plans and meal plans', () => { + assert.equal(GeneratedWorkoutPlanSchema.parse(validWorkout).workout_type, 'strength') + assert.equal(MealPlanContentSchema.parse(validMeals).Meals.Lunch, 'Chicken salad') + }) + + it('rejects unsupported workout values and malformed exercises', () => { + assert.equal(GeneratedWorkoutPlanSchema.safeParse({ ...validWorkout, workout_type: 'dangerous' }).success, false) + assert.equal(GeneratedWorkoutPlanSchema.safeParse({ ...validWorkout, exercises: [] }).success, false) + assert.equal(GeneratedWorkoutPlanSchema.safeParse({ ...validWorkout, exercises: [{ name: 'Squat' }] }).success, false) + }) + + it('rejects incomplete or empty meal plans', () => { + assert.equal(MealPlanContentSchema.safeParse({ Meals: { Breakfast: 'Oats' } }).success, false) + assert.equal(MealPlanContentSchema.safeParse({ ...validMeals, Meals: { ...validMeals.Meals, Dinner: '' } }).success, false) + }) + + it('validates workout duration bounds', () => { + assert.equal(WorkoutDurationSchema.parse(30), 30) + assert.equal(WorkoutDurationSchema.safeParse(9).success, false) + assert.equal(WorkoutDurationSchema.safeParse(121).success, false) + assert.equal(WorkoutDurationSchema.safeParse(30.5).success, false) + }) + + it('rejects empty, invalid JSON, and invalid generated structures', () => { + assert.throws(() => parseGeneratedContent('', GeneratedWorkoutPlanSchema), /no content/) + assert.throws(() => parseGeneratedContent('{bad json', GeneratedWorkoutPlanSchema), /invalid JSON/) + assert.throws(() => parseGeneratedContent(JSON.stringify({ workout_type: 'strength' }), GeneratedWorkoutPlanSchema), /invalid plan structure/) + }) + + it('parses validated legacy string meal plans', () => { + assert.deepEqual(parseMealPlanContent(JSON.stringify(validMeals)), validMeals) + assert.throws(() => parseMealPlanContent('{bad json'), /invalid JSON/) + }) +}) + +describe('generation persistence contract', () => { + it('uses non-destructive upserts for generated plans', async () => { + const { readFile } = await import('node:fs/promises') + const workoutSource = await readFile('app/lib/workout-generator.ts', 'utf8') + const mealSource = await readFile('app/protected/profile/meal-plan/action.ts', 'utf8') + + for (const source of [workoutSource, mealSource]) { + assert.match(source, /\.upsert\(/) + assert.doesNotMatch(source, /\.delete\(\)/) + assert.match(source, /response_format: \{ type: 'json_object' \}/) + } + }) +}) diff --git a/tests/password-recovery.test.ts b/tests/password-recovery.test.ts new file mode 100644 index 0000000..34e9b51 --- /dev/null +++ b/tests/password-recovery.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { describe, it } from 'node:test' + +import { PasswordRecoverySchema, PasswordResetSchema, getSafeRedirectPath } from '../app/lib/auth' + +describe('password recovery validation', () => { + it('validates and normalizes recovery email addresses', () => { + assert.equal(PasswordRecoverySchema.parse({ email: ' USER@example.com ' }).email, 'USER@example.com') + assert.equal(PasswordRecoverySchema.safeParse({ email: 'invalid' }).success, false) + }) + + it('requires a strong matching replacement password', () => { + assert.equal(PasswordResetSchema.safeParse({ password: 'Strong1!', confirmPassword: 'Strong1!' }).success, true) + assert.equal(PasswordResetSchema.safeParse({ password: 'weak', confirmPassword: 'weak' }).success, false) + assert.equal(PasswordResetSchema.safeParse({ password: 'Strong1!', confirmPassword: 'Different1!' }).success, false) + }) + + it('allows only local callback redirect paths', () => { + assert.equal(getSafeRedirectPath('/reset-password'), '/reset-password') + assert.equal(getSafeRedirectPath('https://attacker.example'), '/') + assert.equal(getSafeRedirectPath('//attacker.example'), '/') + assert.equal(getSafeRedirectPath(null, '/login'), '/login') + }) +}) + +describe('password recovery flow contract', () => { + it('uses the recovery callback and avoids account enumeration', () => { + const requestAction = readFileSync('app/forgot-password/action.ts', 'utf8') + const resetAction = readFileSync('app/reset-password/action.ts', 'utf8') + const callback = readFileSync('app/auth/confirm/route.ts', 'utf8') + + assert.match(requestAction, /resetPasswordForEmail/) + assert.match(requestAction, /auth\/confirm\?next=\/reset-password/) + assert.match(requestAction, /If an account exists for that email/) + assert.match(resetAction, /auth\.updateUser\(\{ password:/) + assert.match(resetAction, /auth\.signOut\(\)/) + assert.match(callback, /getSafeRedirectPath/) + assert.match(callback, /exchangeCodeForSession/) + assert.match(callback, /verifyOtp/) + }) +}) diff --git a/tests/profile-schema.test.ts b/tests/profile-schema.test.ts new file mode 100644 index 0000000..ddf8d15 --- /dev/null +++ b/tests/profile-schema.test.ts @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { ProfileSchema } from '../app/lib/profile' + +const validProfile = { + age: '30', + weight_kg: '80.5', + height_cm: '180.2', + gender: 'male', + activity_level: 'moderately_active', + goal: 'stay_fit', +} + +describe('ProfileSchema', () => { + it('coerces valid form values into a typed profile', () => { + const result = ProfileSchema.parse(validProfile) + + assert.deepEqual(result, { + age: 30, + weight_kg: 80.5, + height_cm: 180.2, + gender: 'male', + activity_level: 'moderately_active', + goal: 'stay_fit', + }) + }) + + it('enforces the profile form numeric ranges', () => { + for (const profile of [ + { ...validProfile, age: '0' }, + { ...validProfile, age: '121' }, + { ...validProfile, weight_kg: '19.9' }, + { ...validProfile, weight_kg: '300.1' }, + { ...validProfile, height_cm: '49.9' }, + { ...validProfile, height_cm: '250.1' }, + ]) { + assert.equal(ProfileSchema.safeParse(profile).success, false) + } + }) + + it('rejects non-numeric, empty, and fractional age values', () => { + assert.equal(ProfileSchema.safeParse({ ...validProfile, age: 'not-a-number' }).success, false) + assert.equal(ProfileSchema.safeParse({ ...validProfile, age: '' }).success, false) + assert.equal(ProfileSchema.safeParse({ ...validProfile, age: '30.5' }).success, false) + }) + + it('rejects unsupported domain values', () => { + assert.equal(ProfileSchema.safeParse({ ...validProfile, gender: 'other' }).success, false) + assert.equal(ProfileSchema.safeParse({ ...validProfile, activity_level: 'sometimes_active' }).success, false) + assert.equal(ProfileSchema.safeParse({ ...validProfile, goal: 'run_marathon' }).success, false) + }) +}) diff --git a/tests/supabase-client-contract.test.ts b/tests/supabase-client-contract.test.ts new file mode 100644 index 0000000..69148f2 --- /dev/null +++ b/tests/supabase-client-contract.test.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, it } from 'node:test' + +const readSource = (path: string) => readFileSync(join(process.cwd(), path), 'utf8') + +const browserClient = readSource('utils/supabase/client.ts') +const serverClient = readSource('utils/supabase/server.ts') +const middlewareClient = readSource('utils/supabase/middleware.ts') + +const databaseImportPattern = /import type \{ Database \} from ['"]@\/app\/lib\/database\.types['"];?/g + +describe('Supabase client type contract', () => { + it('imports the Database contract exactly once in each helper', () => { + for (const source of [browserClient, serverClient, middlewareClient]) { + assert.equal(source.match(databaseImportPattern)?.length, 1) + } + }) + + it('preserves the Database contract at each SSR compatibility boundary', () => { + for (const source of [browserClient, serverClient, middlewareClient]) { + assert.match(source, /as SupabaseClient/) + } + }) + + it('types server and middleware cookie batches', () => { + for (const source of [serverClient, middlewareClient]) { + assert.match(source, /setAll\(cookiesToSet: Parameters\[0\]\)/) + } + }) +}) diff --git a/tests/tde.test.ts b/tests/tde.test.ts new file mode 100644 index 0000000..020f2d5 --- /dev/null +++ b/tests/tde.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import type { ActivityLevel } from '../app/lib/profile-options' +import { calculateTDE } from '../app/lib/tde' + +describe('calculateTDE', () => { + it('calculates and rounds male TDEE with the Mifflin-St Jeor equation', () => { + assert.equal(calculateTDE({ weightKg: 80, heightCm: 180, age: 30, gender: 'male', activityLevel: 'moderately_active' }), 2759) + }) + + it('calculates and rounds female TDEE with the Mifflin-St Jeor equation', () => { + assert.equal(calculateTDE({ weightKg: 65, heightCm: 165, age: 35, gender: 'female', activityLevel: 'lightly_active' }), 1850) + }) + + it('supports every configured activity level', () => { + const levels: ActivityLevel[] = [ + 'sedentary', + 'lightly_active', + 'moderately_active', + 'very_active', + 'extra_active', + ] + + const results = levels.map((activityLevel) => calculateTDE({ + weightKg: 70, + heightCm: 175, + age: 30, + gender: 'male', + activityLevel, + })) + + assert.deepEqual(results, [1979, 2267, 2556, 2844, 3133]) + }) + + it('rejects non-positive and non-finite numeric inputs', () => { + const validInput = { heightCm: 180, age: 30, gender: 'male' as const, activityLevel: 'sedentary' as const } + + assert.throws(() => calculateTDE({ ...validInput, weightKg: 0 }), /positive finite numbers/) + assert.throws(() => calculateTDE({ ...validInput, weightKg: Number.NaN }), /positive finite numbers/) + assert.throws(() => calculateTDE({ ...validInput, weightKg: Number.POSITIVE_INFINITY }), /positive finite numbers/) + }) + + it('defensively rejects unsupported runtime gender and activity values', () => { + assert.throws( + () => calculateTDE({ weightKg: 80, heightCm: 180, age: 30, gender: 'other', activityLevel: 'sedentary' } as never), + /Gender must be either/, + ) + assert.throws( + () => calculateTDE({ weightKg: 80, heightCm: 180, age: 30, gender: 'male', activityLevel: 'sometimes_active' } as never), + /Invalid activity level/, + ) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index d8b9323..28dd525 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,5 +23,5 @@ } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "exclude": ["node_modules", "e2e", "playwright.config.ts"] } diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..7e39574 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "incremental": false, + "noEmit": false, + "outDir": ".test-dist", + "module": "commonjs", + "moduleResolution": "node", + "target": "ES2020", + "types": ["node"] + }, + "include": ["app/lib/definitions.ts", "app/lib/date.ts", "app/lib/auth.ts", "app/lib/profile-options.ts", "app/lib/profile.ts", "app/lib/tde.ts", "app/lib/generated-plans.ts", "tests/**/*.test.ts"], + "exclude": ["node_modules"] +} diff --git a/utils/supabase/client.ts b/utils/supabase/client.ts index c540c11..b599472 100644 --- a/utils/supabase/client.ts +++ b/utils/supabase/client.ts @@ -1,4 +1,7 @@ import { createBrowserClient } from '@supabase/ssr' +import type { SupabaseClient } from '@supabase/supabase-js' + +import type { Database } from '@/app/lib/database.types' export function createClient() { const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL @@ -10,5 +13,8 @@ export function createClient() { ) } - return createBrowserClient(supabaseUrl, supabaseKey) + // @supabase/ssr 0.6.x uses the previous SupabaseClient generic signature. + // Cast at this compatibility boundary so newer supabase-js releases retain + // the generated Database contract instead of resolving table rows to never. + return createBrowserClient(supabaseUrl, supabaseKey) as SupabaseClient } diff --git a/utils/supabase/middleware.ts b/utils/supabase/middleware.ts index 9d64ecc..a4f23fb 100644 --- a/utils/supabase/middleware.ts +++ b/utils/supabase/middleware.ts @@ -1,4 +1,7 @@ -import { createServerClient } from "@supabase/ssr"; +import { createServerClient, type SetAllCookies } from "@supabase/ssr"; +import type { SupabaseClient } from "@supabase/supabase-js"; + +import type { Database } from "@/app/lib/database.types"; import { type NextRequest, NextResponse } from "next/server"; export async function updateSession(request: NextRequest) { @@ -13,7 +16,7 @@ export async function updateSession(request: NextRequest) { { cookies: { getAll() { return request.cookies.getAll() }, - setAll(cookiesToSet) { + setAll(cookiesToSet: Parameters[0]) { try { cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value)) supabaseResponse = NextResponse.next({ @@ -26,19 +29,32 @@ export async function updateSession(request: NextRequest) { }, } } - ) + ) as SupabaseClient - // Get the user session - // This will also update the session cookie if it has changed + const requiresAuthentication = request.nextUrl.pathname.startsWith("/protected") + const redirectsAuthenticatedUser = request.nextUrl.pathname === "/" + + // Public routes do not need a remote auth check. This keeps them available + // during transient Supabase outages and makes public journeys deterministic. + if (!requiresAuthentication && !redirectsAuthenticatedUser) { + return supabaseResponse + } + + const hasAuthCookie = request.cookies.getAll().some(({ name }) => + name.startsWith("sb-") && name.includes("auth-token") + ) + if (redirectsAuthenticatedUser && !hasAuthCookie) { + return supabaseResponse + } const user = await supabase.auth.getUser(); - if (request.nextUrl.pathname.startsWith("/protected") && user.error) { + if (requiresAuthentication && user.error) { // If the user is not authenticated, redirect to the login page return NextResponse.redirect(new URL(`/login`, request.url)); } - if(request.nextUrl.pathname === "/" && !user.error) { + if(redirectsAuthenticatedUser && !user.error) { // If the user is authenticated and trying to access the home page, redirect to the protected page return NextResponse.redirect(new URL(`/protected`, request.url)); } diff --git a/utils/supabase/server.ts b/utils/supabase/server.ts index f0ad462..0fdf960 100644 --- a/utils/supabase/server.ts +++ b/utils/supabase/server.ts @@ -1,27 +1,30 @@ 'use server' -import { createServerClient } from "@supabase/ssr"; +import { createServerClient, type SetAllCookies } from "@supabase/ssr"; +import type { SupabaseClient } from "@supabase/supabase-js"; + +import type { Database } from "@/app/lib/database.types"; import { cookies } from "next/headers"; export async function createClient() { const cookieStore = await cookies() - return createServerClient ( + return createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { getAll() { return cookieStore.getAll() }, - setAll(cookiesToSet) { + setAll(cookiesToSet: Parameters[0]) { try{ cookiesToSet.forEach(({name, value, options}) => cookieStore.set(name, value, options) ) - } catch (error) { + } catch { console.error("Error setting cookies") } }, } } - ) -} \ No newline at end of file + ) as SupabaseClient +}