├── .env_example ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .vscode └── settings.json ├── CHANGELOG.md ├── DRIZZLEISSUES.md ├── LICENSE ├── README.md ├── app.vue ├── assets ├── css │ └── tailwind.css └── images │ ├── avatar.svg │ ├── landing_config_environment.jpeg │ ├── landing_db_schema_management.jpeg │ ├── landing_state_management.jpeg │ ├── landing_stripe_integration.jpeg │ ├── landing_style_system.jpeg │ ├── landing_user_management.jpeg │ ├── saas_landing_main.jpeg │ ├── supanuxt_logo_100.png │ ├── supanuxt_logo_200.png │ ├── supanuxt_logo_400.png │ ├── supanuxt_logo_800.png │ └── technical_architecture.png ├── components ├── AppFooter.vue ├── AppHeader.vue ├── Modal.vue ├── Notifications.client.vue ├── UserAccount │ ├── UserAccount.vue │ ├── UserAccountSignout.client.vue │ └── UserAccountSwitch.client.vue └── modal.type.ts ├── drizzle ├── drizzle.client.ts ├── migrate.ts ├── relation.types.ts ├── schema.ts └── seed.ts ├── lib └── services │ ├── account.service.ts │ ├── auth.service.ts │ ├── errors.ts │ ├── notes.service.ts │ ├── openai.client.ts │ ├── service.types.ts │ └── util.service.ts ├── middleware └── auth.ts ├── nuxt.config.ts ├── package-lock.json ├── package.json ├── pages ├── account.vue ├── cancel.vue ├── confirm.vue ├── contact.vue ├── dashboard.vue ├── deletemyaccount.vue ├── fail.vue ├── forgotpassword.vue ├── index.vue ├── join │ └── [join_password].vue ├── notes │ └── [note_id].vue ├── pricing.vue ├── privacy.vue ├── resetpassword.vue ├── signin.vue ├── signup.vue ├── success.vue └── terms.vue ├── patches ├── 1_4_2-service-refactor-to-namespaces.patch └── 1_4_2-service-refactor-to-namespaces_authcontextremoved.patch ├── plugins ├── cookieconsent.client.ts └── trpcClient.ts ├── public ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico └── site.webmanifest ├── server ├── api │ ├── note.ts │ └── trpc │ │ └── [trpc].ts ├── defineProtectedEventHandler.ts ├── middleware │ └── authContext.ts ├── routes │ ├── create-checkout-session.post.ts │ └── webhook.post.ts └── trpc │ ├── context.ts │ ├── routers │ ├── account.router.ts │ ├── app.router.ts │ ├── auth.router.ts │ └── notes.router.ts │ └── trpc.ts ├── stores ├── account.store.ts ├── notes.store.ts └── notify.store.ts ├── tailwind.config.js ├── test ├── TEST.md ├── account.store.spec.ts └── notify.store.spec.ts ├── tsconfig.json └── vitest.config.ts /.env_example: -------------------------------------------------------------------------------- 1 | SUPABASE_URL=https://xxxxxxxxxxxxxxxxxxxx.supabase.co 2 | SUPABASE_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxx.xxxxxx-xxxxx 3 | 4 | STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 5 | STRIPE_ENDPOINT_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 6 | 7 | DATABASE_URL="postgresql://postgres:xxxxxxxxxxxxx@db.xxxxxxxxxxxxx.supabase.co:5432/postgres" 8 | 9 | OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log* 3 | .nuxt 4 | .nitro 5 | .cache 6 | .output 7 | .env 8 | dist 9 | junk 10 | .DS_Store 11 | migrations 12 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | CHANGELOG.md 2 | LICENSE 3 | package.json 4 | package-lock.json 5 | node_modules 6 | *.log* 7 | .nuxt 8 | .nitro 9 | .cache 10 | .output 11 | .env 12 | .env_example 13 | dist 14 | junk 15 | assets 16 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "bracketSameLine": true, 4 | "vueIndentScriptAndStyle": true, 5 | "arrowParens": "avoid", 6 | "trailingComma": "none" 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "[vue]": { 4 | "editor.defaultFormatter": "esbenp.prettier-vscode" 5 | }, 6 | "typescript.tsdk": "node_modules/typescript/lib" 7 | } 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Version 1.4.3 4 | ### Update All Dependencies to latest 5 | - openai (3.3.0 -> 4.28.0) 6 | - superjson (1.12.2 -> 2.2.1) 7 | - node types (18.15.11 -> 20.11.19) 8 | - stripe lib (11.12.0 -> 14.17.0) 9 | - stripe api version (2022-11-15 -> 2023-10-16) 10 | - cookie consent (2.9.2 -> 3.0.0) 11 | - daisyui (2.51.5 -> 4.7.2) 12 | - vitest (0.33.0 -> 1.3.0) 13 | - other minor and patch versions 14 | 15 | 16 | ## Version 1.4.2 17 | - Added Favicons and web manifest and referenced in nuxt.config (I used https://favicon.io/favicon-converter/ to generate the icon assets, seems to work well) 18 | - Added patch folder to hold patch files, should make it easier to update repos based on earlier versions 19 | - Refactor service classes into namespaces to avoid pointless service instantiation (1_4_2-service-refactor-to-namespaces_authcontextremoved.patch or 1_4_2-service-refactor-to-namespaces.patch) 20 | - Added an Acount Deletion page - you will need to show you have one of these (along with privacy and terms pages) for several signups e.g. Facebook login 21 | - Added seoMeta - required for Facebook login 22 | 23 | ## Version 1.4.1 24 | 25 | - Refactor some components and explicitly split out client only components 26 | - Fix bug in the notifications 27 | - Update readme to indicate sister project in react/next 28 | 29 | ## Version 1.4.0 30 | 31 | - Cookie Consent 32 | `npm i vanilla-cookieconsent` 33 | 34 | ## Version 1.3.0 35 | 36 | - Add an example of usage limits (Notes AI Gen). 37 | - Includes non-destructive schema changes 38 | `npx prisma db push` 39 | 40 | ## Version 1.2.0 41 | 42 | - 'Lift' auth context into server middleware to support authenticated api (rest) endpoints for alternate clients while still supporting fully typed Trpc context. 43 | 44 | ## Version 1.1.0 45 | 46 | - Upgrade Prisma to version 5 to improve performance (https://www.prisma.io/docs/guides/upgrade-guides/upgrading-versions/upgrading-to-prisma-5) 47 | 48 | ``` 49 | npm install @prisma/client@5 50 | npm install -D prisma@5 51 | npx prisma generate 52 | ``` 53 | 54 | - Upgrade Nuxt to 3.7.0 55 | 56 | ``` 57 | npx nuxi upgrade --force 58 | ``` 59 | 60 | ## Version 1.0.0 61 | 62 | First Release version. If your package.json does not have a version attribute, this is the version you have. 63 | 64 | ## Project Creation (for interest only) 65 | 66 | This is what I did to create the project including all the extra fiddly stuff. Putting this here so I don't forget. 67 | 68 | ### Setup Nuxt 69 | 70 | I Followed instructions from here https://nuxt.com/docs/getting-started/installation 71 | 72 | ```bash 73 | # install node 74 | n lts 75 | npx nuxi init nuxt3-boilerplate 76 | code nuxt3-boilerplate/ 77 | npm install 78 | npm run dev -- -o 79 | ``` 80 | 81 | ### Setup Supabase 82 | 83 | To setup supabase and middleware, loosely follow instructions from https://www.youtube.com/watch?v=IcaL1RfnU44 84 | remember to update email template 85 | Supabase - new account (free tier), used github oath for supabase account 86 | 87 | ``` 88 | npm install @nuxtjs/supabase 89 | ``` 90 | 91 | add this to nuxt.config.ts 92 | 93 | ``` 94 | modules: ['@nuxtjs/supabase'] 95 | ``` 96 | 97 | ### Setup Google OAuth 98 | 99 | Follow these instructions to add google oath https://supabase.com/docs/guides/auth/social-login/auth-google 100 | 101 | ### Nuxt-Supabase 102 | 103 | Then I frigged around trying to get the nuxt-supabase module to work properly for the oauth flow. It's a bit of a mess TBH. Eventually I looked at the demo https://github.com/nuxt-modules/supabase/tree/main/demo like a chump and got it working 104 | 105 | ### Integrating Prisma 106 | 107 | This felt like a difficult decision at first. the Subabase client has some pseudo sql Ormy sort of features already 108 | but Prisma has this awesome schema management support and autogeneration of a typed client works great and reduces errors. 109 | I already had a schema lying around based on this (https://blog.checklyhq.com/building-a-multi-tenant-saas-data-model/) that was nearly what I needed and it was nice to be able to re-use it. 110 | 111 | ``` 112 | npm install prisma --save-dev 113 | npx prisma init 114 | ``` 115 | 116 | go to Supabase -> settings -> database -> connection string -> URI.. and copy the URI into the 117 | DATABASE_URL setting created with prisma init. 118 | still in database, go to 'Database password' and reset/set it and copy the password into the [YOUR-PASSWORD] placeholder in the URI 119 | 120 | Then I manually hand coded the schema.prisma file based on something else I already had. 121 | 122 | ``` 123 | npx prisma db push 124 | npm install @prisma/client --save-dev 125 | npx prisma generate 126 | ``` 127 | 128 | ### Stripe Integration 129 | 130 | This was a royal pain in the butt. Got some tips from https://github.com/jurassicjs/nuxt3-fullstack-tutorial and https://www.youtube.com/watch?v=A24aKCQ-rf4&t=895s Official docs try to be helpful but succeed only in confusing things https://stripe.com/docs/billing/quickstart 131 | 132 | I set up a Stripe account with a couple of 'Products' with a single price each to represent my different plans. These price id's are embedded into the Pricing page. 133 | 134 | ### Key things I learned 135 | 136 | - You need to need to pre-emptively create a Stripe user _before_ you send them to the checkout page so that you know who they are when the webhook comes back. 137 | - There are like a Billion Fricking Webhooks you _can_ subscribe to but for an MVP, you just need the _customer.subscription_ events and you basically treat them all the same. 138 | -------------------------------------------------------------------------------- /DRIZZLEISSUES.md: -------------------------------------------------------------------------------- 1 | # Drizzle Issues 2 | 3 | I encountered several things that I didn't like very much. 4 | 5 | ### Cannot infer types from nested with relations + cannot DRY the where clauses 6 | 7 | In the Prisma version, I can do this in service types... 8 | 9 | ```ts 10 | export const accountWithMembers = Prisma.validator()({ 11 | include: { 12 | members: { 13 | include: { 14 | user: true 15 | } 16 | } 17 | } 18 | }); 19 | 20 | export type AccountWithMembers = Prisma.AccountGetPayload 21 | ``` 22 | 23 | This exports both a type (AccountWithMembers) and a handy dandy (accountWithMembers) constant I can use in my queries to define a where clause that faithfully and completely matches the type definition... 24 | 25 | ```ts 26 | await prisma_client.account.findFirstOrThrow({ 27 | where: { id: account_id }, 28 | ...accountWithMembers 29 | }); 30 | ``` 31 | 32 | in Drizzle, I can use a crazy workaround dynamic type thingo (drizzle/relation.types.ts) to get my nested type... 33 | 34 | ```ts 35 | export type AccountWithMembers = InferResultType< 36 | 'account', 37 | { members: { with: { user: true } } } 38 | >; 39 | ``` 40 | 41 | but I couldn't figure out how to DRY the where clause bit, so the service method specifies it again... 42 | 43 | ```ts 44 | await drizzle_client.query.account.findFirst({ 45 | where: eq(account.id, account_id), 46 | with: { members: { with: { user: true } } } 47 | }); 48 | ``` 49 | 50 | Issue is mentioned in github https://github.com/drizzle-team/drizzle-orm/issues/695 51 | 52 | ### FindFirstOrThrow, FindUniqueOrThrow etc... don't exist 53 | 54 | This is just annoying, in Drizzle, I need to check and throw all over the place. 55 | 56 | ```ts 57 | const this_account = await drizzle_client.query.account.findFirst({ 58 | where: eq(account.id, account_id), 59 | with: { members: { with: { user: true } } } 60 | }); 61 | 62 | if (!this_account) { 63 | throw new Error('Account not found.'); 64 | } 65 | ``` 66 | 67 | ### Single updates and deletions return an array instead of a single object 68 | 69 | In Prisma, an update of a single row returns a single object... 70 | 71 | ```ts 72 | export async function updateAccountStipeCustomerId( 73 | account_id: number, 74 | stripe_customer_id: string 75 | ) { 76 | return await prisma_client.account.update({ 77 | where: { id: account_id }, 78 | data: { 79 | stripe_customer_id 80 | } 81 | }); 82 | } 83 | ``` 84 | 85 | in Drizzle, updates always return an array. I had a look at the Drizzle Discord and, hilariously, this question was asked many times with no answer. 86 | 87 | ```ts 88 | export async function updateAccountStipeCustomerId( 89 | account_id: number, 90 | stripe_customer_id: string 91 | ) { 92 | const updatedAccounts = await drizzle_client 93 | .update(account) 94 | .set({ stripe_customer_id }) 95 | .where(eq(account.id, account_id)) 96 | .returning(); 97 | return updatedAccounts[0] as Account; 98 | } 99 | ``` 100 | 101 | It's just a quality of life thing but I have lots of these single row updates so it's annoying. 102 | 103 | ### No Deep relations on update or delete 104 | 105 | In Prisma, I can update or delete and return a deeply nested type in one statement 106 | 107 | ```ts 108 | export async function deleteUser(user_id: number): Promise { 109 | return prisma_client.user.delete({ 110 | where: { id: user_id }, 111 | ...fullDBUser 112 | }); 113 | } 114 | ``` 115 | 116 | In Drizzle, while I can workaround this for updates by doing an update and select... 117 | 118 | ...for deletes, it doesn't seem to be possible.. maybe a select and delete? 119 | 120 | ```ts 121 | export async function deleteUser(user_id: number): Promise { 122 | const deletedUser = await drizzle_client 123 | .delete(user) 124 | .where(eq(user.id, user_id)) 125 | .returning(); 126 | 127 | // This feels silly 128 | return deletedUser[0] as User; 129 | } 130 | ``` 131 | 132 | ### Nested Create doesn't exist 133 | 134 | In Prisma, I can create a deeply nested object into multiple tables with one statement. For example, here is me creating a user, account and the membership 135 | record (a join table) between the user and the account in one go... much nice. 136 | 137 | ```ts 138 | prisma_client.user.create({ 139 | data: { 140 | supabase_uid: supabase_uid, 141 | display_name: display_name, 142 | email: email, 143 | memberships: { 144 | create: { 145 | account: { 146 | create: { 147 | name: display_name, 148 | current_period_ends: UtilService.addMonths( 149 | new Date(), 150 | config.initialPlanActiveMonths 151 | ), 152 | plan_id: trialPlan.id, 153 | features: trialPlan.features, 154 | max_notes: trialPlan.max_notes, 155 | max_members: trialPlan.max_members, 156 | plan_name: trialPlan.name, 157 | join_password: join_password 158 | } 159 | }, 160 | access: ACCOUNT_ACCESS.OWNER 161 | } 162 | } 163 | }, 164 | ...fullDBUser 165 | }); 166 | ``` 167 | 168 | In Drizzle, I gotta create rows and frig around with ids... 169 | 170 | ```ts 171 | const newAccountId: { insertedId: number }[] = await drizzle_client 172 | .insert(account) 173 | .values({ 174 | name: display_name, 175 | current_period_ends: UtilService.addMonths( 176 | new Date(), 177 | config.initialPlanActiveMonths 178 | ).toDateString(), 179 | plan_id: trialPlan.id, 180 | features: trialPlan.features, 181 | max_notes: trialPlan.max_notes, 182 | max_members: trialPlan.max_members, 183 | plan_name: trialPlan.name, 184 | join_password: join_password 185 | }) 186 | .returning({ insertedId: account.id }); 187 | 188 | const newUserId: { insertedId: number }[] = await drizzle_client 189 | .insert(user) 190 | .values({ 191 | supabase_uid: supabase_uid, 192 | display_name: display_name, 193 | email: email 194 | }) 195 | .returning({ insertedId: user.id }); 196 | 197 | const newMembershipId: { insertedId: number }[] = await drizzle_client 198 | .insert(membership) 199 | .values({ 200 | account_id: newAccountId[0].insertedId, // Use the ids from the other inserted records to create a join table entry 201 | user_id: newUserId[0].insertedId, 202 | access: ACCOUNT_ACCESS.OWNER 203 | }) 204 | .returning({ insertedId: membership.id }); 205 | ``` 206 | 207 | ### Enums are a mess in both Orms but Prisma at least has a workaround 208 | 209 | Postgres supports enum types... 210 | 211 | ``` 212 | CREATE TYPE "ACCOUNT_ACCESS" AS ENUM('READ_ONLY', 'READ_WRITE', 'ADMIN', 'OWNER'); 213 | ... 214 | CREATE TABLE IF NOT EXISTS "membership" ( 215 | ... 216 | "access" "ACCOUNT_ACCESS" DEFAULT 'READ_ONLY' NOT NULL, 217 | ``` 218 | 219 | In Prisma, I can do this in the schema.. 220 | 221 | ``` 222 | enum ACCOUNT_ACCESS { 223 | READ_ONLY 224 | READ_WRITE 225 | ADMIN 226 | OWNER 227 | } 228 | ``` 229 | 230 | and then this horrible but functional kludge to create a type that is based on the schema definition and works everywhere you need to use the enum ... 231 | 232 | ``` 233 | // Workaround for prisma issue (https://github.com/prisma/prisma/issues/12504#issuecomment-1147356141) 234 | 235 | // Import original enum as type 236 | import type { ACCOUNT_ACCESS as ACCOUNT_ACCESS_ORIGINAL } from '@prisma/client'; 237 | 238 | // Guarantee that the implementation corresponds to the original type 239 | export const ACCOUNT_ACCESS: { [k in ACCOUNT_ACCESS_ORIGINAL]: k } = { 240 | READ_ONLY: 'READ_ONLY', 241 | READ_WRITE: 'READ_WRITE', 242 | ADMIN: 'ADMIN', 243 | OWNER: 'OWNER' 244 | } as const; 245 | 246 | // Re-exporting the original type with the original name 247 | export type ACCOUNT_ACCESS = ACCOUNT_ACCESS_ORIGINAL; 248 | ``` 249 | 250 | in Drizzle, you need to use helper method pgEnum to create a thing which is NOT a type... 251 | 252 | ``` 253 | export const accountAccessEnum = pgEnum('ACCOUNT_ACCESS', [ 254 | 'OWNER', 255 | 'ADMIN', 256 | 'READ_WRITE', 257 | 'READ_ONLY' 258 | ]); 259 | ``` 260 | 261 | and then stick it in the schema like this... 262 | 263 | ``` 264 | export const membership = pgTable( 265 | 'membership', 266 | { 267 | ... 268 | access: accountAccessEnum('access') 269 | .default(ACCOUNT_ACCESS.READ_ONLY) 270 | .notNull(), 271 | ``` 272 | 273 | but AFAIK, there is no way to infer a type for this enum. 274 | I tried a bunch of different things but ended creating a completely duplicate type... 275 | 276 | ``` 277 | export enum ACCOUNT_ACCESS { 278 | OWNER = 'OWNER', 279 | ADMIN = 'ADMIN', 280 | READ_WRITE = 'READ_WRITE', 281 | READ_ONLY = 'READ_ONLY' 282 | } 283 | ``` 284 | 285 | which works in some cases... 286 | 287 | ``` 288 | await drizzle_client 289 | .insert(membership) 290 | .values({ 291 | account_id: newAccountId[0].insertedId, 292 | user_id: newUserId[0].insertedId, 293 | access: ACCOUNT_ACCESS.OWNER 294 | }) 295 | .returning({ insertedId: membership.id }); 296 | ``` 297 | 298 | but not others [example](https://github.com/JavascriptMick/supanuxt-saas-drizzle/blob/7919f6a32c83ae114fb5041fdad3d109149d3b16/server/trpc/trpc.ts#L80). 299 | 300 | ``` 301 | const accessList: ACCOUNT_ACCESS[] = [ACCOUNT_ACCESS.OWNER, ACCOUNT_ACCESS.ADMIN] 302 | 303 | //doesn't work, activeMembership.access is not an enum 304 | if (!access.includes(activeMembership.access)) .... 305 | 306 | //need to do this map thing 307 | if (!access.map(a => a.valueOf()).includes(activeMembership.access)).... 308 | ``` 309 | 310 | so, in both cases, it's more difficult than it needs to be but at least Prisma has a workaround that works. 311 | 312 | ### MINOR: In schema definition not null is not the default 313 | 314 | Note that in Prisma, types are not nullable by default and you add a ? to make them nullable, in Drizzle is opposite... 315 | 316 | In Prisma 317 | 318 | ```ts 319 | 320 | model Account { 321 | ... 322 | features String[] //not nullable by default 323 | 324 | ``` 325 | 326 | In Drizzle 327 | ```ts 328 | export const account = pgTable( 329 | 'account', 330 | { 331 | ... 332 | features: text('features').array().notNull(), // nullable by default 333 | 334 | ``` 335 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Michael Dausmann 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![SupaNuxt SaaS](assets/images/supanuxt_logo_200.png) 2 | 3 | # SupaNuxt SaaS Drizzle Edition 4 | 5 | > [!WARNING] 6 | > This project is currently in alpha testing and not ready for production use. 7 | 8 | This repo is a port of an [original Prisma version](https://github.com/JavascriptMick/supanuxt-saas) to Drizzle. I has several [Issues](DRIZZLEISSUES.md) with the port. 9 | 10 | Both the Prisma and Drizzle editions will be maintained going forward. 11 | 12 | ## Demo Sites 13 | 14 | (Prisma) Demo site [here](https://nuxt3-saas-boilerplate.netlify.app/) 15 | 16 | (Prisma) Pottery Helper [here](https://potteryhelper.com/) 17 | 18 | ## Community 19 | 20 | Discord [here](https://discord.gg/3hWPDTA4kD) 21 | 22 | ## Tech Stack 23 | 24 | - Nuxt 3 25 | - Supabase (auth including OAuth + Postgresql instance) 26 | - Drizzle (schema management + Strongly typed client) 27 | - TRPC (server/client communication with Strong types, SSR compatible) 28 | - Pinia (state Store) 29 | - Stripe (payments including webhook integration) 30 | - Tailwind + daisyUI (styling and components) 31 | - OpenAI (text completions with AI) 32 | 33 | ## Features 34 | 35 | ### User Management 36 | 37 | - [x] Social Signon (e.g. google) via Supabase, Full list of available [providers](https://supabase.com/docs/guides/auth#providers) 38 | - [x] Email/Password Signon via Supabase 39 | - [x] Password recovery 40 | - [x] User roles and permissions (admin, regular user, etc. roles defined in the [Drizzle Schema](/drizzle/schema.ts)) 41 | - [x] User Email captured on initial login 42 | - [x] Initial plan and plan period controled via config to allow either a trial plan or a 'No Plan' for initial users 43 | - [x] Edit Account Name from Account Page 44 | 45 | ### Schema and DB Management 46 | 47 | - [x] Drizzle based Schema Management 48 | - [x] Supabase integration for DB 49 | - [x] DB Seed Script to setup plan information including Plan and Stripe Product information 50 | 51 | ### Config Management and Env integration 52 | 53 | - [x] [Config](/nuxt.config.ts) for Stripe Keys 54 | - [x] [Env](/.env_example) keys for Supabase and Stripe 55 | - [x] Config Switches for free trial - If you want a 'free trial period' set initialPlanName to an appropriate plan name in the DB and initialPlanActiveMonths to a positive value. If you don't want a free trial, set initialPlanName to an appropriate 'No Plan' plan in the DB and set the initialPlanActiveMonths to -1. 56 | 57 | ### Multi-Modal State Management 58 | 59 | - [x] SPA type pages (e.g. [Dashboard](/pages/dashboard.vue)) - postgresql(supabase) -> Drizzle -> Service Layer for Business Logic -> TRPC -> Pinia -> UI 60 | - [x] SSR type pages (e.g. [Note](/pages/notes/[note_id].vue)) - postgresql(supabase) -> Drizzle -> Service Layer for Business Logic -> TRPC -> UI 61 | 62 | ### Multi User Accounts (Teams) 63 | 64 | - [x] Allow users to upgrade their accounts fron individual accounts to multi-user accounts (Teams). 65 | - [x] Allow users to switch between Teams and view/edit data from the selected Team. 66 | - [x] All features, billing and limits is controlled at the Account (Team) level (not the user level) 67 | - [x] Gen/Regen an invite link to allow users to join a team 68 | - [x] Team administrators and owners can accept pending invites 69 | - [x] Team administrators and owners can administer the permissions (roles) of other team members on the Accounts page 70 | - [x] Team owners can remove users from team 71 | 72 | ### Plans and Pricing 73 | 74 | - [x] Manage multiple Plans each with specific Feature flags and Plan limits 75 | - [x] Plan features copied to Accounts upon successfull subscription 76 | - [x] Loose coupling between Plan and Account Features to allow ad-hoc account tweaks without creating custom plans 77 | - [x] Pricing page appropriately reacts to users with/without account and current plan. 78 | - [x] User Access level available at the router layer as procedures allowing restriction of access based on user access 79 | - [x] Account features available at the router layer as utility procedures allowing restriction of access based on account features 80 | 81 | ### Stripe (Payments) Integration 82 | 83 | - [x] Each plan is configured with Stripe Product ID so that multiple Stripe Prices can be created for each plan but subscriptions (via Webhook) will still activate the correct plan. 84 | - [x] Support basic (customer.subscription) flows for Subscription payments via Webhook 85 | - [ ] Support additional Stripe flows for things like failed payments, imminent subscription expiry (send email?) etc..... 86 | 87 | ### Support 88 | 89 | - [ ] Help desk support (ticketing system, live chat, etc.) 90 | - [ ] Knowledge base with FAQs and tutorials 91 | 92 | ### Look and Feel, Design System and Customisation 93 | 94 | - [x] Default UI isn't too crap 95 | - [x] Integrated Design system including theming (Tailwind + daisyUI) 96 | - [x] Toasts for things like reset email sent 97 | - [x] Modals, just because people like modals 98 | 99 | ### GDPR 100 | 101 | - [x] Cookie Consent 102 | 103 | ### Demo Software (Notes) 104 | 105 | - [x] Simple Text based Notes functionality 106 | - [x] Read only Notes Dashboard 107 | - [x] SSR Rendered (SEO Optimised) [Note](/pages/notes/[note_id].vue) Display 108 | - [x] Max Notes limit property on Plan 109 | - [x] Max Notes enforced 110 | - [x] Add, Delete notes on Dashboard 111 | - [x] AI Note generation with OpenAI 112 | - [x] Per Account, Per Month Useage Limits on AI Access 113 | 114 | ### Testing 115 | 116 | - [x] Manual test scenario for auth and sub workflows passing 117 | - [x] Unit test framework (vitest) 118 | - [ ] Integration tests for auth and sub workflows 119 | 120 | ## Special Mention 121 | 122 | This https://blog.checklyhq.com/building-a-multi-tenant-saas-data-model/ Article by https://twitter.com/tim_nolet was my inspiration for the user/account/subscription schema. Tim was also generous with his time and answered some of my stoopid questions on the https://www.reddit.com/r/SaaS/ Subreddit. 123 | 124 | ## Architecture 125 | 126 | The focus is on separation of concerns and avoiding vendor lock in. 127 | 128 | ### Diagram 129 | 130 | 131 | 132 | ### Walkthrough 133 | 134 | [](https://www.youtube.com/watch?v=AFfbGuJYRqI) 135 | 136 | ### Tricky Decisions 137 | 138 | _Composition over options API_ - I have decided to use composition api and setup functions accross the board including components, pages and Pinia stores. I was resistant at first, especially with the stores as I was used to Vuex but have come to the conclusion that it is easier to go one approach all over. It's also the latest and greatest and folks don't like to use a starter that starts behind the cutting edge. 139 | 140 | _Drizzle over Supabase API_ - I went with Drizzle for direct DB access rather than use the Supabase client. This is Primarily to avoid lock-in with Supabase too much. Supabase is great but I thought burdening my users with a future situation where it's difficult to move off it wouldn't be very cool. 141 | 142 | _Trpc over REST_ - Primarily for full thickness types without duplication on the client. Also I think the remote procedure call paradigm works well. Note however that I still include a [REST endpoint example](/server/api/note.ts) for flexibility. My preference for mobile is Flutter and there is not a Trpc client for Flutter that i'm aware off so it was important for me to make sure REST works also. 143 | 144 | ## Externals Setup 145 | 146 | Things you gotta do that aren't code (and are therefore not very interesting) 147 | 148 | ### Env 149 | 150 | Copy the [.env_example](/.env_example) file to create [.env](/.env) 151 | Note) This file is for development convenience, is .gitignored by default and should _not_ be added to source control 152 | 153 | ### Supabase 154 | 155 | This solution uses Supabase for Auth and to provide a DB. In addition to Magic Link and email/password login via Supabase, it also supports Google OAuth via Supabase. 156 | 157 | 1. Go to [Supabase](https://supabase.com/) and 'Start your Project' 158 | 2. Setup your org and project (Free tier is fine to start) 159 | 3. Update the project's email template (Supabase -> Authentication -> Email Templates) Note that the default Supabase email templates are very generic and for some reason, this can lead to your emails being sent to spam folders. for e.g. to get my password reset emails to my inbox, I needed to change the subject to "Password Reset for ..." and the email body text. 160 | 4. Choose an OAuth provider. I have chosen Google using these [Instructions](https://supabase.com/docs/guides/auth/social-login/auth-google) for the purposes of demonstration but they all should work. 161 | 5. Go to Project Settings -> API and copy Project URL and Project API Key to SUPABASE_URL and SUPABASE_KEY settings respectively in your [.env](/.env) file 162 | 6. Go to Project Settings -> Database -> Connection String -> URI and copy the uri value into the DATABASE_URL setting in your [.env](/.env) file, remembering to replace `[YOUR-PASSWORD]` with the password you provided when you setup the project. 163 | 164 | ### Stripe 165 | 166 | This solution uses Stripe for Subscription payments. 167 | 168 | 1. Go to [Stripe](https://stripe.com) and setup your business (Free Tier is fine to start) 169 | 2. Create 2 products ('Team Plan' and 'Individual Plan') each with a single price and note the Product ID's and Price ID's 170 | 3. Edit the [seed.ts](/drizzle/seed.ts) file and replace the stripe_product_id values with the Product ID's from 2) 171 | 172 | ```typescript 173 | create: { 174 | name: 'Team Plan', 175 | ..... 176 | stripe_product_id: '[Your Product ID from Stripe]' 177 | }, 178 | ``` 179 | 180 | 4. Edit the Pricing [pricing](/pages/pricing.vue) page and put your Price ID's from 2) into the appropriate hidden `price_id` form fields... 181 | 182 | ```html 183 | 184 | ``` 185 | 186 | 5. go to the [API Keys](https://dashboard.stripe.com/test/apikeys) page find 'Secret Key' -> reveal test key. click to copy and then replace the STRIPE_SECRET_KEY value in your .env 187 | 188 | 6. install the stripe cli used to forward webhooks (macos) 189 | 190 | ``` 191 | brew install stripe/stripe-cli/stripe 192 | ``` 193 | 194 | 7. log the CLI into your stripe account. 195 | 196 | ``` 197 | stripe login -i 198 | ``` 199 | 200 | provide the api key found in step 5) above 201 | 202 | ### Setup Database (Drizzle) 203 | 204 | This solution uses Drizzle to both manage changes and connect to the Postgresql database provided by Supabase. Your Supabase DB will be empty by default so you need to hydrate the schema. 205 | 206 | ``` 207 | npm run db:generate 208 | npm run db:migrate 209 | npm run db:seed 210 | ``` 211 | 212 | ...you should now have a a Plan table with 3 rows and a bunch of empty tables in your Supabase DB 213 | 214 | _Note_ Beyond these commands, schema management is up to you. You might want to remove the migrations folder from .gitignore and start checking in your migration metadata, not sure. 215 | 216 | ## Developement Setup 217 | 218 | ### Dependencies 219 | 220 | ```bash 221 | # yarn 222 | yarn install 223 | 224 | # npm 225 | npm install 226 | 227 | # pnpm 228 | pnpm install --shamefully-hoist 229 | ``` 230 | 231 | ### Webhook Forwarding 232 | 233 | This makes sure that you can debug subscription workflows locally 234 | 235 | ```bash 236 | stripe listen --forward-to localhost:3000/webhook 237 | ``` 238 | 239 | If you haven't already done so look at the stripe cli output for this text 240 | 241 | ``` 242 | Your webhook signing secret is whsec_xxxxxxxxxxxxx (^C to quit) 243 | ``` 244 | 245 | take ths signing secret and update the STRIPE_ENDPOINT_SECRET value in .env 246 | 247 | ### Start the Server 248 | 249 | Start the development server on http://localhost:3000 250 | 251 | ```bash 252 | npm run dev 253 | ``` 254 | 255 | ### Running the Tests 256 | 257 | There are a few unit tests, just for the stores because I needed to refactor. Feel free to extend the tests for your use cases, or not, it's your SaaS, not mine. 258 | 259 | ```bash 260 | npm run test 261 | ``` 262 | 263 | ## Production 264 | 265 | Build the application for production: 266 | 267 | ```bash 268 | npm run build 269 | ``` 270 | 271 | Locally preview production build: 272 | 273 | ```bash 274 | npm run preview 275 | ``` 276 | 277 | Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information. 278 | 279 | ### Going Live on Netlify 280 | 281 | Where you host your SAAS is 100% your problem however :- 282 | 283 | - A quick look at the vue.js discord indicates that netlify has the most mentions (2020) out of all the hosting providers beating out Firebase (1592), Vercel (973) and AWS (740) 284 | - I was able to get my app up and running with ridiculously little effort 285 | 286 | Steps (Assumes your repo is in github) 287 | 288 | 1. Go to [Netlify](https://www.netlify.com/) 289 | 2. Log in with your github account (it's easier) and create an account (Free Tier is fine for now) 290 | 3. Add a New Site -> Import from Existing Proect 291 | 4. Choose your repo (You might need to Configure the Netlify app on GitHub) - Netlify auto-detects a nuxt app pretty good and the defaults it chooses seem to be fine. 292 | 5. Setup environment variables per the .env_example file (SUPABASE_URL, SUPABASE_KEY....etc) 293 | 6. Optionally change site name (e.g. mycoolsaas) or apply a domain name 294 | 295 | 7. Go to [Supabase](https://app.supabase.com/) 296 | 8. Choose your project 297 | 9. Go to URL Authentication -> URL Configuration -> Site URL 298 | 10. enter your new netlify URL e.g. https://mycoolsaas.netlify.app/ and click 'save' 299 | 11. Add the following additional redirect URLs for local development and deployment previews: 300 | 301 | - http://localhost:3000/\*\* 302 | - https://**--mycoolsaas.netlify.app/** (or https://mycustomdomain.com/**) 303 | 304 | 12. If you haven't already done so, edit your Supabase Email templates as the generic ones tend to get blocked by GMail. 305 | 306 | ### Netlify deployments and environment variables 307 | 308 | Netlify is a bit rubbish at updating environment variables so you may need to manually re-deploy your site in certain situations e.g. 309 | 310 | - If on initial load of the site you get a message along the lines of 'SUPABASE_URL is required'.. but you have set that environment variable correctly... try a manual deployment. 311 | - Changing the default domain e.g. setting to a custom domain - If you notice you are redirected to the wrong version of the site after signup to a stripe subscription, this means the URL env variable has not been reset by Netlify. a manual deployment may fix it. 312 | 313 | To manually redeploy to to your Netlify dashboard and navigate to Deploys -> Trigger Deploy -> Deploy site 314 | -------------------------------------------------------------------------------- /app.vue: -------------------------------------------------------------------------------- 1 | 12 | 28 | -------------------------------------------------------------------------------- /assets/css/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; -------------------------------------------------------------------------------- /assets/images/avatar.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 11 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /assets/images/landing_config_environment.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas-drizzle/3c79b57fd8f28ed86b295dc9174d630645ae9c74/assets/images/landing_config_environment.jpeg -------------------------------------------------------------------------------- /assets/images/landing_db_schema_management.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas-drizzle/3c79b57fd8f28ed86b295dc9174d630645ae9c74/assets/images/landing_db_schema_management.jpeg -------------------------------------------------------------------------------- /assets/images/landing_state_management.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas-drizzle/3c79b57fd8f28ed86b295dc9174d630645ae9c74/assets/images/landing_state_management.jpeg -------------------------------------------------------------------------------- /assets/images/landing_stripe_integration.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas-drizzle/3c79b57fd8f28ed86b295dc9174d630645ae9c74/assets/images/landing_stripe_integration.jpeg -------------------------------------------------------------------------------- /assets/images/landing_style_system.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas-drizzle/3c79b57fd8f28ed86b295dc9174d630645ae9c74/assets/images/landing_style_system.jpeg -------------------------------------------------------------------------------- /assets/images/landing_user_management.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas-drizzle/3c79b57fd8f28ed86b295dc9174d630645ae9c74/assets/images/landing_user_management.jpeg -------------------------------------------------------------------------------- /assets/images/saas_landing_main.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas-drizzle/3c79b57fd8f28ed86b295dc9174d630645ae9c74/assets/images/saas_landing_main.jpeg -------------------------------------------------------------------------------- /assets/images/supanuxt_logo_100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas-drizzle/3c79b57fd8f28ed86b295dc9174d630645ae9c74/assets/images/supanuxt_logo_100.png -------------------------------------------------------------------------------- /assets/images/supanuxt_logo_200.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas-drizzle/3c79b57fd8f28ed86b295dc9174d630645ae9c74/assets/images/supanuxt_logo_200.png -------------------------------------------------------------------------------- /assets/images/supanuxt_logo_400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas-drizzle/3c79b57fd8f28ed86b295dc9174d630645ae9c74/assets/images/supanuxt_logo_400.png -------------------------------------------------------------------------------- /assets/images/supanuxt_logo_800.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas-drizzle/3c79b57fd8f28ed86b295dc9174d630645ae9c74/assets/images/supanuxt_logo_800.png -------------------------------------------------------------------------------- /assets/images/technical_architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas-drizzle/3c79b57fd8f28ed86b295dc9174d630645ae9c74/assets/images/technical_architecture.png -------------------------------------------------------------------------------- /components/AppFooter.vue: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /components/AppHeader.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 54 | -------------------------------------------------------------------------------- /components/Modal.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 72 | -------------------------------------------------------------------------------- /components/Notifications.client.vue: -------------------------------------------------------------------------------- 1 | 21 | 50 | -------------------------------------------------------------------------------- /components/UserAccount/UserAccount.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 37 | -------------------------------------------------------------------------------- /components/UserAccount/UserAccountSignout.client.vue: -------------------------------------------------------------------------------- 1 | 17 | 20 | -------------------------------------------------------------------------------- /components/UserAccount/UserAccountSwitch.client.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 28 | -------------------------------------------------------------------------------- /components/modal.type.ts: -------------------------------------------------------------------------------- 1 | import { Modal } from '#components'; 2 | 3 | // seems pretty stoopid that I need to do this in a seperate file but it seems to work 4 | export type ModalType = typeof Modal extends new () => infer T ? T : never; 5 | -------------------------------------------------------------------------------- /drizzle/drizzle.client.ts: -------------------------------------------------------------------------------- 1 | import * as schema from './schema'; 2 | import { drizzle } from 'drizzle-orm/postgres-js'; 3 | import postgres from 'postgres'; 4 | const connectionString = process.env.DATABASE_URL as string; 5 | const client = postgres(connectionString); 6 | export const drizzle_client = drizzle(client, { schema }); 7 | -------------------------------------------------------------------------------- /drizzle/migrate.ts: -------------------------------------------------------------------------------- 1 | import { drizzle } from 'drizzle-orm/postgres-js'; 2 | import { migrate } from 'drizzle-orm/postgres-js/migrator'; 3 | import postgres from 'postgres'; 4 | import dotenv from 'dotenv'; 5 | 6 | dotenv.config(); 7 | const client = postgres(process.env.DATABASE_URL as string); 8 | const drizzle_client = drizzle(client); 9 | 10 | const main = async () => { 11 | try { 12 | await migrate(drizzle_client, { 13 | migrationsFolder: 'drizzle/migrations' 14 | }); 15 | 16 | console.log('Migration successful'); 17 | process.exit(0); 18 | } catch (error) { 19 | console.error(error); 20 | process.exit(1); 21 | } 22 | }; 23 | 24 | main(); 25 | -------------------------------------------------------------------------------- /drizzle/relation.types.ts: -------------------------------------------------------------------------------- 1 | // Issue: https://github.com/drizzle-team/drizzle-orm/issues/695 2 | // Workaround solution provided by: https://github.com/BearToCode 3 | import type { 4 | BuildQueryResult, 5 | DBQueryConfig, 6 | ExtractTablesWithRelations 7 | } from 'drizzle-orm'; 8 | import * as schema from './schema'; 9 | 10 | type Schema = typeof schema; 11 | export type TSchema = ExtractTablesWithRelations; 12 | 13 | export type IncludeRelation = DBQueryConfig< 14 | 'one' | 'many', 15 | boolean, 16 | TSchema, 17 | TSchema[TableName] 18 | >['with']; 19 | 20 | export type InferResultType< 21 | TableName extends keyof TSchema, 22 | With extends IncludeRelation | undefined = undefined 23 | > = BuildQueryResult< 24 | TSchema, 25 | TSchema[TableName], 26 | { 27 | with: With; 28 | } 29 | >; 30 | -------------------------------------------------------------------------------- /drizzle/schema.ts: -------------------------------------------------------------------------------- 1 | import { 2 | pgTable, 3 | pgEnum, 4 | serial, 5 | text, 6 | uniqueIndex, 7 | timestamp, 8 | integer, 9 | boolean 10 | } from 'drizzle-orm/pg-core'; 11 | import { relations } from 'drizzle-orm'; 12 | 13 | export const user = pgTable('users', { 14 | id: serial('id').primaryKey().notNull(), 15 | supabase_uid: text('supabase_uid').notNull(), 16 | email: text('email').notNull(), 17 | display_name: text('display_name') 18 | }); 19 | export const userRelations = relations(user, ({ many }) => ({ 20 | memberships: many(membership) 21 | })); 22 | export type User = typeof user.$inferSelect; 23 | 24 | export enum ACCOUNT_ACCESS { 25 | OWNER = 'OWNER', 26 | ADMIN = 'ADMIN', 27 | READ_WRITE = 'READ_WRITE', 28 | READ_ONLY = 'READ_ONLY' 29 | } 30 | 31 | // I tried a few hacks to derive the pgEnum from the ts enum but in the end I gave up 32 | export const accountAccessEnum = pgEnum('ACCOUNT_ACCESS', [ 33 | 'OWNER', 34 | 'ADMIN', 35 | 'READ_WRITE', 36 | 'READ_ONLY' 37 | ]); 38 | 39 | // Membership 40 | export const membership = pgTable( 41 | 'membership', 42 | { 43 | id: serial('id').primaryKey().notNull(), 44 | user_id: integer('user_id') 45 | .notNull() 46 | .references(() => user.id, { onDelete: 'restrict', onUpdate: 'cascade' }), 47 | account_id: integer('account_id') 48 | .notNull() 49 | .references(() => account.id, { 50 | onDelete: 'restrict', 51 | onUpdate: 'cascade' 52 | }), 53 | access: accountAccessEnum('access') 54 | .default(ACCOUNT_ACCESS.READ_ONLY) 55 | .notNull(), 56 | pending: boolean('pending').default(false).notNull() 57 | }, 58 | table => { 59 | return { 60 | user_idAccountIdKey: uniqueIndex('membership_user_id_account_id_key').on( 61 | table.user_id, 62 | table.account_id 63 | ) 64 | }; 65 | } 66 | ); 67 | export const membershipRelations = relations(membership, ({ one }) => ({ 68 | user: one(user, { 69 | fields: [membership.user_id], 70 | references: [user.id] 71 | }), 72 | account: one(account, { 73 | fields: [membership.account_id], 74 | references: [account.id] 75 | }) 76 | })); 77 | export type Membership = typeof membership.$inferSelect; 78 | 79 | // Account 80 | export const account = pgTable( 81 | 'account', 82 | { 83 | id: serial('id').primaryKey().notNull(), 84 | name: text('name').notNull(), 85 | current_period_ends: timestamp('current_period_ends', { 86 | precision: 3, 87 | mode: 'date' 88 | }) 89 | .defaultNow() 90 | .notNull(), 91 | features: text('features').array().notNull(), 92 | plan_id: integer('plan_id') 93 | .notNull() 94 | .references(() => plan.id, { onDelete: 'restrict', onUpdate: 'cascade' }), 95 | plan_name: text('plan_name').notNull(), 96 | max_notes: integer('max_notes').default(100).notNull(), 97 | stripe_subscription_id: text('stripe_subscription_id'), 98 | stripe_customer_id: text('stripe_customer_id'), 99 | max_members: integer('max_members').default(1).notNull(), 100 | join_password: text('join_password').notNull(), 101 | ai_gen_max_pm: integer('ai_gen_max_pm').default(7).notNull(), 102 | ai_gen_count: integer('ai_gen_count').default(0).notNull() 103 | }, 104 | table => { 105 | return { 106 | join_passwordKey: uniqueIndex('account_join_password_key').on( 107 | table.join_password 108 | ) 109 | }; 110 | } 111 | ); 112 | export const accountRelations = relations(account, ({ many }) => ({ 113 | notes: many(note), 114 | members: many(membership) 115 | })); 116 | export type Account = typeof account.$inferSelect; 117 | 118 | // Plan 119 | export const plan = pgTable( 120 | 'plan', 121 | { 122 | id: serial('id').primaryKey().notNull(), 123 | name: text('name').notNull(), 124 | features: text('features').array().notNull(), 125 | max_notes: integer('max_notes').default(100).notNull(), 126 | stripe_product_id: text('stripe_product_id'), 127 | max_members: integer('max_members').default(1).notNull(), 128 | ai_gen_max_pm: integer('ai_gen_max_pm').default(7).notNull() 129 | }, 130 | table => { 131 | return { 132 | nameKey: uniqueIndex('plan_name_key').on(table.name) 133 | }; 134 | } 135 | ); 136 | export type Plan = typeof plan.$inferSelect; 137 | 138 | // Note 139 | export const note = pgTable('note', { 140 | id: serial('id').primaryKey().notNull(), 141 | account_id: integer('account_id').references(() => account.id, { 142 | onDelete: 'set null', 143 | onUpdate: 'cascade' 144 | }), 145 | note_text: text('note_text').notNull() 146 | }); 147 | export const noteRelations = relations(note, ({ one }) => ({ 148 | account: one(account, { 149 | fields: [note.account_id], 150 | references: [account.id] 151 | }) 152 | })); 153 | export type Note = typeof note.$inferSelect; 154 | -------------------------------------------------------------------------------- /drizzle/seed.ts: -------------------------------------------------------------------------------- 1 | import { plan } from './schema'; 2 | import { drizzle } from 'drizzle-orm/postgres-js'; 3 | import postgres from 'postgres'; 4 | import dotenv from 'dotenv'; 5 | 6 | dotenv.config(); 7 | const client = postgres(process.env.DATABASE_URL as string); 8 | const drizzle_client = drizzle(client); 9 | 10 | const main = async () => { 11 | try { 12 | console.log('Seeding database'); 13 | 14 | const freeTrialValues = { 15 | features: ['ADD_NOTES', 'EDIT_NOTES', 'VIEW_NOTES'], 16 | max_notes: 10, 17 | max_members: 1, 18 | ai_gen_max_pm: 7 19 | }; 20 | const freeTrial = await drizzle_client 21 | .insert(plan) 22 | .values({ 23 | name: 'Free Trial', 24 | ...freeTrialValues 25 | }) 26 | .onConflictDoUpdate({ 27 | target: plan.name, 28 | set: freeTrialValues 29 | }) 30 | .returning({ id: plan.id }); 31 | 32 | const individualPlanValues = { 33 | features: ['ADD_NOTES', 'EDIT_NOTES', 'VIEW_NOTES', 'SPECIAL_FEATURE'], 34 | max_notes: 100, 35 | max_members: 1, 36 | ai_gen_max_pm: 50, 37 | stripe_product_id: 'prod_NQR7vwUulvIeqW' 38 | }; 39 | const individualPlan = await drizzle_client 40 | .insert(plan) 41 | .values({ 42 | name: 'Individual Plan', 43 | ...individualPlanValues 44 | }) 45 | .onConflictDoUpdate({ 46 | target: plan.name, 47 | set: individualPlanValues 48 | }) 49 | .returning({ id: plan.id }); 50 | 51 | const teamPlanValues = { 52 | features: [ 53 | 'ADD_NOTES', 54 | 'EDIT_NOTES', 55 | 'VIEW_NOTES', 56 | 'SPECIAL_FEATURE', 57 | 'SPECIAL_TEAM_FEATURE' 58 | ], 59 | max_notes: 200, 60 | max_members: 10, 61 | ai_gen_max_pm: 500, 62 | stripe_product_id: 'prod_NQR8IkkdhqBwu2' 63 | }; 64 | const teamPlan = await drizzle_client 65 | .insert(plan) 66 | .values({ 67 | name: 'Team Plan', 68 | ...teamPlanValues 69 | }) 70 | .onConflictDoUpdate({ 71 | target: plan.name, 72 | set: teamPlanValues 73 | }) 74 | .returning({ id: plan.id }); 75 | 76 | console.log({ freeTrial, individualPlan, teamPlan }); 77 | 78 | process.exit(0); 79 | } catch (error) { 80 | console.error(error); 81 | throw new Error('Failed to seed database'); 82 | } 83 | }; 84 | 85 | main(); 86 | -------------------------------------------------------------------------------- /lib/services/account.service.ts: -------------------------------------------------------------------------------- 1 | import { eq } from 'drizzle-orm'; 2 | 3 | import { drizzle_client } from '~~/drizzle/drizzle.client'; 4 | import { 5 | account, 6 | membership, 7 | plan, 8 | ACCOUNT_ACCESS, 9 | type Account, 10 | type Membership 11 | } from '~~/drizzle/schema'; 12 | 13 | import { 14 | type AccountWithMembers, 15 | type MembershipWithAccount, 16 | type MembershipWithUser 17 | } from './service.types'; 18 | import generator from 'generate-password-ts'; 19 | import { UtilService } from './util.service'; 20 | import { AccountLimitError } from './errors'; 21 | 22 | const config = useRuntimeConfig(); 23 | 24 | export namespace AccountService { 25 | export async function getAccountById( 26 | account_id: number 27 | ): Promise { 28 | const this_account = await drizzle_client.query.account.findFirst({ 29 | where: eq(account.id, account_id), 30 | with: { members: { with: { user: true } } } 31 | }); 32 | 33 | if (!this_account) { 34 | throw new Error('Account not found.'); 35 | } 36 | 37 | return this_account as AccountWithMembers; 38 | } 39 | 40 | export async function getAccountByJoinPassword( 41 | join_password: string 42 | ): Promise { 43 | const this_account = await drizzle_client.query.account.findFirst({ 44 | where: eq(account.join_password, join_password), 45 | with: { members: { with: { user: true } } } 46 | }); 47 | 48 | if (!this_account) { 49 | throw new Error('Account not found.'); 50 | } 51 | 52 | return this_account as AccountWithMembers; 53 | } 54 | 55 | export async function getAccountMembers( 56 | account_id: number 57 | ): Promise { 58 | return drizzle_client.query.membership.findMany({ 59 | where: eq(account.id, account_id), 60 | with: { user: true } 61 | }); 62 | } 63 | 64 | export async function updateAccountStipeCustomerId( 65 | account_id: number, 66 | stripe_customer_id: string 67 | ) { 68 | const updatedAccounts = await drizzle_client 69 | .update(account) 70 | .set({ stripe_customer_id }) 71 | .where(eq(account.id, account_id)) 72 | .returning(); 73 | return updatedAccounts[0] as Account; 74 | } 75 | 76 | export async function updateStripeSubscriptionDetailsForAccount( 77 | stripe_customer_id: string, 78 | stripe_subscription_id: string, 79 | current_period_ends: Date, 80 | stripe_product_id: string 81 | ) { 82 | const this_account = await drizzle_client.query.account.findFirst({ 83 | where: eq(account.stripe_customer_id, stripe_customer_id) 84 | }); 85 | 86 | if (!this_account) { 87 | throw new Error( 88 | `Account not found for customer id ${stripe_customer_id}` 89 | ); 90 | } 91 | 92 | const paid_plan = await drizzle_client.query.plan.findFirst({ 93 | where: eq(plan.stripe_product_id, stripe_product_id) 94 | }); 95 | 96 | if (!paid_plan) { 97 | throw new Error(`Plan not found for product id ${stripe_product_id}`); 98 | } 99 | 100 | let updatedAccounts: Account[]; 101 | if (paid_plan.id == this_account.plan_id) { 102 | // only update sub and period info 103 | updatedAccounts = await drizzle_client 104 | .update(account) 105 | .set({ 106 | stripe_subscription_id, 107 | current_period_ends, 108 | ai_gen_count: 0 109 | }) 110 | .where(eq(account.id, this_account.id)) 111 | .returning(); 112 | } else { 113 | // plan upgrade/downgrade... update everything, copying over plan features and perks 114 | updatedAccounts = await drizzle_client 115 | .update(account) 116 | .set({ 117 | stripe_subscription_id, 118 | current_period_ends, 119 | plan_id: paid_plan.id, 120 | features: paid_plan.features, 121 | max_notes: paid_plan.max_notes, 122 | max_members: paid_plan.max_members, 123 | plan_name: paid_plan.name, 124 | ai_gen_max_pm: paid_plan.ai_gen_max_pm, 125 | ai_gen_count: 0 // I did vacillate on this point ultimately easier to just reset, discussion here https://www.reddit.com/r/SaaS/comments/16e9bew/should_i_reset_usage_counts_on_plan_upgrade/ 126 | }) 127 | .where(eq(account.id, this_account.id)) 128 | .returning(); 129 | } 130 | 131 | return updatedAccounts[0] as Account; 132 | } 133 | 134 | export async function acceptPendingMembership( 135 | account_id: number, 136 | membership_id: number 137 | ): Promise { 138 | const this_membership = await drizzle_client.query.membership.findFirst({ 139 | where: membership => eq(membership.id, membership_id) 140 | }); 141 | 142 | if (!this_membership) { 143 | throw new Error(`Membership does not exist`); 144 | } 145 | 146 | if (this_membership.account_id != account_id) { 147 | throw new Error(`Membership does not belong to current account`); 148 | } 149 | 150 | await drizzle_client 151 | .update(membership) 152 | .set({ pending: false }) 153 | .where(eq(membership.id, membership_id)); 154 | 155 | // Retrieve the updated user with related entities 156 | const updatedMembershipWithAccount = 157 | await drizzle_client.query.membership.findFirst({ 158 | where: membership => eq(membership.id, membership_id), 159 | with: { account: true } 160 | }); 161 | 162 | return updatedMembershipWithAccount as MembershipWithAccount; 163 | } 164 | 165 | export async function deleteMembership( 166 | account_id: number, 167 | membership_id: number 168 | ): Promise { 169 | const this_membership = await drizzle_client.query.membership.findFirst({ 170 | where: membership => eq(membership.id, membership_id) 171 | }); 172 | 173 | if (!this_membership) { 174 | throw new Error(`Membership does not exist`); 175 | } 176 | 177 | if (this_membership.account_id != account_id) { 178 | throw new Error(`Membership does not belong to current account`); 179 | } 180 | 181 | const deletedMembership = await drizzle_client 182 | .delete(membership) 183 | .where(eq(membership.id, membership_id)) 184 | .returning(); 185 | 186 | return deletedMembership[0] as Membership; 187 | } 188 | 189 | export async function joinUserToAccount( 190 | user_id: number, 191 | account_id: number, 192 | pending: boolean 193 | ): Promise { 194 | const this_account = await drizzle_client.query.account.findFirst({ 195 | where: account => eq(account.id, account_id), 196 | with: { 197 | members: true 198 | } 199 | }); 200 | 201 | if ( 202 | this_account?.members && 203 | this_account?.members?.length >= this_account?.max_members 204 | ) { 205 | throw new Error( 206 | `Too Many Members, Account only permits ${this_account?.max_members} members.` 207 | ); 208 | } 209 | 210 | if (this_account?.members) { 211 | for (const member of this_account.members) { 212 | if (member.user_id === user_id) { 213 | throw new Error(`User is already a member`); 214 | } 215 | } 216 | } 217 | 218 | const newMembership = await drizzle_client 219 | .insert(membership) 220 | .values({ 221 | user_id: user_id, 222 | account_id: account_id, 223 | access: 'READ_ONLY', 224 | pending 225 | }) 226 | .returning(); 227 | 228 | // Retrieve the updated membership 229 | return (await drizzle_client.query.membership.findFirst({ 230 | where: membership => eq(membership.id, newMembership[0].id), 231 | with: { 232 | account: true 233 | } 234 | })) as MembershipWithAccount; 235 | } 236 | 237 | export async function changeAccountName( 238 | account_id: number, 239 | new_name: string 240 | ) { 241 | const updatedAccount = await drizzle_client 242 | .update(account) 243 | .set({ 244 | name: new_name 245 | }) 246 | .where(eq(account.id, account_id)) 247 | .returning(); 248 | 249 | return updatedAccount[0] as Account; 250 | } 251 | 252 | export async function changeAccountPlan(account_id: number, plan_id: number) { 253 | const this_plan = await drizzle_client.query.plan.findFirst({ 254 | where: eq(plan.id, plan_id) 255 | }); 256 | 257 | if (!this_plan) { 258 | throw new Error(`Plan not found for plan id ${plan_id}`); 259 | } 260 | 261 | const updatedPlans = await drizzle_client 262 | .update(account) 263 | .set({ 264 | plan_id: this_plan.id, 265 | features: this_plan.features, 266 | max_notes: this_plan.max_notes 267 | }) 268 | .where(eq(account.id, account_id)) 269 | .returning(); 270 | return updatedPlans[0] as Account; 271 | } 272 | 273 | export async function rotateJoinPassword(account_id: number) { 274 | const join_password: string = generator.generate({ 275 | length: 10, 276 | numbers: true 277 | }); 278 | const updatedAccount = await drizzle_client 279 | .update(account) 280 | .set({ 281 | join_password 282 | }) 283 | .where(eq(account.id, account_id)) 284 | .returning(); 285 | 286 | return updatedAccount[0] as Account; 287 | } 288 | 289 | // Claim ownership of an account. 290 | // User must already be an ADMIN for the Account 291 | // Existing OWNER memberships are downgraded to ADMIN 292 | // In future, some sort of Billing/Stripe tie in here e.g. changing email details on the Account, not sure. 293 | export async function claimOwnershipOfAccount( 294 | user_id: number, 295 | account_id: number 296 | ): Promise { 297 | const this_membership = await drizzle_client.query.membership.findFirst({ 298 | where: membership => 299 | eq(membership.user_id, user_id) && eq(membership.account_id, account_id) 300 | }); 301 | 302 | if (!this_membership) { 303 | throw new Error(`Membership does not exist`); 304 | } 305 | 306 | if (this_membership.access === ACCOUNT_ACCESS.OWNER) { 307 | throw new Error('BADREQUEST: user is already owner'); 308 | } else if (this_membership.access !== ACCOUNT_ACCESS.ADMIN) { 309 | throw new Error('UNAUTHORISED: only Admins can claim ownership'); 310 | } 311 | 312 | const existing_owner_memberships = 313 | await drizzle_client.query.membership.findMany({ 314 | where: membership => 315 | eq(membership.account_id, account_id) && 316 | eq(membership.access, ACCOUNT_ACCESS.OWNER) 317 | }); 318 | 319 | for (const existing_owner_membership of existing_owner_memberships) { 320 | await drizzle_client 321 | .update(membership) 322 | .set({ access: ACCOUNT_ACCESS.ADMIN }) // Downgrade OWNER to ADMIN 323 | .where( 324 | eq(membership.user_id, existing_owner_membership.user_id) && 325 | eq(membership.account_id, account_id) 326 | ) 327 | .returning(); 328 | } 329 | 330 | // finally update the ADMIN member to OWNER 331 | await drizzle_client 332 | .update(membership) 333 | .set({ access: ACCOUNT_ACCESS.OWNER }) 334 | .where( 335 | eq(membership.user_id, user_id) && eq(membership.account_id, account_id) 336 | ) 337 | .returning(); 338 | 339 | return (await drizzle_client.query.membership.findMany({ 340 | where: eq(membership.account_id, account_id), 341 | with: { user: true } 342 | })) as MembershipWithUser[]; 343 | } 344 | 345 | // Upgrade access of a membership. Cannot use this method to upgrade to or downgrade from OWNER access 346 | export async function changeUserAccessWithinAccount( 347 | user_id: number, 348 | account_id: number, 349 | access: ACCOUNT_ACCESS 350 | ) { 351 | if (access === ACCOUNT_ACCESS.OWNER) { 352 | throw new Error( 353 | 'UNABLE TO UPDATE MEMBERSHIP: use claimOwnershipOfAccount method to change ownership' 354 | ); 355 | } 356 | 357 | const this_membership = await drizzle_client.query.membership.findFirst({ 358 | where: membership => 359 | eq(membership.user_id, user_id) && eq(membership.account_id, account_id) 360 | }); 361 | if (!this_membership) { 362 | throw new Error( 363 | `Membership does not exist for user ${user_id} and account ${account_id}` 364 | ); 365 | } 366 | 367 | if (this_membership.access === ACCOUNT_ACCESS.OWNER) { 368 | throw new Error( 369 | 'UNABLE TO UPDATE MEMBERSHIP: use claimOwnershipOfAccount method to change ownership' 370 | ); 371 | } 372 | 373 | const updatedMembershipId: { updatedId: number }[] = await drizzle_client 374 | .update(membership) 375 | .set({ access }) 376 | .where( 377 | eq(membership.user_id, user_id) && eq(membership.account_id, account_id) 378 | ) 379 | .returning({ updatedId: membership.id }); 380 | 381 | // Retrieve the updated membership 382 | return (await drizzle_client.query.membership.findFirst({ 383 | where: membership => eq(membership.id, updatedMembershipId[0].updatedId), 384 | with: { 385 | account: true 386 | } 387 | })) as MembershipWithAccount; 388 | } 389 | 390 | /* 391 | **** Usage Limit Checking ***** 392 | This is trickier than you might think at first. Free plan users don't get a webhook from Stripe 393 | that we can use to tick over their period end date and associated usage counts. I also didn't 394 | want to require an additional background thread to do the rollover processing. 395 | 396 | getAccountWithPeriodRollover: retrieves an account record and does the rollover checking returning up to date account info 397 | checkAIGenCount: retrieves the account using getAccountWithPeriodRollover, checks the count and returns the account 398 | incrementAIGenCount: increments the counter using the account. Note that passing in the account avoids another db fetch for the account. 399 | 400 | Note.. for each usage limit, you will need another pair of check/increment methods and of course the count and max limit in the account schema 401 | 402 | How to use in a service method.... 403 | export async function someServiceMethod(account_id: number, .....etc) { 404 | const account = await AccountService.checkAIGenCount(account_id); 405 | ... User is under the limit so do work 406 | await AccountService.incrementAIGenCount(account); 407 | } 408 | */ 409 | 410 | export async function getAccountWithPeriodRollover(account_id: number) { 411 | const this_account = await drizzle_client.query.account.findFirst({ 412 | where: account => eq(account.id, account_id) 413 | }); 414 | if (!this_account) { 415 | throw new Error(`Account not found for id ${account_id}`); 416 | } 417 | 418 | if ( 419 | this_account.plan_name === config.initialPlanName && 420 | this_account.current_period_ends < new Date() 421 | ) { 422 | const updatedAccount = await drizzle_client 423 | .update(account) 424 | .set({ 425 | current_period_ends: UtilService.addMonths( 426 | this_account.current_period_ends, 427 | 1 428 | ), 429 | // reset anything that is affected by the rollover 430 | ai_gen_count: 0 431 | }) 432 | .where(eq(account.id, account_id)) 433 | .returning(); 434 | 435 | return updatedAccount[0] as Account; 436 | } 437 | return this_account as Account; 438 | } 439 | 440 | export async function checkAIGenCount(account_id: number) { 441 | const this_account = await getAccountWithPeriodRollover(account_id); 442 | 443 | if (this_account.ai_gen_count >= this_account.ai_gen_max_pm) { 444 | throw new AccountLimitError( 445 | 'Monthly AI gen limit reached, no new AI Generations can be made' 446 | ); 447 | } 448 | 449 | return this_account; 450 | } 451 | 452 | export async function incrementAIGenCount(this_account: any) { 453 | const updatedAccounts = await drizzle_client 454 | .update(account) 455 | .set({ 456 | ai_gen_count: this_account.ai_gen_count + 1 457 | }) 458 | .where(eq(account.id, this_account.id)); 459 | 460 | return updatedAccounts[0] as Account; 461 | } 462 | } 463 | -------------------------------------------------------------------------------- /lib/services/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { drizzle_client } from '~~/drizzle/drizzle.client'; 2 | import { eq } from 'drizzle-orm'; 3 | 4 | import type { FullDBUser } from './service.types'; 5 | import { 6 | membership, 7 | account, 8 | plan, 9 | user, 10 | ACCOUNT_ACCESS, 11 | type User 12 | } from '~~/drizzle/schema'; 13 | import { UtilService } from './util.service'; 14 | import generator from 'generate-password-ts'; 15 | 16 | const config = useRuntimeConfig(); 17 | 18 | export namespace AuthService { 19 | export async function getFullUserBySupabaseId( 20 | supabase_uid: string 21 | ): Promise { 22 | return (await drizzle_client.query.user.findFirst({ 23 | where: eq(user.supabase_uid, supabase_uid), 24 | with: { 25 | memberships: { 26 | with: { 27 | account: true 28 | } 29 | } 30 | } 31 | })) as FullDBUser; 32 | } 33 | 34 | export async function getUserById( 35 | user_id: number 36 | ): Promise { 37 | const this_user = await drizzle_client.query.user.findFirst({ 38 | where: eq(user.id, user_id), 39 | with: { 40 | memberships: { 41 | with: { 42 | account: true 43 | } 44 | } 45 | } 46 | }); 47 | 48 | if (!this_user) { 49 | throw new Error('User not found'); 50 | } 51 | 52 | return this_user as FullDBUser; 53 | } 54 | 55 | export async function createUser( 56 | supabase_uid: string, 57 | display_name: string, 58 | email: string 59 | ): Promise { 60 | const trialPlan = await drizzle_client.query.plan.findFirst({ 61 | where: eq(plan.name, config.initialPlanName) 62 | }); 63 | 64 | if (!trialPlan) { 65 | throw new Error('Trial plan not found'); 66 | } 67 | 68 | const join_password: string = generator.generate({ 69 | length: 10, 70 | numbers: true 71 | }); 72 | 73 | const newAccountId: { insertedId: number }[] = await drizzle_client 74 | .insert(account) 75 | .values({ 76 | name: display_name, 77 | current_period_ends: UtilService.addMonths( 78 | new Date(), 79 | config.initialPlanActiveMonths 80 | ), 81 | plan_id: trialPlan.id, 82 | features: trialPlan.features, 83 | max_notes: trialPlan.max_notes, 84 | max_members: trialPlan.max_members, 85 | plan_name: trialPlan.name, 86 | join_password: join_password 87 | }) 88 | .returning({ insertedId: account.id }); 89 | 90 | const newUserId: { insertedId: number }[] = await drizzle_client 91 | .insert(user) 92 | .values({ 93 | supabase_uid: supabase_uid, 94 | display_name: display_name, 95 | email: email 96 | }) 97 | .returning({ insertedId: user.id }); 98 | 99 | const newMembershipId: { insertedId: number }[] = await drizzle_client 100 | .insert(membership) 101 | .values({ 102 | account_id: newAccountId[0].insertedId, 103 | user_id: newUserId[0].insertedId, 104 | access: ACCOUNT_ACCESS.OWNER 105 | }) 106 | .returning({ insertedId: membership.id }); 107 | 108 | // Retrieve the new user 109 | return (await drizzle_client.query.user.findFirst({ 110 | where: user => eq(user.id, newUserId[0].insertedId), 111 | with: { 112 | memberships: { 113 | with: { 114 | account: true 115 | } 116 | } 117 | } 118 | })) as FullDBUser; 119 | } 120 | 121 | export async function deleteUser(user_id: number): Promise { 122 | const deletedUser = await drizzle_client 123 | .delete(user) 124 | .where(eq(user.id, user_id)) 125 | .returning(); 126 | return deletedUser[0] as User; 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /lib/services/errors.ts: -------------------------------------------------------------------------------- 1 | export class AccountLimitError extends Error { 2 | constructor(message: string) { 3 | super(message); 4 | Object.setPrototypeOf(this, AccountLimitError.prototype); 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /lib/services/notes.service.ts: -------------------------------------------------------------------------------- 1 | import { drizzle_client } from '~~/drizzle/drizzle.client'; 2 | import { eq } from 'drizzle-orm'; 3 | import { note, account, type Note } from '~~/drizzle/schema'; 4 | import { openai } from './openai.client'; 5 | import { AccountLimitError } from './errors'; 6 | import { AccountService } from './account.service'; 7 | 8 | export namespace NotesService { 9 | export async function getNoteById(id: number) { 10 | return drizzle_client.select().from(note).where(eq(note.id, id)); 11 | } 12 | 13 | export async function getNotesForAccountId(account_id: number) { 14 | return drizzle_client 15 | .select() 16 | .from(note) 17 | .where(eq(note.account_id, account_id)); 18 | } 19 | 20 | export async function createNote(account_id: number, note_text: string) { 21 | const this_account = await drizzle_client.query.account.findFirst({ 22 | where: eq(account.id, account_id), 23 | with: { 24 | notes: true 25 | } 26 | }); 27 | 28 | if (!this_account) { 29 | throw new Error('Account not found'); 30 | } 31 | 32 | if (this_account.notes.length >= this_account.max_notes) { 33 | throw new AccountLimitError( 34 | 'Note Limit reached, no new notes can be added' 35 | ); 36 | } 37 | 38 | const insertedNotes = await drizzle_client 39 | .insert(note) 40 | .values({ account_id, note_text }) 41 | .returning(); 42 | 43 | return insertedNotes[0] as Note; 44 | } 45 | 46 | export async function updateNote(id: number, note_text: string) { 47 | const updatedNotes = await drizzle_client 48 | .update(note) 49 | .set({ note_text }) 50 | .where(eq(note.id, id)); 51 | 52 | return updatedNotes[0] as Note; 53 | } 54 | 55 | export async function deleteNote(id: number) { 56 | const deletedNotes = await drizzle_client 57 | .delete(note) 58 | .where(eq(note.id, id)) 59 | .returning(); 60 | 61 | return deletedNotes[0] as Note; 62 | } 63 | 64 | export async function generateAINoteFromPrompt( 65 | userPrompt: string, 66 | account_id: number 67 | ) { 68 | const account = await AccountService.checkAIGenCount(account_id); 69 | 70 | const prompt = ` 71 | Write an interesting short note about ${userPrompt}. 72 | Restrict the note to a single paragraph. 73 | `; 74 | const completion = await openai.chat.completions.create({ 75 | model: 'gpt-3.5-turbo', 76 | messages: [{ role: 'user', content: prompt }], 77 | temperature: 0.6, 78 | stop: '\n\n', 79 | max_tokens: 1000, 80 | n: 1 81 | }); 82 | 83 | await AccountService.incrementAIGenCount(account); 84 | 85 | return completion.choices?.[0]?.message.content?.trim(); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /lib/services/openai.client.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from 'openai'; 2 | 3 | export const openai = new OpenAI({ 4 | apiKey: process.env.OPENAI_API_KEY 5 | }); 6 | -------------------------------------------------------------------------------- /lib/services/service.types.ts: -------------------------------------------------------------------------------- 1 | import type { InferResultType } from '~/drizzle/relation.types'; 2 | 3 | export type MembershipWithAccount = InferResultType< 4 | 'membership', 5 | { account: true } 6 | >; 7 | 8 | export type MembershipWithUser = InferResultType<'membership', { user: true }>; 9 | 10 | export type FullDBUser = InferResultType< 11 | 'user', 12 | { memberships: { with: { account: true } } } 13 | >; 14 | 15 | export type AccountWithMembers = InferResultType< 16 | 'account', 17 | { members: { with: { user: true } } } 18 | >; 19 | -------------------------------------------------------------------------------- /lib/services/util.service.ts: -------------------------------------------------------------------------------- 1 | export namespace UtilService { 2 | export function addMonths(date: Date, months: number): Date { 3 | const d = date.getDate(); 4 | date.setMonth(date.getMonth() + +months); 5 | if (date.getDate() != d) { 6 | date.setDate(0); 7 | } 8 | return date; 9 | } 10 | 11 | export function getErrorMessage(error: unknown) { 12 | if (error instanceof Error) return error.message; 13 | return String(error); 14 | } 15 | 16 | export function stringifySafely(obj: any) { 17 | let cache: any[] = []; 18 | let str = JSON.stringify(obj, function (key, value) { 19 | if (typeof value === 'object' && value !== null) { 20 | if (cache.indexOf(value) !== -1) { 21 | // Circular reference found, discard key 22 | return; 23 | } 24 | // Store value in our collection 25 | cache.push(value); 26 | } 27 | return value; 28 | }); 29 | cache = []; // reset the cache 30 | return str; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /middleware/auth.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtRouteMiddleware(() => { 2 | const user = useSupabaseUser(); 3 | 4 | if (!user.value) { 5 | return navigateTo('/'); 6 | } 7 | }); 8 | -------------------------------------------------------------------------------- /nuxt.config.ts: -------------------------------------------------------------------------------- 1 | // https://nuxt.com/docs/api/configuration/nuxt-config 2 | export default defineNuxtConfig({ 3 | debug: true, 4 | build: { 5 | transpile: ['trpc-nuxt'] 6 | }, 7 | typescript: { 8 | shim: false 9 | }, 10 | modules: [ 11 | '@nuxtjs/supabase', 12 | '@pinia/nuxt', 13 | '@nuxtjs/tailwindcss', 14 | 'nuxt-icon' 15 | ], 16 | imports: { 17 | dirs: ['./stores'] 18 | }, 19 | app: { 20 | head: { 21 | htmlAttrs: { 22 | lang: 'en' 23 | }, 24 | title: 'SupaNuxt SaaS', 25 | link: [ 26 | { rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }, 27 | { 28 | rel: 'icon', 29 | type: 'image/png', 30 | sizes: '32x32', 31 | href: '/favicon-32x32.png' 32 | }, 33 | { 34 | rel: 'icon', 35 | type: 'image/png', 36 | sizes: '16x16', 37 | href: '/favicon-16x16.png' 38 | }, 39 | { 40 | rel: 'apple-touch-icon', 41 | sizes: '180x180', 42 | href: '/apple-touch-icon.png' 43 | }, 44 | { rel: 'manifest', href: '/site.webmanifest' } 45 | ] 46 | } 47 | }, 48 | runtimeConfig: { 49 | stripeSecretKey: process.env.STRIPE_SECRET_KEY, 50 | stripeEndpointSecret: process.env.STRIPE_ENDPOINT_SECRET, 51 | subscriptionGraceDays: 3, 52 | initialPlanName: 'Free Trial', 53 | initialPlanActiveMonths: 1, 54 | openAIKey: process.env.OPENAI_API_KEY, 55 | public: { 56 | debugMode: true, 57 | siteRootUrl: process.env.URL || 'http://localhost:3000' // URL env variable is provided by netlify by default 58 | } 59 | }, 60 | supabase: { 61 | redirect: false, 62 | redirectOptions: { 63 | login: '/signin', 64 | callback: '/confirm' 65 | } 66 | } 67 | }); 68 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "supanuxt-saas-drizzle", 3 | "version": "1.4.3", 4 | "author": { 5 | "name": "Michael Dausmann", 6 | "email": "mdausmann@gmail.com", 7 | "url": "https://www.michaeldausmann.com/" 8 | }, 9 | "license": "MIT", 10 | "private": true, 11 | "scripts": { 12 | "build": "nuxt build", 13 | "dev": "nuxt dev", 14 | "generate": "nuxt generate", 15 | "preview": "nuxt preview", 16 | "postinstall": "nuxt prepare", 17 | "test": "vitest", 18 | "db:generate": "drizzle-kit generate:pg --schema ./drizzle/schema.ts --out=./drizzle/migrations", 19 | "db:migrate": "npx vite-node ./drizzle/migrate.ts", 20 | "db:seed": "npx vite-node ./drizzle/seed.ts" 21 | }, 22 | "devDependencies": { 23 | "@nuxt/test-utils": "^3.11.0", 24 | "@nuxtjs/supabase": "^1.1.6", 25 | "@nuxtjs/tailwindcss": "^6.11.4", 26 | "@tailwindcss/typography": "^0.5.10", 27 | "@types/node": "^20.11.19", 28 | "dotenv": "^16.4.5", 29 | "drizzle-kit": "^0.20.14", 30 | "nuxt": "^3.10.2", 31 | "nuxt-icon": "^0.6.8", 32 | "ts-node": "^10.9.2", 33 | "typescript": "^5.3.3", 34 | "vitest": "^1.3.0" 35 | }, 36 | "dependencies": { 37 | "@pinia/nuxt": "^0.5.1", 38 | "@trpc/client": "^10.45.1", 39 | "@trpc/server": "^10.45.1", 40 | "daisyui": "^4.7.2", 41 | "drizzle-orm": "^0.29.3", 42 | "generate-password-ts": "^1.6.5", 43 | "openai": "^4.28.0", 44 | "pinia": "^2.1.7", 45 | "postgres": "^3.4.3", 46 | "stripe": "^14.17.0", 47 | "superjson": "^2.2.1", 48 | "trpc-nuxt": "^0.10.19", 49 | "vanilla-cookieconsent": "^3.0.0", 50 | "zod": "^3.22.4" 51 | }, 52 | "overrides": { 53 | "vue": "latest" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /pages/account.vue: -------------------------------------------------------------------------------- 1 | 29 | 227 | -------------------------------------------------------------------------------- /pages/cancel.vue: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /pages/confirm.vue: -------------------------------------------------------------------------------- 1 | 13 | 16 | -------------------------------------------------------------------------------- /pages/contact.vue: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /pages/dashboard.vue: -------------------------------------------------------------------------------- 1 | 32 |