Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: webhooks-based approach #16

Merged
merged 12 commits into from
Mar 31, 2023
6 changes: 5 additions & 1 deletion .example.env
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
SUPABASE_URL=http://localhost:54321
SUPABSE_SERVICE_KEY=xxx

STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_SIGNING_SECRET=xxx
iuioiua marked this conversation as resolved.
Show resolved Hide resolved

API_ROUTE_SECRET=xxx
98 changes: 83 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
# Deno SaaSKit

[Deno SaaSKit](https://deno.com/saaskit) is an open sourced, highly performant
[Deno SaaSKit](https://deno.com/saaskit) is an open-sourced, highly performant
template for building your SaaS quickly and easily. This template ships with
these foundational features that every SaaS needs:

- User accounts
- User creation flows
- Landing page
- Pricing section
- Signin and session management
- Sign-in and session management
- Billing integration via Stripe
- Gated API endpoints

Expand Down Expand Up @@ -52,7 +52,10 @@ The only variables you need are:

- `SUPABASE_ANON_KEY`
- `SUPABASE_URL`
- `SUPABSE_SERVICE_KEY`
- `STRIPE_SECRET_KEY`
- `STRIPE_SIGNING_SECRET`
- `API_ROUTE_SECRET`

Continue below to learn where to grab these keys.

Expand All @@ -73,7 +76,7 @@ Once ready, you can create a Supabase account and then
[create a new Supabase project](https://app.supabase.com/projects).

Once your project is created, you can grab your `SUPABASE_URL` and
`SUPABASE_ANON_KEY` from the
`SUPABASE_ANON_KEY` from
[Settings > API](https://app.supabase.com/project/_/settings/api).

### Create a `todos` table
Expand Down Expand Up @@ -106,6 +109,74 @@ You can also keep the column `created_at` if you'd like.

Hit save and then your table should be created.

### Create a `customers` table with automattic population

- Go to `Database` > `Tables`
- Click `New Table`
- Enter the name as `customers` and check `Enable Row Level Security (RLS)`
- Configure the following columns:

| Name | Type | Default value | Primary |
| -------------------- | ------ | ------------- | ------- |
| `user_id` | `uuid` | `auth.uid()` | `true` |
| `stripe_customer_id` | `text` | `NULL` | `false` |
| `is_subscribed` | `bool` | `false` | `false` |

- Click the link symbol next to the `user_id` column name, select schema `auth`,
table `users`, and column `id`. Now the `user_id` will link back to a user
object in Supabase Auth.
- Next, go `Database` > `Functions` and click `Create a new function` with the
following values:
- Name of function = `create_customer`
- Schema = `public`
- Return type = `trigger`
- Definition =

```sql
begin
insert into public.customers(user_id)
values (new.id);
return new;
end;
```

- Click `Show advanced settings` and select `SECURITY DEFINER` under
`Type of security`
- Click `Confirm`
- Go to `Database` > `Triggers`
- Click `Create a new trigger` and enter the following values:
- Name of trigger = `new_customer`
- Table = `users auth`
- Events = `Insert`
- Trigger type = `After the event`
- Orientation = `Row`
- Function to trigger = `create_customer`

### Automate Stripe subscription updates via Supabase
iuioiua marked this conversation as resolved.
Show resolved Hide resolved

Next, [create a new webhook](https://app.supabase.com/project/_/database/hooks)
with the following values:

- Name = `create_stripe_customer`
- Table = `customers`
- Event = `Insert`
- Type of hook = `HTTP Request`
- Method = `POST`
- URL = `https://<SITE HOSTNAME>/api/customer`
- HTTP Parameters = `API_ROUTE_SECRET` for the parameter name and the value of
the `API_ROUTE_SECRET` environment variable for the parameter value.
- Click `Create webhook`

In Stripe, register a webhook endpoint by following
[this guide](https://stripe.com/docs/development/dashboard/register-webhook)
with the following values:

- Endpoint URL = `https://<SITE HOSTNAME>/api/subscription`
- Listen to `Events on your account`
- Select events to listen to:
- `customer.subscription.created`
- `customer.subscription.deleted`

### Setup authentication

[Supabase Auth](https://supabase.com/docs/guides/auth/overview) makes it simple
Expand All @@ -119,16 +190,18 @@ To setup Supabase Auth:

- Go to `Authentication` > `Providers` > `Email`
- Disable `Confirm email`
- Back on the left hand bar, under `Configuration`, click on `Policies`
- Click `New Policy` and then `Create a policy from scratch`
- Back on the left-hand bar, under `Configuration`, click on `Policies`
- Click `New Policy` on the `customers` table pane and then
`Create a policy from scratch`
- Enter the policy name as `Enable all operations for users based on user_id`
- For `Allowed operation`, select `All`
- For `Target Roles` select `authenticated`
- Enter the `USING expression` as `(auth.uid() = user_id)`
- Enter the `WITH CHECK expression` as `(auth.uid() = user_id)`
- Click `Review` then `Save policy`
- Repeat for the `todos` table pane

These steps enable using email with Supabase Auth, and provides a simple
These steps enable using email with Supabase Auth and provide a simple
authentication strategy restricting each user to only create, read, update, and
delete their own data.

Expand All @@ -137,7 +210,7 @@ delete their own data.
Currently, Deno SaaSKit uses [Stripe](https://stripe.com) for subscription
billing. In the future, we are open to adding other payment processors.

To setup Stripe:
To set up Stripe:

- Create a Stripe account
- Since upgrading to a paid tier will take you directly to Stripe's domain, we
Expand All @@ -159,13 +232,8 @@ Once you have all of this setup, you should be able to run Deno SaaSKit locally.

### Running locally

You can start the project by running:

```
deno task start
```

And going to `localhost:8000` on your browser.
You can start the project by running: And go to `localhost:8000`` on your
browser.

## Customizing Deno SaaSKit

Expand Down Expand Up @@ -211,7 +279,7 @@ can be found in these locations:

### Dashboard

This template comes with a simple To Do checklist app. All of the logic for that
This template comes with a simple To-Do checklist app. All of the logic for that
can be found:

- `/routes/dashboard/api/todo.ts`: the API route to handle creating and deleting
Expand Down
60 changes: 32 additions & 28 deletions fresh.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,40 +5,44 @@
import config from "./deno.json" assert { type: "json" };
import * as $0 from "./routes/_404.tsx";
import * as $1 from "./routes/_500.tsx";
import * as $2 from "./routes/api/login.ts";
import * as $3 from "./routes/api/logout.ts";
import * as $4 from "./routes/api/signup.ts";
import * as $5 from "./routes/dashboard/_middleware.ts";
import * as $6 from "./routes/dashboard/account.tsx";
import * as $7 from "./routes/dashboard/api/todo.ts";
import * as $8 from "./routes/dashboard/index.tsx";
import * as $9 from "./routes/dashboard/manage-subscription.ts";
import * as $10 from "./routes/dashboard/todos.tsx";
import * as $11 from "./routes/dashboard/upgrade-subscription.ts";
import * as $12 from "./routes/index.tsx";
import * as $13 from "./routes/login.tsx";
import * as $14 from "./routes/logout.ts";
import * as $15 from "./routes/signup.tsx";
import * as $2 from "./routes/api/customer.ts";
import * as $3 from "./routes/api/login.ts";
import * as $4 from "./routes/api/logout.ts";
import * as $5 from "./routes/api/signup.ts";
import * as $6 from "./routes/api/subscription.ts";
import * as $7 from "./routes/dashboard/_middleware.ts";
import * as $8 from "./routes/dashboard/account.tsx";
import * as $9 from "./routes/dashboard/api/todo.ts";
import * as $10 from "./routes/dashboard/index.tsx";
import * as $11 from "./routes/dashboard/manage-subscription.ts";
import * as $12 from "./routes/dashboard/todos.tsx";
import * as $13 from "./routes/dashboard/upgrade-subscription.ts";
import * as $14 from "./routes/index.tsx";
import * as $15 from "./routes/login.tsx";
import * as $16 from "./routes/logout.ts";
import * as $17 from "./routes/signup.tsx";
import * as $$0 from "./islands/TodoList.tsx";

const manifest = {
routes: {
"./routes/_404.tsx": $0,
"./routes/_500.tsx": $1,
"./routes/api/login.ts": $2,
"./routes/api/logout.ts": $3,
"./routes/api/signup.ts": $4,
"./routes/dashboard/_middleware.ts": $5,
"./routes/dashboard/account.tsx": $6,
"./routes/dashboard/api/todo.ts": $7,
"./routes/dashboard/index.tsx": $8,
"./routes/dashboard/manage-subscription.ts": $9,
"./routes/dashboard/todos.tsx": $10,
"./routes/dashboard/upgrade-subscription.ts": $11,
"./routes/index.tsx": $12,
"./routes/login.tsx": $13,
"./routes/logout.ts": $14,
"./routes/signup.tsx": $15,
"./routes/api/customer.ts": $2,
"./routes/api/login.ts": $3,
"./routes/api/logout.ts": $4,
"./routes/api/signup.ts": $5,
"./routes/api/subscription.ts": $6,
"./routes/dashboard/_middleware.ts": $7,
"./routes/dashboard/account.tsx": $8,
"./routes/dashboard/api/todo.ts": $9,
"./routes/dashboard/index.tsx": $10,
"./routes/dashboard/manage-subscription.ts": $11,
"./routes/dashboard/todos.tsx": $12,
"./routes/dashboard/upgrade-subscription.ts": $13,
"./routes/index.tsx": $14,
"./routes/login.tsx": $15,
"./routes/logout.ts": $16,
"./routes/signup.tsx": $17,
},
islands: {
"./islands/TodoList.tsx": $$0,
Expand Down
4 changes: 2 additions & 2 deletions islands/TodoList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,15 @@ async function deleteTodo(
}

interface TodoListProps {
subscribed: boolean;
isSubscribed: boolean;
todos: Todo[];
}

export default function TodoList(props: TodoListProps) {
const todos = useSignal(props.todos);
const newTodoRef = useRef<HTMLInputElement | null>(null);

const isMoreTodos = props.subscribed ||
const isMoreTodos = props.isSubscribed ||
todos.value.length < FREE_PLAN_TODOS_LIMIT;

return (
Expand Down
58 changes: 58 additions & 0 deletions routes/api/customer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { Handlers } from "$fresh/server.ts";
import { stripe } from "@/utils/stripe.ts";
import { supabaseAdminClient } from "@/utils/supabase.ts";
import type { SupabaseClient } from "@supabase/supabase-js";
import { Database } from "@/utils/supabase_types.ts";

/**
* Ensures that the Supabase request is authenticated based on the `API_ROUTE_SECRET` header.
*
* This function can be reused where in other Supabase webhook handlers.
*/
function hasRouteSecret(request: Request) {
return new URL(request.url).searchParams.get("API_ROUTE_SECRET") ===
Deno.env.get("API_ROUTE_SECRET");
}

interface SetStripeCustomerIdConfig {
userId: string;
stripeCustomerId: string;
}

export async function setStripeCustomerId(
supabaseClient: SupabaseClient<Database>,
{ userId, stripeCustomerId }: SetStripeCustomerIdConfig,
) {
await supabaseClient
.from("customers")
.update({ stripe_customer_id: stripeCustomerId })
.eq("user_id", userId)
.throwOnError();
}

export const handler: Handlers = {
/**
* This handler handles Supabase webhook when an insert event is triggered on the `users_customers` table.
* A HTTP parameter of key `API_ROUTE_SECRET` and value of that in the `.env` file must be set.
*/
async POST(request) {
if (!hasRouteSecret(request)) {
await request.body?.cancel();
return new Response(null, { status: 401 });
}

const { record: { user_id } } = await request.json();
const { data } = await supabaseAdminClient.auth.admin.getUserById(user_id);

const customer = await stripe.customers.create({
email: data.user!.email,
});

await setStripeCustomerId(supabaseAdminClient, {
userId: user_id,
stripeCustomerId: customer.id,
});

return Response.json(null, { status: 201 });
},
};
12 changes: 5 additions & 7 deletions routes/api/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,15 @@ export const handler: Handlers = {
const form = await request.formData();
const email = form.get("email");
const password = form.get("password");

assert(typeof email === "string");
assert(typeof password === "string");

const headers = new Headers();
const supabaseClient = createSupabaseClient(request.headers, headers);
const { error } = await supabaseClient.auth.signInWithPassword({
email,
password,
});
const { error } = await createSupabaseClient(request.headers, headers)
.auth.signInWithPassword({
email,
password,
});

let redirectUrl = new URL(request.url).searchParams.get("redirect_url") ??
AUTHENTICATED_REDIRECT_PATH;
Expand All @@ -26,7 +25,6 @@ export const handler: Handlers = {
}

headers.set("location", redirectUrl);

return new Response(null, { headers, status: 302 });
},
};
4 changes: 2 additions & 2 deletions routes/api/logout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { createSupabaseClient } from "@/utils/supabase.ts";
export const handler: Handlers = {
async GET(request) {
const headers = new Headers({ location: "/" });
const supabaseClient = createSupabaseClient(request.headers, headers);
const { error } = await supabaseClient.auth.signOut();
const { error } = await createSupabaseClient(request.headers, headers)
.auth.signOut();
if (error) throw error;

return new Response(null, { headers, status: 302 });
Expand Down
Loading