├── .env_example ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .vscode └── settings.json ├── CHANGELOG.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 ├── 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 ├── prisma ├── account-access-enum.ts ├── prisma.client.ts ├── schema.prisma └── seed.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 | # This was inserted by `prisma init`: 8 | # Environment variables declared in this file are automatically made available to Prisma. 9 | # See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema 10 | 11 | # Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB. 12 | # See the documentation for all the connection string options: https://pris.ly/d/connection-strings 13 | 14 | DATABASE_URL="postgresql://postgres:xxxxxxxxxxxxx@db.xxxxxxxxxxxxx.supabase.co:5432/postgres" 15 | 16 | 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 | -------------------------------------------------------------------------------- /.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 | prisma/schema.prisma 16 | assets 17 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 4 | 5 | [![Netlify Status](https://api.netlify.com/api/v1/badges/19d67f94-afdc-4b79-8490-600be26e85de/deploy-status)](https://app.netlify.com/sites/nuxt3-saas-boilerplate/deploys) 6 | 7 | ## Demo Sites 8 | 9 | Demo site [here](https://nuxt3-saas-boilerplate.netlify.app/) 10 | 11 | Pottery Helper [here](https://potteryhelper.com/) 12 | 13 | ## Community 14 | 15 | Discord [here](https://discord.gg/3hWPDTA4kD) 16 | 17 | ## Tech Stack 18 | 19 | - Nuxt 3 20 | - Supabase (auth including OAuth + Postgresql instance) 21 | - Prisma (schema management + Strongly typed client) 22 | - TRPC (server/client communication with Strong types, SSR compatible) 23 | - Pinia (state Store) 24 | - Stripe (payments including webhook integration) 25 | - Tailwind + daisyUI (styling and components) 26 | - OpenAI (text completions with AI) 27 | 28 | ## Features 29 | 30 | ### User Management 31 | 32 | - [x] Social Signon (e.g. google) via Supabase, Full list of available [providers](https://supabase.com/docs/guides/auth#providers) 33 | - [x] Email/Password Signon via Supabase 34 | - [x] Password recovery 35 | - [x] User roles and permissions (admin, regular user, etc. roles defined in the [Prisma Schema](/prisma/schema.prisma)) 36 | - [x] User Email captured on initial login 37 | - [x] Initial plan and plan period controled via config to allow either a trial plan or a 'No Plan' for initial users 38 | - [x] Edit Account Name from Account Page 39 | 40 | ### Schema and DB Management 41 | 42 | - [x] Prisma based Schema Management 43 | - [x] Supabase integration for DB 44 | - [x] DB Seed Script to setup plan information including Plan and Stripe Product information 45 | 46 | ### Config Management and Env integration 47 | 48 | - [x] [Config](/nuxt.config.ts) for Stripe Keys 49 | - [x] [Env](/.env_example) keys for Supabase and Stripe 50 | - [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. 51 | 52 | ### Multi-Modal State Management 53 | 54 | - [x] SPA type pages (e.g. [Dashboard](/pages/dashboard.vue)) - postgresql(supabase) -> Prisma -> Service Layer for Business Logic -> TRPC -> Pinia -> UI 55 | - [x] SSR type pages (e.g. [Note](/pages/notes/[note_id].vue)) - postgresql(supabase) -> Prisma -> Service Layer for Business Logic -> TRPC -> UI 56 | 57 | ### Multi User Accounts (Teams) 58 | 59 | - [x] Allow users to upgrade their accounts fron individual accounts to multi-user accounts (Teams). 60 | - [x] Allow users to switch between Teams and view/edit data from the selected Team. 61 | - [x] All features, billing and limits is controlled at the Account (Team) level (not the user level) 62 | - [x] Gen/Regen an invite link to allow users to join a team 63 | - [x] Team administrators and owners can accept pending invites 64 | - [x] Team administrators and owners can administer the permissions (roles) of other team members on the Accounts page 65 | - [x] Team owners can remove users from team 66 | 67 | ### Plans and Pricing 68 | 69 | - [x] Manage multiple Plans each with specific Feature flags and Plan limits 70 | - [x] Plan features copied to Accounts upon successfull subscription 71 | - [x] Loose coupling between Plan and Account Features to allow ad-hoc account tweaks without creating custom plans 72 | - [x] Pricing page appropriately reacts to users with/without account and current plan. 73 | - [x] User Access level available at the router layer as procedures allowing restriction of access based on user access 74 | - [x] Account features available at the router layer as utility procedures allowing restriction of access based on account features 75 | 76 | ### Stripe (Payments) Integration 77 | 78 | - [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. 79 | - [x] Support basic (customer.subscription) flows for Subscription payments via Webhook 80 | - [ ] Support additional Stripe flows for things like failed payments, imminent subscription expiry (send email?) etc..... 81 | 82 | ### Support 83 | 84 | - [ ] Help desk support (ticketing system, live chat, etc.) 85 | - [ ] Knowledge base with FAQs and tutorials 86 | 87 | ### Look and Feel, Design System and Customisation 88 | 89 | - [x] Default UI isn't too crap 90 | - [x] Integrated Design system including theming (Tailwind + daisyUI) 91 | - [x] Toasts for things like reset email sent 92 | - [x] Modals, just because people like modals 93 | 94 | ### GDPR 95 | 96 | - [x] Cookie Consent 97 | 98 | ### Demo Software (Notes) 99 | 100 | - [x] Simple Text based Notes functionality 101 | - [x] Read only Notes Dashboard 102 | - [x] SSR Rendered (SEO Optimised) [Note](/pages/notes/[note_id].vue) Display 103 | - [x] Max Notes limit property on Plan 104 | - [x] Max Notes enforced 105 | - [x] Add, Delete notes on Dashboard 106 | - [x] AI Note generation with OpenAI 107 | - [x] Per Account, Per Month Useage Limits on AI Access 108 | 109 | ### Testing 110 | 111 | - [x] Manual test scenario for auth and sub workflows passing 112 | - [x] Unit test framework (vitest) 113 | - [ ] Integration tests for auth and sub workflows 114 | 115 | ## Special Mention 116 | 117 | 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. 118 | 119 | ## Architecture 120 | 121 | The focus is on separation of concerns and avoiding vendor lock in. 122 | 123 | ### Diagram 124 | 125 | 126 | 127 | ### Walkthrough 128 | 129 | [](https://www.youtube.com/watch?v=AFfbGuJYRqI) 130 | 131 | ### Tricky Decisions 132 | 133 | _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. 134 | 135 | _Prisma over Supabase API_ - I went with Prisma 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. Also, I really like how Prisma handles schema changes and updates to the client layer and types with just two bash commands, after using other approaches, I find this super smooth. 136 | 137 | _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. 138 | 139 | ## Externals Setup 140 | 141 | Things you gotta do that aren't code (and are therefore not very interesting) 142 | 143 | ### Env 144 | 145 | Copy the [.env_example](/.env_example) file to create [.env](/.env) 146 | Note) This file is for development convenience, is .gitignored by default and should _not_ be added to source control 147 | 148 | ### Supabase 149 | 150 | 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. 151 | 152 | 1. Go to [Supabase](https://supabase.com/) and 'Start your Project' 153 | 2. Setup your org and project (Free tier is fine to start) 154 | 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. 155 | 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. 156 | 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 157 | 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. 158 | 159 | ### Stripe 160 | 161 | This solution uses Stripe for Subscription payments. 162 | 163 | 1. Go to [Stripe](https://stripe.com) and setup your business (Free Tier is fine to start) 164 | 2. Create 2 products ('Team Plan' and 'Individual Plan') each with a single price and note the Product ID's and Price ID's 165 | 3. Edit the [seed.ts](/prisma/seed.ts) file and replace the stripe_product_id values with the Product ID's from 2) 166 | 167 | ```typescript 168 | create: { 169 | name: 'Team Plan', 170 | ..... 171 | stripe_product_id: '[Your Product ID from Stripe]' 172 | }, 173 | ``` 174 | 175 | 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... 176 | 177 | ```html 178 | 179 | ``` 180 | 181 | 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 182 | 183 | 6. install the stripe cli used to forward webhooks (macos) 184 | 185 | ``` 186 | brew install stripe/stripe-cli/stripe 187 | ``` 188 | 189 | 7. log the CLI into your stripe account. 190 | 191 | ``` 192 | stripe login -i 193 | ``` 194 | 195 | provide the api key found in step 5) above 196 | 197 | ### Setup Database (Prisma) 198 | 199 | This solution uses Prisma 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 and re-generate the local prisma client. 200 | 201 | ``` 202 | npx prisma db push 203 | npx prisma generate 204 | npm install @prisma/client --save-dev 205 | npx prisma db seed 206 | ``` 207 | 208 | ...you should now have a a Plan table with 3 rows and a bunch of empty tables in your Supabase DB 209 | 210 | ## Developement Setup 211 | 212 | ### Dependencies 213 | 214 | ```bash 215 | # yarn 216 | yarn install 217 | 218 | # npm 219 | npm install 220 | 221 | # pnpm 222 | pnpm install --shamefully-hoist 223 | ``` 224 | 225 | ### Webhook Forwarding 226 | 227 | This makes sure that you can debug subscription workflows locally 228 | 229 | ```bash 230 | stripe listen --forward-to localhost:3000/webhook 231 | ``` 232 | 233 | If you haven't already done so look at the stripe cli output for this text 234 | 235 | ``` 236 | Your webhook signing secret is whsec_xxxxxxxxxxxxx (^C to quit) 237 | ``` 238 | 239 | take ths signing secret and update the STRIPE_ENDPOINT_SECRET value in .env 240 | 241 | ### Start the Server 242 | 243 | Start the development server on http://localhost:3000 244 | 245 | ```bash 246 | npm run dev 247 | ``` 248 | 249 | ### Running the Tests 250 | 251 | 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. 252 | 253 | ```bash 254 | npm run test 255 | ``` 256 | 257 | ## Production 258 | 259 | Build the application for production: 260 | 261 | ```bash 262 | npm run build 263 | ``` 264 | 265 | Locally preview production build: 266 | 267 | ```bash 268 | npm run preview 269 | ``` 270 | 271 | Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information. 272 | 273 | ### Going Live on Netlify 274 | 275 | Where you host your SAAS is 100% your problem however :- 276 | 277 | - 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) 278 | - I was able to get my app up and running with ridiculously little effort 279 | 280 | Steps (Assumes your repo is in github) 281 | 282 | 1. Go to [Netlify](https://www.netlify.com/) 283 | 2. Log in with your github account (it's easier) and create an account (Free Tier is fine for now) 284 | 3. Add a New Site -> Import from Existing Proect 285 | 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. 286 | 5. Setup environment variables per the .env_example file (SUPABASE_URL, SUPABASE_KEY....etc) 287 | 6. Optionally change site name (e.g. mycoolsaas) or apply a domain name 288 | 289 | 7. Go to [Supabase](https://app.supabase.com/) 290 | 8. Choose your project 291 | 9. Go to URL Authentication -> URL Configuration -> Site URL 292 | 10. enter your new netlify URL e.g. https://mycoolsaas.netlify.app/ and click 'save' 293 | 11. Add the following additional redirect URLs for local development and deployment previews: 294 | 295 | - http://localhost:3000/\*\* 296 | - https://**--mycoolsaas.netlify.app/** (or https://mycustomdomain.com/**) 297 | 298 | 12. If you haven't already done so, edit your Supabase Email templates as the generic ones tend to get blocked by GMail. 299 | 300 | ### Netlify deployments and environment variables 301 | 302 | Netlify is a bit rubbish at updating environment variables so you may need to manually re-deploy your site in certain situations e.g. 303 | 304 | - 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. 305 | - 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. 306 | 307 | To manually redeploy to to your Netlify dashboard and navigate to Deploys -> Trigger Deploy -> Deploy site 308 | -------------------------------------------------------------------------------- /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/460c859ab3012fa0a3bf267a6cb6e4cf03c17784/assets/images/landing_config_environment.jpeg -------------------------------------------------------------------------------- /assets/images/landing_db_schema_management.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas/460c859ab3012fa0a3bf267a6cb6e4cf03c17784/assets/images/landing_db_schema_management.jpeg -------------------------------------------------------------------------------- /assets/images/landing_state_management.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas/460c859ab3012fa0a3bf267a6cb6e4cf03c17784/assets/images/landing_state_management.jpeg -------------------------------------------------------------------------------- /assets/images/landing_stripe_integration.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas/460c859ab3012fa0a3bf267a6cb6e4cf03c17784/assets/images/landing_stripe_integration.jpeg -------------------------------------------------------------------------------- /assets/images/landing_style_system.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas/460c859ab3012fa0a3bf267a6cb6e4cf03c17784/assets/images/landing_style_system.jpeg -------------------------------------------------------------------------------- /assets/images/landing_user_management.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas/460c859ab3012fa0a3bf267a6cb6e4cf03c17784/assets/images/landing_user_management.jpeg -------------------------------------------------------------------------------- /assets/images/saas_landing_main.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas/460c859ab3012fa0a3bf267a6cb6e4cf03c17784/assets/images/saas_landing_main.jpeg -------------------------------------------------------------------------------- /assets/images/supanuxt_logo_100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas/460c859ab3012fa0a3bf267a6cb6e4cf03c17784/assets/images/supanuxt_logo_100.png -------------------------------------------------------------------------------- /assets/images/supanuxt_logo_200.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas/460c859ab3012fa0a3bf267a6cb6e4cf03c17784/assets/images/supanuxt_logo_200.png -------------------------------------------------------------------------------- /assets/images/supanuxt_logo_400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas/460c859ab3012fa0a3bf267a6cb6e4cf03c17784/assets/images/supanuxt_logo_400.png -------------------------------------------------------------------------------- /assets/images/supanuxt_logo_800.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas/460c859ab3012fa0a3bf267a6cb6e4cf03c17784/assets/images/supanuxt_logo_800.png -------------------------------------------------------------------------------- /assets/images/technical_architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas/460c859ab3012fa0a3bf267a6cb6e4cf03c17784/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 | -------------------------------------------------------------------------------- /lib/services/account.service.ts: -------------------------------------------------------------------------------- 1 | import { ACCOUNT_ACCESS } from '~~/prisma/account-access-enum'; 2 | import prisma_client from '~~/prisma/prisma.client'; 3 | import { 4 | accountWithMembers, 5 | type AccountWithMembers, 6 | membershipWithAccount, 7 | type MembershipWithAccount, 8 | membershipWithUser, 9 | type MembershipWithUser 10 | } from './service.types'; 11 | import generator from 'generate-password-ts'; 12 | import { UtilService } from './util.service'; 13 | import { AccountLimitError } from './errors'; 14 | 15 | const config = useRuntimeConfig(); 16 | 17 | export namespace AccountService { 18 | export async function getAccountById( 19 | account_id: number 20 | ): Promise { 21 | return prisma_client.account.findFirstOrThrow({ 22 | where: { id: account_id }, 23 | ...accountWithMembers 24 | }); 25 | } 26 | 27 | export async function getAccountByJoinPassword( 28 | join_password: string 29 | ): Promise { 30 | return prisma_client.account.findFirstOrThrow({ 31 | where: { join_password }, 32 | ...accountWithMembers 33 | }); 34 | } 35 | 36 | export async function getAccountMembers( 37 | account_id: number 38 | ): Promise { 39 | return prisma_client.membership.findMany({ 40 | where: { account_id }, 41 | ...membershipWithUser 42 | }); 43 | } 44 | 45 | export async function updateAccountStipeCustomerId( 46 | account_id: number, 47 | stripe_customer_id: string 48 | ) { 49 | return await prisma_client.account.update({ 50 | where: { id: account_id }, 51 | data: { 52 | stripe_customer_id 53 | } 54 | }); 55 | } 56 | 57 | export async function updateStripeSubscriptionDetailsForAccount( 58 | stripe_customer_id: string, 59 | stripe_subscription_id: string, 60 | current_period_ends: Date, 61 | stripe_product_id: string 62 | ) { 63 | const account = await prisma_client.account.findFirstOrThrow({ 64 | where: { stripe_customer_id } 65 | }); 66 | 67 | const paid_plan = await prisma_client.plan.findFirstOrThrow({ 68 | where: { stripe_product_id } 69 | }); 70 | 71 | if (paid_plan.id == account.plan_id) { 72 | // only update sub and period info 73 | return await prisma_client.account.update({ 74 | where: { id: account.id }, 75 | data: { 76 | stripe_subscription_id, 77 | current_period_ends, 78 | ai_gen_count: 0 79 | } 80 | }); 81 | } else { 82 | // plan upgrade/downgrade... update everything, copying over plan features and perks 83 | return await prisma_client.account.update({ 84 | where: { id: account.id }, 85 | data: { 86 | stripe_subscription_id, 87 | current_period_ends, 88 | plan_id: paid_plan.id, 89 | features: paid_plan.features, 90 | max_notes: paid_plan.max_notes, 91 | max_members: paid_plan.max_members, 92 | plan_name: paid_plan.name, 93 | ai_gen_max_pm: paid_plan.ai_gen_max_pm, 94 | 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/ 95 | } 96 | }); 97 | } 98 | } 99 | 100 | export async function acceptPendingMembership( 101 | account_id: number, 102 | membership_id: number 103 | ): Promise { 104 | const membership = await prisma_client.membership.findFirstOrThrow({ 105 | where: { 106 | id: membership_id 107 | } 108 | }); 109 | 110 | if (membership.account_id != account_id) { 111 | throw new Error(`Membership does not belong to current account`); 112 | } 113 | 114 | return await prisma_client.membership.update({ 115 | where: { 116 | id: membership_id 117 | }, 118 | data: { 119 | pending: false 120 | }, 121 | ...membershipWithAccount 122 | }); 123 | } 124 | 125 | export async function deleteMembership( 126 | account_id: number, 127 | membership_id: number 128 | ): Promise { 129 | const membership = await prisma_client.membership.findFirstOrThrow({ 130 | where: { 131 | id: membership_id 132 | } 133 | }); 134 | 135 | if (membership.account_id != account_id) { 136 | throw new Error(`Membership does not belong to current account`); 137 | } 138 | 139 | return await prisma_client.membership.delete({ 140 | where: { 141 | id: membership_id 142 | }, 143 | ...membershipWithAccount 144 | }); 145 | } 146 | 147 | export async function joinUserToAccount( 148 | user_id: number, 149 | account_id: number, 150 | pending: boolean 151 | ): Promise { 152 | const account = await prisma_client.account.findUnique({ 153 | where: { 154 | id: account_id 155 | }, 156 | include: { 157 | members: true 158 | } 159 | }); 160 | 161 | if (account?.members && account?.members?.length >= account?.max_members) { 162 | throw new Error( 163 | `Too Many Members, Account only permits ${account?.max_members} members.` 164 | ); 165 | } 166 | 167 | if (account?.members) { 168 | for (const member of account.members) { 169 | if (member.user_id === user_id) { 170 | throw new Error(`User is already a member`); 171 | } 172 | } 173 | } 174 | 175 | return prisma_client.membership.create({ 176 | data: { 177 | user_id: user_id, 178 | account_id, 179 | access: ACCOUNT_ACCESS.READ_ONLY, 180 | pending 181 | }, 182 | ...membershipWithAccount 183 | }); 184 | } 185 | 186 | export async function changeAccountName( 187 | account_id: number, 188 | new_name: string 189 | ) { 190 | return prisma_client.account.update({ 191 | where: { id: account_id }, 192 | data: { 193 | name: new_name 194 | } 195 | }); 196 | } 197 | 198 | export async function changeAccountPlan(account_id: number, plan_id: number) { 199 | const plan = await prisma_client.plan.findFirstOrThrow({ 200 | where: { id: plan_id } 201 | }); 202 | return prisma_client.account.update({ 203 | where: { id: account_id }, 204 | data: { 205 | plan_id: plan_id, 206 | features: plan.features, 207 | max_notes: plan.max_notes 208 | } 209 | }); 210 | } 211 | 212 | export async function rotateJoinPassword(account_id: number) { 213 | const join_password: string = generator.generate({ 214 | length: 10, 215 | numbers: true 216 | }); 217 | return await prisma_client.account.update({ 218 | where: { id: account_id }, 219 | data: { join_password } 220 | }); 221 | } 222 | 223 | // Claim ownership of an account. 224 | // User must already be an ADMIN for the Account 225 | // Existing OWNER memberships are downgraded to ADMIN 226 | // In future, some sort of Billing/Stripe tie in here e.g. changing email details on the Account, not sure. 227 | export async function claimOwnershipOfAccount( 228 | user_id: number, 229 | account_id: number 230 | ): Promise { 231 | const membership = await prisma_client.membership.findUniqueOrThrow({ 232 | where: { 233 | user_id_account_id: { 234 | user_id: user_id, 235 | account_id: account_id 236 | } 237 | } 238 | }); 239 | 240 | if (membership.access === ACCOUNT_ACCESS.OWNER) { 241 | throw new Error('BADREQUEST: user is already owner'); 242 | } else if (membership.access !== ACCOUNT_ACCESS.ADMIN) { 243 | throw new Error('UNAUTHORISED: only Admins can claim ownership'); 244 | } 245 | 246 | const existing_owner_memberships = await prisma_client.membership.findMany({ 247 | where: { 248 | account_id: account_id, 249 | access: ACCOUNT_ACCESS.OWNER 250 | } 251 | }); 252 | 253 | for (const existing_owner_membership of existing_owner_memberships) { 254 | await prisma_client.membership.update({ 255 | where: { 256 | user_id_account_id: { 257 | user_id: existing_owner_membership.user_id, 258 | account_id: account_id 259 | } 260 | }, 261 | data: { 262 | access: ACCOUNT_ACCESS.ADMIN // Downgrade OWNER to ADMIN 263 | } 264 | }); 265 | } 266 | 267 | // finally update the ADMIN member to OWNER 268 | await prisma_client.membership.update({ 269 | where: { 270 | user_id_account_id: { 271 | user_id: user_id, 272 | account_id: account_id 273 | } 274 | }, 275 | data: { 276 | access: ACCOUNT_ACCESS.OWNER 277 | } 278 | }); 279 | 280 | // return the full membership list because 2 members have changed. 281 | return prisma_client.membership.findMany({ 282 | where: { account_id }, 283 | ...membershipWithUser 284 | }); 285 | } 286 | 287 | // Upgrade access of a membership. Cannot use this method to upgrade to or downgrade from OWNER access 288 | export async function changeUserAccessWithinAccount( 289 | user_id: number, 290 | account_id: number, 291 | access: ACCOUNT_ACCESS 292 | ) { 293 | if (access === ACCOUNT_ACCESS.OWNER) { 294 | throw new Error( 295 | 'UNABLE TO UPDATE MEMBERSHIP: use claimOwnershipOfAccount method to change ownership' 296 | ); 297 | } 298 | 299 | const membership = await prisma_client.membership.findUniqueOrThrow({ 300 | where: { 301 | user_id_account_id: { 302 | user_id: user_id, 303 | account_id: account_id 304 | } 305 | } 306 | }); 307 | 308 | if (membership.access === ACCOUNT_ACCESS.OWNER) { 309 | throw new Error( 310 | 'UNABLE TO UPDATE MEMBERSHIP: use claimOwnershipOfAccount method to change ownership' 311 | ); 312 | } 313 | 314 | return prisma_client.membership.update({ 315 | where: { 316 | user_id_account_id: { 317 | user_id: user_id, 318 | account_id: account_id 319 | } 320 | }, 321 | data: { 322 | access: access 323 | }, 324 | include: { 325 | account: true 326 | } 327 | }); 328 | } 329 | 330 | /* 331 | **** Usage Limit Checking ***** 332 | This is trickier than you might think at first. Free plan users don't get a webhook from Stripe 333 | that we can use to tick over their period end date and associated usage counts. I also didn't 334 | want to require an additional background thread to do the rollover processing. 335 | 336 | getAccountWithPeriodRollover: retrieves an account record and does the rollover checking returning up to date account info 337 | checkAIGenCount: retrieves the account using getAccountWithPeriodRollover, checks the count and returns the account 338 | incrementAIGenCount: increments the counter using the account. Note that passing in the account avoids another db fetch for the account. 339 | 340 | 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 341 | 342 | How to use in a service method.... 343 | export async function someServiceMethod(account_id: number, .....etc) { 344 | const account = await AccountService.checkAIGenCount(account_id); 345 | ... User is under the limit so do work 346 | await AccountService.incrementAIGenCount(account); 347 | } 348 | */ 349 | 350 | export async function getAccountWithPeriodRollover(account_id: number) { 351 | const account = await prisma_client.account.findFirstOrThrow({ 352 | where: { id: account_id } 353 | }); 354 | 355 | if ( 356 | account.plan_name === config.initialPlanName && 357 | account.current_period_ends < new Date() 358 | ) { 359 | return await prisma_client.account.update({ 360 | where: { id: account.id }, 361 | data: { 362 | current_period_ends: UtilService.addMonths( 363 | account.current_period_ends, 364 | 1 365 | ), 366 | // reset anything that is affected by the rollover 367 | ai_gen_count: 0 368 | } 369 | }); 370 | } 371 | 372 | return account; 373 | } 374 | 375 | export async function checkAIGenCount(account_id: number) { 376 | const account = await getAccountWithPeriodRollover(account_id); 377 | 378 | if (account.ai_gen_count >= account.ai_gen_max_pm) { 379 | throw new AccountLimitError( 380 | 'Monthly AI gen limit reached, no new AI Generations can be made' 381 | ); 382 | } 383 | 384 | return account; 385 | } 386 | 387 | export async function incrementAIGenCount(account: any) { 388 | return await prisma_client.account.update({ 389 | where: { id: account.id }, 390 | data: { 391 | ai_gen_count: account.ai_gen_count + 1 392 | } 393 | }); 394 | } 395 | } 396 | -------------------------------------------------------------------------------- /lib/services/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { ACCOUNT_ACCESS } from '~~/prisma/account-access-enum'; 2 | import prisma_client from '~~/prisma/prisma.client'; 3 | import { fullDBUser, type FullDBUser } from './service.types'; 4 | import { UtilService } from './util.service'; 5 | import generator from 'generate-password-ts'; 6 | 7 | const config = useRuntimeConfig(); 8 | 9 | export namespace AuthService { 10 | export async function getFullUserBySupabaseId( 11 | supabase_uid: string 12 | ): Promise { 13 | return prisma_client.user.findFirst({ 14 | where: { supabase_uid }, 15 | ...fullDBUser 16 | }); 17 | } 18 | 19 | export async function getUserById( 20 | user_id: number 21 | ): Promise { 22 | return prisma_client.user.findFirstOrThrow({ 23 | where: { id: user_id }, 24 | ...fullDBUser 25 | }); 26 | } 27 | 28 | export async function createUser( 29 | supabase_uid: string, 30 | display_name: string, 31 | email: string 32 | ): Promise { 33 | const trialPlan = await prisma_client.plan.findFirstOrThrow({ 34 | where: { name: config.initialPlanName } 35 | }); 36 | const join_password: string = generator.generate({ 37 | length: 10, 38 | numbers: true 39 | }); 40 | return prisma_client.user.create({ 41 | data: { 42 | supabase_uid: supabase_uid, 43 | display_name: display_name, 44 | email: email, 45 | memberships: { 46 | create: { 47 | account: { 48 | create: { 49 | name: display_name, 50 | current_period_ends: UtilService.addMonths( 51 | new Date(), 52 | config.initialPlanActiveMonths 53 | ), 54 | plan_id: trialPlan.id, 55 | features: trialPlan.features, 56 | max_notes: trialPlan.max_notes, 57 | max_members: trialPlan.max_members, 58 | plan_name: trialPlan.name, 59 | join_password: join_password 60 | } 61 | }, 62 | access: ACCOUNT_ACCESS.OWNER 63 | } 64 | } 65 | }, 66 | ...fullDBUser 67 | }); 68 | } 69 | 70 | export async function deleteUser(user_id: number): Promise { 71 | return prisma_client.user.delete({ 72 | where: { id: user_id }, 73 | ...fullDBUser 74 | }); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /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 prisma_client from '~~/prisma/prisma.client'; 2 | import { openai } from './openai.client'; 3 | import { AccountLimitError } from './errors'; 4 | import { AccountService } from './account.service'; 5 | 6 | export namespace NotesService { 7 | export async function getNoteById(id: number) { 8 | return prisma_client.note.findUniqueOrThrow({ where: { id } }); 9 | } 10 | 11 | export async function getNotesForAccountId(account_id: number) { 12 | return prisma_client.note.findMany({ where: { account_id } }); 13 | } 14 | 15 | export async function createNote(account_id: number, note_text: string) { 16 | const account = await prisma_client.account.findFirstOrThrow({ 17 | where: { id: account_id }, 18 | include: { notes: true } 19 | }); 20 | 21 | if (account.notes.length >= account.max_notes) { 22 | throw new AccountLimitError( 23 | 'Note Limit reached, no new notes can be added' 24 | ); 25 | } 26 | 27 | return prisma_client.note.create({ data: { account_id, note_text } }); 28 | } 29 | 30 | export async function updateNote(id: number, note_text: string) { 31 | return prisma_client.note.update({ where: { id }, data: { note_text } }); 32 | } 33 | 34 | export async function deleteNote(id: number) { 35 | return prisma_client.note.delete({ where: { id } }); 36 | } 37 | 38 | export async function generateAINoteFromPrompt( 39 | userPrompt: string, 40 | account_id: number 41 | ) { 42 | const account = await AccountService.checkAIGenCount(account_id); 43 | 44 | const prompt = ` 45 | Write an interesting short note about ${userPrompt}. 46 | Restrict the note to a single paragraph. 47 | `; 48 | const completion = await openai.chat.completions.create({ 49 | model: 'gpt-3.5-turbo', 50 | messages: [{ role: 'user', content: prompt }], 51 | temperature: 0.6, 52 | stop: '\n\n', 53 | max_tokens: 1000, 54 | n: 1 55 | }); 56 | 57 | await AccountService.incrementAIGenCount(account); 58 | 59 | return completion.choices?.[0]?.message.content?.trim(); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /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 { Prisma } from '@prisma/client'; 2 | 3 | export const membershipWithAccount = Prisma.validator()({ 4 | include: { account: true } 5 | }); 6 | export type MembershipWithAccount = Prisma.MembershipGetPayload< 7 | typeof membershipWithAccount 8 | >; 9 | 10 | export const membershipWithUser = Prisma.validator()({ 11 | include: { user: true } 12 | }); 13 | export type MembershipWithUser = Prisma.MembershipGetPayload< 14 | typeof membershipWithUser 15 | >; 16 | 17 | export const fullDBUser = Prisma.validator()({ 18 | include: { 19 | memberships: { 20 | include: { 21 | account: true 22 | } 23 | } 24 | } 25 | }); 26 | export type FullDBUser = Prisma.UserGetPayload; //TODO - I wonder if this could be replaced by just user level info 27 | 28 | export const accountWithMembers = Prisma.validator()({ 29 | include: { 30 | members: { 31 | include: { 32 | user: true 33 | } 34 | } 35 | } 36 | }); 37 | export type AccountWithMembers = Prisma.AccountGetPayload< 38 | typeof accountWithMembers 39 | >; //TODO - I wonder if this could just be a list of full memberships 40 | -------------------------------------------------------------------------------- /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", 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 | "prisma": { 12 | "seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts" 13 | }, 14 | "scripts": { 15 | "build": "nuxt build", 16 | "dev": "nuxt dev", 17 | "generate": "nuxt generate", 18 | "preview": "nuxt preview", 19 | "postinstall": "prisma generate && nuxt prepare", 20 | "test": "vitest" 21 | }, 22 | "devDependencies": { 23 | "@nuxt/test-utils": "^3.11.0", 24 | "@nuxtjs/supabase": "^1.1.6", 25 | "@nuxtjs/tailwindcss": "^6.11.4", 26 | "@prisma/client": "^5.9.1", 27 | "@tailwindcss/typography": "^0.5.10", 28 | "@types/node": "^20.11.19", 29 | "nuxt": "^3.10.2", 30 | "nuxt-icon": "^0.6.8", 31 | "prisma": "^5.9.1", 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 | "generate-password-ts": "^1.6.5", 42 | "openai": "^4.28.0", 43 | "pinia": "^2.1.7", 44 | "stripe": "^14.17.0", 45 | "superjson": "^2.2.1", 46 | "trpc-nuxt": "^0.10.19", 47 | "vanilla-cookieconsent": "^3.0.0", 48 | "zod": "^3.22.4" 49 | }, 50 | "overrides": { 51 | "vue": "latest" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /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 |