diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 861b0bdd..1a5c8ae3 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -26,6 +26,13 @@ module.exports = { }, }, }, + { + // Apply to all test files. Proper type checking in tests with mocks can be tedious and counterproductive. + files: ["**/*.test.ts", "**/*.spec.ts"], + rules: { + "@typescript-eslint/no-explicit-any": "off", + }, + }, ], env: { browser: true, diff --git a/.vscode/settings.json b/.vscode/settings.json index 0a39a7a2..78afa4dd 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,13 @@ { "editor.formatOnSave": true, - "editor.defaultFormatter": "svelte.svelte-vscode", - "eslint.validate": ["javascript", "javascriptreact", "svelte"] + "[svelte]": { + "editor.defaultFormatter": "svelte.svelte-vscode" + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "eslint.validate": ["javascript", "javascriptreact", "typescript", "svelte"] } diff --git a/README.md b/README.md index 4a5561b5..2e08599e 100644 --- a/README.md +++ b/README.md @@ -193,9 +193,14 @@ Finally: if you find build, formatting or linting rules too tedious, you can dis - Create a Supabase account - Create a new Supabase project in the console - Wait for the database to launch -- Create your user management tables in the database - - Go to the [SQL Editor](https://supabase.com/dashboard/project/_/sql) page in the Dashboard. - - Paste the SQL from `database_migration.sql` in this repo to create your user/profiles table and click run. +- Set up your database schema: + - For new Supabase projects: + - Go to the [SQL Editor](https://supabase.com/dashboard/project/_/sql) page in the Dashboard. + - Run the SQL from `database_migration.sql` to create the initial schema. + - For existing projects: + - Apply migrations from the `supabase/migrations` directory: + 1. Go to the Supabase dashboard's SQL Editor. + 2. Identify the last migration you applied, then run the SQL content of each subsequent file in chronological order. - Enable user signups in the [Supabase console](https://app.supabase.com/project/_/settings/auth): sometimes new signups are disabled by default in Supabase projects - Go to the [API Settings](https://supabase.com/dashboard/project/_/settings/api) page in the Dashboard. Find your Project-URL (PUBLIC_SUPABASE_URL), anon (PUBLIC_SUPABASE_ANON_KEY) and service_role (PRIVATE_SUPABASE_SERVICE_ROLE). - For local development: create a `.env.local` file: diff --git a/database_migration.sql b/database_migration.sql index d78d6f15..d1b212e8 100644 --- a/database_migration.sql +++ b/database_migration.sql @@ -5,7 +5,8 @@ create table profiles ( full_name text, company_name text, avatar_url text, - website text + website text, + unsubscribed boolean NOT NULL DEFAULT false ); -- Set up Row Level Security (RLS) -- See https://supabase.com/docs/guides/auth/row-level-security for more details. @@ -69,4 +70,4 @@ create policy "Avatar images are publicly accessible." on storage.objects for select using (bucket_id = 'avatars'); create policy "Anyone can upload an avatar." on storage.objects - for insert with check (bucket_id = 'avatars'); \ No newline at end of file + for insert with check (bucket_id = 'avatars'); diff --git a/src/DatabaseDefinitions.ts b/src/DatabaseDefinitions.ts index 91bbc6d1..3d9122a1 100644 --- a/src/DatabaseDefinitions.ts +++ b/src/DatabaseDefinitions.ts @@ -50,6 +50,7 @@ export interface Database { updated_at: string | null company_name: string | null website: string | null + unsubscribed: boolean } Insert: { avatar_url?: string | null @@ -58,6 +59,7 @@ export interface Database { updated_at?: Date | null company_name?: string | null website?: string | null + unsubscribed: boolean } Update: { avatar_url?: string | null @@ -66,6 +68,7 @@ export interface Database { updated_at?: string | null company_name?: string | null website?: string | null + unsubscribed: boolean } Relationships: [ { diff --git a/src/lib/emails/welcome_email_html.svelte b/src/lib/emails/welcome_email_html.svelte index cdac05cc..2dbfb0c2 100644 --- a/src/lib/emails/welcome_email_html.svelte +++ b/src/lib/emails/welcome_email_html.svelte @@ -1,4 +1,6 @@ + + + Change Email Subscription + + +

+ {unsubscribed ? "Re-subscribe to Emails" : "Unsubscribe from Emails"} +

+ + diff --git a/src/routes/(admin)/account/api/+page.server.ts b/src/routes/(admin)/account/api/+page.server.ts index 9be2ba3a..c8356a4d 100644 --- a/src/routes/(admin)/account/api/+page.server.ts +++ b/src/routes/(admin)/account/api/+page.server.ts @@ -2,6 +2,34 @@ import { fail, redirect } from "@sveltejs/kit" import { sendAdminEmail, sendUserEmail } from "$lib/mailer" export const actions = { + toggleEmailSubscription: async ({ locals: { supabase, safeGetSession } }) => { + const { session } = await safeGetSession() + + if (!session) { + redirect(303, "/login") + } + + const { data: currentProfile } = await supabase + .from("profiles") + .select("unsubscribed") + .eq("id", session.user.id) + .single() + + const newUnsubscribedStatus = !currentProfile?.unsubscribed + + const { error } = await supabase + .from("profiles") + .update({ unsubscribed: newUnsubscribedStatus }) + .eq("id", session.user.id) + + if (error) { + return fail(500, { message: "Failed to update subscription status" }) + } + + return { + unsubscribed: newUnsubscribedStatus, + } + }, updateEmail: async ({ request, locals: { supabase, safeGetSession } }) => { const { session } = await safeGetSession() if (!session) { @@ -254,6 +282,7 @@ export const actions = { company_name: companyName, website: website, updated_at: new Date(), + unsubscribed: priorProfile?.unsubscribed ?? false, }) .select() diff --git a/src/routes/(admin)/account/api/page.server.test.ts b/src/routes/(admin)/account/api/page.server.test.ts new file mode 100644 index 00000000..5d63e8d8 --- /dev/null +++ b/src/routes/(admin)/account/api/page.server.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { actions } from "./+page.server" +import { fail, redirect } from "@sveltejs/kit" + +vi.mock("@sveltejs/kit", async () => { + const actual = await vi.importActual("@sveltejs/kit") + return { + ...actual, + fail: vi.fn(), + redirect: vi.fn().mockImplementation(() => { + throw new Error("Redirect error") + }), + } +}) + +describe("toggleEmailSubscription", () => { + const mockSupabase = { + from: vi.fn().mockReturnThis(), + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + single: vi.fn().mockResolvedValue({ data: null }), + update: vi.fn().mockReturnThis(), + } + + const mockSafeGetSession = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it("should redirect if no session", async () => { + mockSafeGetSession.mockResolvedValue({ session: null }) + + await expect( + actions.toggleEmailSubscription({ + locals: { + supabase: mockSupabase, + safeGetSession: mockSafeGetSession, + }, + } as any), + ).rejects.toThrow("Redirect") + + expect(redirect).toHaveBeenCalledWith(303, "/login") + }) + + it("should toggle subscription status from false to true", async () => { + const mockSession = { user: { id: "user123" } } + mockSafeGetSession.mockResolvedValue({ session: mockSession }) + + // Mock the first query to get the current status + mockSupabase.single.mockResolvedValueOnce({ data: { unsubscribed: false } }) + + // Mock the update query + const mockUpdateChain = { + eq: vi.fn().mockResolvedValue({ error: null }), + } + + mockSupabase.update.mockReturnValue(mockUpdateChain) + + const result = await actions.toggleEmailSubscription({ + locals: { supabase: mockSupabase, safeGetSession: mockSafeGetSession }, + } as any) + + expect(mockSupabase.from).toHaveBeenCalledWith("profiles") + expect(mockSupabase.select).toHaveBeenCalledWith("unsubscribed") + expect(mockSupabase.eq).toHaveBeenCalledWith("id", "user123") + expect(mockSupabase.single).toHaveBeenCalled() + expect(mockSupabase.update).toHaveBeenCalledWith({ unsubscribed: true }) + expect(mockUpdateChain.eq).toHaveBeenCalledWith("id", "user123") + expect(result).toEqual({ unsubscribed: true }) + }) + + it("should toggle subscription status from true to false", async () => { + const mockSession = { user: { id: "user123" } } + mockSafeGetSession.mockResolvedValue({ session: mockSession }) + + // Mock the first query to get the current status + mockSupabase.single.mockResolvedValueOnce({ data: { unsubscribed: true } }) + + // Mock the update query + const mockUpdateChain = { + eq: vi.fn().mockResolvedValue({ error: null }), + } + + mockSupabase.update.mockReturnValue(mockUpdateChain) + + const result = await actions.toggleEmailSubscription({ + locals: { supabase: mockSupabase, safeGetSession: mockSafeGetSession }, + } as any) + + expect(mockSupabase.from).toHaveBeenCalledWith("profiles") + expect(mockSupabase.select).toHaveBeenCalledWith("unsubscribed") + expect(mockSupabase.eq).toHaveBeenCalledWith("id", "user123") + expect(mockSupabase.single).toHaveBeenCalled() + expect(mockSupabase.update).toHaveBeenCalledWith({ unsubscribed: false }) + expect(mockUpdateChain.eq).toHaveBeenCalledWith("id", "user123") + expect(result).toEqual({ unsubscribed: false }) + }) + + it("should return fail response if update operation fails", async () => { + const mockSession = { user: { id: "user123" } } + mockSafeGetSession.mockResolvedValue({ session: mockSession }) + + // Mock the first query to get the current status + mockSupabase.single.mockResolvedValueOnce({ data: { unsubscribed: false } }) + + // Mock the update query to return an error + const mockUpdateChain = { + eq: vi.fn().mockResolvedValue({ error: new Error("Update failed") }), + } + + mockSupabase.update.mockReturnValue(mockUpdateChain) + + await actions.toggleEmailSubscription({ + locals: { supabase: mockSupabase, safeGetSession: mockSafeGetSession }, + } as any) + + // Check if fail was called with the correct arguments + expect(fail).toHaveBeenCalledWith(500, { + message: "Failed to update subscription status", + }) + }) +}) diff --git a/supabase/migrations/20240730010101_initial.sql b/supabase/migrations/20240730010101_initial.sql new file mode 100644 index 00000000..076ae11d --- /dev/null +++ b/supabase/migrations/20240730010101_initial.sql @@ -0,0 +1,72 @@ +-- Create a table for public profiles +create table profiles ( + id uuid references auth.users on delete cascade not null primary key, + updated_at timestamp with time zone, + full_name text, + company_name text, + avatar_url text, + website text +); +-- Set up Row Level Security (RLS) +-- See https://supabase.com/docs/guides/auth/row-level-security for more details. +alter table profiles + enable row level security; + +create policy "Profiles are viewable by self." on profiles + for select using (auth.uid() = id); + +create policy "Users can insert their own profile." on profiles + for insert with check (auth.uid() = id); + +create policy "Users can update own profile." on profiles + for update using (auth.uid() = id); + +-- Create Stripe Customer Table +-- One stripe customer per user (PK enforced) +-- Limit RLS policies -- mostly only server side access +create table stripe_customers ( + user_id uuid references auth.users on delete cascade not null primary key, + updated_at timestamp with time zone, + stripe_customer_id text unique +); +alter table stripe_customers enable row level security; + +-- Create a table for "Contact Us" form submissions +-- Limit RLS policies -- only server side access +create table contact_requests ( + id uuid primary key default gen_random_uuid(), + updated_at timestamp with time zone, + first_name text, + last_name text, + email text, + phone text, + company_name text, + message_body text +); +alter table contact_requests enable row level security; + +-- This trigger automatically creates a profile entry when a new user signs up via Supabase Auth. +-- See https://supabase.com/docs/guides/auth/managing-user-data#using-triggers for more details. +create function public.handle_new_user() +returns trigger as $$ +begin + insert into public.profiles (id, full_name, avatar_url) + values (new.id, new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'avatar_url'); + return new; +end; +$$ language plpgsql security definer; +create trigger on_auth_user_created + after insert on auth.users + for each row execute procedure public.handle_new_user(); + +-- Set up Storage! +insert into storage.buckets (id, name) + values ('avatars', 'avatars'); + +-- Set up access controls for storage. +-- See https://supabase.com/docs/guides/storage#policy-examples for more details. +create policy "Avatar images are publicly accessible." on storage.objects + for select using (bucket_id = 'avatars'); + +create policy "Anyone can upload an avatar." on storage.objects + for insert with check (bucket_id = 'avatars'); diff --git a/supabase/migrations/20240731051052_add_unsubscribed_to_profiles.sql b/supabase/migrations/20240731051052_add_unsubscribed_to_profiles.sql new file mode 100644 index 00000000..1042d6d1 --- /dev/null +++ b/supabase/migrations/20240731051052_add_unsubscribed_to_profiles.sql @@ -0,0 +1,2 @@ +ALTER TABLE profiles +ADD COLUMN IF NOT EXISTS unsubscribed boolean NOT NULL DEFAULT false; diff --git a/tsconfig.json b/tsconfig.json index f1da068b..59aa71ce 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,7 +8,8 @@ "resolveJsonModule": true, "skipLibCheck": true, "sourceMap": true, - "strict": true + "strict": true, + "types": ["vitest/globals"] // allows to skip import of test functions like `describe`, `it`, `expect`, etc. } // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias and https://kit.svelte.dev/docs/configuration#files // diff --git a/vite.config.ts b/vite.config.ts index d1d54a1d..0eebd751 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,5 +5,6 @@ export default defineConfig({ plugins: [sveltekit()], test: { include: ["src/**/*.{test,spec}.{js,ts}"], + globals: true, /// allows to skip import of test functions like `describe`, `it`, `expect`, etc. }, })