├── assets ├── css │ └── tailwind.css └── images │ ├── saas_landing_main.jpeg │ ├── supanuxt_logo_100.png │ ├── supanuxt_logo_200.png │ ├── supanuxt_logo_400.png │ ├── supanuxt_logo_800.png │ ├── landing_style_system.jpeg │ ├── landing_user_management.jpeg │ ├── technical_architecture.png │ ├── landing_state_management.jpeg │ ├── landing_config_environment.jpeg │ ├── landing_db_schema_management.jpeg │ ├── landing_stripe_integration.jpeg │ └── avatar.svg ├── public ├── favicon.ico ├── favicon-16x16.png ├── favicon-32x32.png ├── apple-touch-icon.png ├── android-chrome-192x192.png ├── android-chrome-512x512.png └── site.webmanifest ├── tsconfig.json ├── .gitignore ├── lib └── services │ ├── openai.client.ts │ ├── errors.ts │ ├── util.service.ts │ ├── service.types.ts │ ├── notes.service.ts │ ├── auth.service.ts │ └── account.service.ts ├── prisma ├── prisma.client.ts ├── account-access-enum.ts ├── seed.ts └── schema.prisma ├── .prettierrc.json ├── middleware └── auth.ts ├── .vscode └── settings.json ├── components ├── modal.type.ts ├── AppFooter.vue ├── UserAccount │ ├── UserAccountSignout.client.vue │ ├── UserAccountSwitch.client.vue │ └── UserAccount.vue ├── AppHeader.vue ├── Notifications.client.vue └── Modal.vue ├── .prettierignore ├── server ├── trpc │ ├── routers │ │ ├── auth.router.ts │ │ ├── app.router.ts │ │ ├── notes.router.ts │ │ └── account.router.ts │ ├── context.ts │ └── trpc.ts ├── api │ ├── trpc │ │ └── [trpc].ts │ └── note.ts ├── defineProtectedEventHandler.ts ├── routes │ ├── create-checkout-session.post.ts │ └── webhook.post.ts └── middleware │ └── authContext.ts ├── pages ├── contact.vue ├── fail.vue ├── cancel.vue ├── confirm.vue ├── notes │ └── [note_id].vue ├── success.vue ├── forgotpassword.vue ├── resetpassword.vue ├── deletemyaccount.vue ├── join │ └── [join_password].vue ├── dashboard.vue ├── privacy.vue ├── signin.vue ├── signup.vue ├── terms.vue ├── pricing.vue ├── account.vue └── index.vue ├── tailwind.config.js ├── vitest.config.ts ├── plugins ├── trpcClient.ts └── cookieconsent.client.ts ├── .env_example ├── LICENSE ├── app.vue ├── stores ├── notify.store.ts ├── notes.store.ts └── account.store.ts ├── package.json ├── nuxt.config.ts ├── test ├── notify.store.spec.ts ├── TEST.md └── account.store.spec.ts ├── CHANGELOG.md ├── README.md └── patches ├── 1_4_2-service-refactor-to-namespaces_authcontextremoved.patch └── 1_4_2-service-refactor-to-namespaces.patch /assets/css/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas/HEAD/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas/HEAD/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log* 3 | .nuxt 4 | .nitro 5 | .cache 6 | .output 7 | .env 8 | dist 9 | junk 10 | .DS_Store 11 | -------------------------------------------------------------------------------- /assets/images/saas_landing_main.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas/HEAD/assets/images/saas_landing_main.jpeg -------------------------------------------------------------------------------- /assets/images/supanuxt_logo_100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas/HEAD/assets/images/supanuxt_logo_100.png -------------------------------------------------------------------------------- /assets/images/supanuxt_logo_200.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas/HEAD/assets/images/supanuxt_logo_200.png -------------------------------------------------------------------------------- /assets/images/supanuxt_logo_400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas/HEAD/assets/images/supanuxt_logo_400.png -------------------------------------------------------------------------------- /assets/images/supanuxt_logo_800.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas/HEAD/assets/images/supanuxt_logo_800.png -------------------------------------------------------------------------------- /assets/images/landing_style_system.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas/HEAD/assets/images/landing_style_system.jpeg -------------------------------------------------------------------------------- /assets/images/landing_user_management.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas/HEAD/assets/images/landing_user_management.jpeg -------------------------------------------------------------------------------- /assets/images/technical_architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas/HEAD/assets/images/technical_architecture.png -------------------------------------------------------------------------------- /assets/images/landing_state_management.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas/HEAD/assets/images/landing_state_management.jpeg -------------------------------------------------------------------------------- /assets/images/landing_config_environment.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas/HEAD/assets/images/landing_config_environment.jpeg -------------------------------------------------------------------------------- /assets/images/landing_db_schema_management.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas/HEAD/assets/images/landing_db_schema_management.jpeg -------------------------------------------------------------------------------- /assets/images/landing_stripe_integration.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavascriptMick/supanuxt-saas/HEAD/assets/images/landing_stripe_integration.jpeg -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /prisma/prisma.client.ts: -------------------------------------------------------------------------------- 1 | import pkg from '@prisma/client'; 2 | 3 | const { PrismaClient } = pkg; 4 | const prisma_client = new PrismaClient(); 5 | export default prisma_client; 6 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "bracketSameLine": true, 4 | "vueIndentScriptAndStyle": true, 5 | "arrowParens": "avoid", 6 | "trailingComma": "none" 7 | } 8 | -------------------------------------------------------------------------------- /middleware/auth.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtRouteMiddleware(() => { 2 | const user = useSupabaseUser(); 3 | 4 | if (!user.value) { 5 | return navigateTo('/'); 6 | } 7 | }); 8 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /server/trpc/routers/auth.router.ts: -------------------------------------------------------------------------------- 1 | import { publicProcedure, router } from '../trpc'; 2 | 3 | export const authRouter = router({ 4 | getDBUser: publicProcedure.query(({ ctx }) => { 5 | return { 6 | dbUser: ctx.dbUser 7 | }; 8 | }) 9 | }); 10 | -------------------------------------------------------------------------------- /pages/contact.vue: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /pages/fail.vue: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /pages/cancel.vue: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: [require("@tailwindcss/typography"), require("daisyui")], 3 | daisyui: { 4 | styled: true, 5 | themes: ["acid", "night"], 6 | base: true, 7 | utils: true, 8 | logs: true, 9 | rtl: false, 10 | prefix: "", 11 | darkTheme: "night", 12 | }, 13 | } 14 | -------------------------------------------------------------------------------- /pages/confirm.vue: -------------------------------------------------------------------------------- 1 | 13 | 16 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { fileURLToPath } from 'url'; 3 | import { defineConfig } from 'vitest/config'; 4 | 5 | export default defineConfig({ 6 | test: { 7 | // ... Specify options here. 8 | }, 9 | resolve: { 10 | alias: { 11 | '~~': fileURLToPath(new URL('./', import.meta.url)) 12 | } 13 | } 14 | }); 15 | -------------------------------------------------------------------------------- /pages/notes/[note_id].vue: -------------------------------------------------------------------------------- 1 | 8 | 14 | -------------------------------------------------------------------------------- /components/AppFooter.vue: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "SupaNuxt SaaS", 3 | "short_name": "supanuxt_saas", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /server/trpc/routers/app.router.ts: -------------------------------------------------------------------------------- 1 | import { router } from '~/server/trpc/trpc'; 2 | import { notesRouter } from './notes.router'; 3 | import { authRouter } from './auth.router'; 4 | import { accountRouter } from './account.router'; 5 | 6 | export const appRouter = router({ 7 | notes: notesRouter, 8 | auth: authRouter, 9 | account: accountRouter 10 | }); 11 | 12 | // export only the type definition of the API 13 | // None of the actual implementation is exposed to the client 14 | export type AppRouter = typeof appRouter; 15 | -------------------------------------------------------------------------------- /components/UserAccount/UserAccountSignout.client.vue: -------------------------------------------------------------------------------- 1 | 17 | 20 | -------------------------------------------------------------------------------- /server/trpc/context.ts: -------------------------------------------------------------------------------- 1 | import type { inferAsyncReturnType } from '@trpc/server'; 2 | import { H3Event } from 'h3'; 3 | 4 | export async function createContext(event: H3Event) { 5 | return { 6 | user: event.context.user, // the Supabase User 7 | dbUser: event.context.dbUser, // the corresponding Database User 8 | activeAccountId: event.context.activeAccountId, // the account ID that is active for the user 9 | event // required to enable setCookie in accountRouter 10 | }; 11 | } 12 | 13 | export type Context = inferAsyncReturnType; 14 | -------------------------------------------------------------------------------- /server/api/trpc/[trpc].ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This is the API-handler of your app that contains all your API routes. 3 | * On a bigger app, you will probably want to split this file up into multiple files. 4 | */ 5 | import { createNuxtApiHandler } from 'trpc-nuxt'; 6 | import { createContext } from '~~/server/trpc/context'; 7 | import { appRouter } from '~~/server/trpc/routers/app.router'; 8 | 9 | // export API handler 10 | export default createNuxtApiHandler({ 11 | router: appRouter, 12 | createContext: createContext, 13 | onError({ error }) { 14 | console.error(error); 15 | } 16 | }); 17 | -------------------------------------------------------------------------------- /server/defineProtectedEventHandler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type EventHandler, 3 | type EventHandlerRequest, 4 | H3Event, 5 | eventHandler 6 | } from 'h3'; 7 | 8 | export const defineProtectedEventHandler = ( 9 | handler: EventHandler 10 | ): EventHandler => { 11 | handler.__is_handler__ = true; 12 | 13 | return eventHandler((event: H3Event) => { 14 | const user = event.context.user; 15 | if (!user) { 16 | throw createError({ statusCode: 401, statusMessage: 'Unauthenticated' }); 17 | } 18 | return handler(event); 19 | }); 20 | }; 21 | -------------------------------------------------------------------------------- /plugins/trpcClient.ts: -------------------------------------------------------------------------------- 1 | import { createTRPCNuxtClient, httpBatchLink } from 'trpc-nuxt/client'; 2 | import type { AppRouter } from '~/server/trpc/routers/app.router'; 3 | import superjson from 'superjson'; 4 | 5 | export default defineNuxtPlugin(() => { 6 | /** 7 | * createTRPCNuxtClient adds a `useQuery` composable 8 | * built on top of `useAsyncData`. 9 | */ 10 | const client = createTRPCNuxtClient({ 11 | links: [ 12 | httpBatchLink({ 13 | url: '/api/trpc' 14 | }) 15 | ], 16 | transformer: superjson 17 | }); 18 | 19 | return { 20 | provide: { 21 | client 22 | } 23 | }; 24 | }); 25 | -------------------------------------------------------------------------------- /prisma/account-access-enum.ts: -------------------------------------------------------------------------------- 1 | // Workaround for prisma issue (https://github.com/prisma/prisma/issues/12504#issuecomment-1147356141) 2 | 3 | // Import original enum as type 4 | import type { ACCOUNT_ACCESS as ACCOUNT_ACCESS_ORIGINAL } from '@prisma/client'; 5 | 6 | // Guarantee that the implementation corresponds to the original type 7 | export const ACCOUNT_ACCESS: { [k in ACCOUNT_ACCESS_ORIGINAL]: k } = { 8 | READ_ONLY: 'READ_ONLY', 9 | READ_WRITE: 'READ_WRITE', 10 | ADMIN: 'ADMIN', 11 | OWNER: 'OWNER' 12 | } as const; 13 | 14 | // Re-exporting the original type with the original name 15 | export type ACCOUNT_ACCESS = ACCOUNT_ACCESS_ORIGINAL; 16 | -------------------------------------------------------------------------------- /server/api/note.ts: -------------------------------------------------------------------------------- 1 | import { H3Event, getQuery } from 'h3'; 2 | import { defineProtectedEventHandler } from '../defineProtectedEventHandler'; 3 | import { NotesService } from '~/lib/services/notes.service'; 4 | 5 | // Example API Route with query params ... /api/note?note_id=41 6 | export default defineProtectedEventHandler(async (event: H3Event) => { 7 | const queryParams = getQuery(event); 8 | let note_id: string = ''; 9 | if (queryParams.note_id) { 10 | if (Array.isArray(queryParams.note_id)) { 11 | note_id = queryParams.note_id[0]; 12 | } else { 13 | note_id = queryParams.note_id.toString(); 14 | } 15 | } 16 | 17 | const note = await NotesService.getNoteById(+note_id); 18 | 19 | return { 20 | note 21 | }; 22 | }); 23 | -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /components/UserAccount/UserAccountSwitch.client.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 28 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /assets/images/avatar.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 11 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /pages/success.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 32 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /components/UserAccount/UserAccount.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 37 | -------------------------------------------------------------------------------- /app.vue: -------------------------------------------------------------------------------- 1 | 12 | 28 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /stores/notify.store.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | import { ref } from 'vue'; 3 | 4 | /* 5 | This store manages User and Account state including the ActiveAccount 6 | It is used in the Account administration page and the header due to it's account switching features. 7 | */ 8 | export interface Notification { 9 | message: string; 10 | type: NotificationType; 11 | notifyTime: number; 12 | } 13 | 14 | export enum NotificationType { 15 | Info, 16 | Success, 17 | Warning, 18 | Error 19 | } 20 | 21 | export const useNotifyStore = defineStore('notify', () => { 22 | const notifications = ref([]); 23 | const notificationsArchive = ref([]); 24 | 25 | const notify = (messageOrError: unknown, type: NotificationType) => { 26 | let message: string = ''; 27 | if (messageOrError instanceof Error) message = messageOrError.message; 28 | if (typeof messageOrError === 'string') message = messageOrError; 29 | const notification: Notification = { 30 | message, 31 | type, 32 | notifyTime: Date.now() 33 | }; 34 | notifications.value.push(notification); 35 | setTimeout(removeNotification.bind(this), 5000, notification); 36 | }; 37 | 38 | const removeNotification = (notification: Notification) => { 39 | notifications.value = notifications.value.filter( 40 | n => n.notifyTime != notification.notifyTime 41 | ); 42 | }; 43 | 44 | return { 45 | notifications, 46 | notificationsArchive, 47 | notify, 48 | removeNotification 49 | }; 50 | }); 51 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /prisma/seed.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | const prisma = new PrismaClient(); 3 | async function main() { 4 | const freeTrial = await prisma.plan.upsert({ 5 | where: { name: 'Free Trial' }, 6 | update: {}, 7 | create: { 8 | name: 'Free Trial', 9 | features: ['ADD_NOTES', 'EDIT_NOTES', 'VIEW_NOTES'], 10 | max_notes: 10, 11 | max_members: 1, 12 | ai_gen_max_pm: 7 13 | } 14 | }); 15 | const individualPlan = await prisma.plan.upsert({ 16 | where: { name: 'Individual Plan' }, 17 | update: {}, 18 | create: { 19 | name: 'Individual Plan', 20 | features: ['ADD_NOTES', 'EDIT_NOTES', 'VIEW_NOTES', 'SPECIAL_FEATURE'], 21 | max_notes: 100, 22 | max_members: 1, 23 | ai_gen_max_pm: 50, 24 | stripe_product_id: 'prod_NQR7vwUulvIeqW' 25 | } 26 | }); 27 | const teamPlan = await prisma.plan.upsert({ 28 | where: { name: 'Team Plan' }, 29 | update: {}, 30 | create: { 31 | name: 'Team Plan', 32 | features: [ 33 | 'ADD_NOTES', 34 | 'EDIT_NOTES', 35 | 'VIEW_NOTES', 36 | 'SPECIAL_FEATURE', 37 | 'SPECIAL_TEAM_FEATURE' 38 | ], 39 | max_notes: 200, 40 | max_members: 10, 41 | ai_gen_max_pm: 500, 42 | stripe_product_id: 'prod_NQR8IkkdhqBwu2' 43 | } 44 | }); 45 | 46 | console.log({ freeTrial, individualPlan, teamPlan }); 47 | } 48 | main() 49 | .then(async () => { 50 | await prisma.$disconnect(); 51 | }) 52 | .catch(async e => { 53 | console.error(e); 54 | await prisma.$disconnect(); 55 | process.exit(1); 56 | }); 57 | -------------------------------------------------------------------------------- /stores/notes.store.ts: -------------------------------------------------------------------------------- 1 | import type { Note } from '.prisma/client'; 2 | import { defineStore, storeToRefs } from 'pinia'; 3 | import type { Ref } from 'vue'; 4 | 5 | export const useNotesStore = defineStore('notes', () => { 6 | const accountStore = useAccountStore(); 7 | const { activeAccountId } = storeToRefs(accountStore); 8 | 9 | let _notes: Ref = ref([]); 10 | 11 | async function fetchNotesForCurrentUser() { 12 | const { $client } = useNuxtApp(); 13 | const { notes } = await $client.notes.getForActiveAccount.query(); 14 | if (notes) { 15 | _notes.value = notes; 16 | } 17 | } 18 | 19 | async function createNote(note_text: string) { 20 | const { $client } = useNuxtApp(); 21 | const { note } = await $client.notes.createNote.mutate({ note_text }); 22 | if (note) { 23 | _notes.value.push(note); 24 | } 25 | } 26 | 27 | async function deleteNote(note_id: number) { 28 | const { $client } = useNuxtApp(); 29 | const { note } = await $client.notes.deleteNote.mutate({ note_id }); 30 | if (note) { 31 | _notes.value = _notes.value.filter(n => n.id !== note.id); 32 | } 33 | } 34 | 35 | async function generateAINoteFromPrompt(user_prompt: string) { 36 | const { $client } = useNuxtApp(); 37 | const { noteText } = await $client.notes.generateAINoteFromPrompt.query({ 38 | user_prompt 39 | }); 40 | return noteText ? noteText : ''; 41 | } 42 | 43 | // if the active account changes, fetch notes again (i.e dynamic.. probabl overkill) 44 | watch(activeAccountId, async (val, oldVal) => { 45 | await fetchNotesForCurrentUser(); 46 | }); 47 | 48 | return { 49 | notes: _notes, 50 | fetchNotesForCurrentUser, 51 | createNote, 52 | deleteNote, 53 | generateAINoteFromPrompt 54 | }; 55 | }); 56 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /pages/forgotpassword.vue: -------------------------------------------------------------------------------- 1 | 31 | 56 | -------------------------------------------------------------------------------- /test/notify.store.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect, beforeEach } from 'vitest'; 2 | import { setup, $fetch } from '@nuxt/test-utils'; 3 | import { useNotifyStore, NotificationType } from '../stores/notify.store'; 4 | import { setActivePinia, createPinia } from 'pinia'; 5 | 6 | describe('Notify Store', async () => { 7 | await setup({ 8 | // test context options 9 | }); 10 | 11 | beforeEach(() => { 12 | // creates a fresh pinia and makes it active 13 | // so it's automatically picked up by any useStore() call 14 | // without having to pass it to it: `useStore(pinia)` 15 | setActivePinia(createPinia()); 16 | }); 17 | 18 | test('should add a notification', () => { 19 | const notifyStore = useNotifyStore(); 20 | const message = 'Test notification'; 21 | const type = NotificationType.Info; 22 | 23 | notifyStore.notify(message, type); 24 | 25 | expect(notifyStore.notifications).toHaveLength(1); 26 | expect(notifyStore.notifications[0].message).toBe(message); 27 | expect(notifyStore.notifications[0].type).toBe(type); 28 | }); 29 | 30 | test('should add an Error notification', () => { 31 | const notifyStore = useNotifyStore(); 32 | const error = new Error('Test error'); 33 | const type = NotificationType.Error; 34 | 35 | notifyStore.notify(error, type); 36 | 37 | expect(notifyStore.notifications).toHaveLength(1); 38 | expect(notifyStore.notifications[0].message).toBe(error.message); 39 | expect(notifyStore.notifications[0].type).toBe(type); 40 | }); 41 | 42 | test('should remove a notification', () => { 43 | const notifyStore = useNotifyStore(); 44 | const message = 'Test notification'; 45 | const type = NotificationType.Info; 46 | 47 | notifyStore.notify(message, type); 48 | const notification = notifyStore.notifications[0]; 49 | 50 | notifyStore.removeNotification(notification); 51 | 52 | expect(notifyStore.notifications).toHaveLength(0); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /server/trpc/routers/notes.router.ts: -------------------------------------------------------------------------------- 1 | import { NotesService } from '~~/lib/services/notes.service'; 2 | import { 3 | accountHasSpecialFeature, 4 | adminProcedure, 5 | memberProcedure, 6 | publicProcedure, 7 | readWriteProcedure, 8 | router 9 | } from '../trpc'; 10 | import { z } from 'zod'; 11 | 12 | export const notesRouter = router({ 13 | getForActiveAccount: memberProcedure.query(async ({ ctx, input }) => { 14 | const notes = ctx.activeAccountId 15 | ? await NotesService.getNotesForAccountId(ctx.activeAccountId) 16 | : []; 17 | return { 18 | notes 19 | }; 20 | }), 21 | getById: publicProcedure 22 | .input(z.object({ note_id: z.number() })) 23 | .query(async ({ ctx, input }) => { 24 | const note = await NotesService.getNoteById(input.note_id); 25 | return { 26 | note 27 | }; 28 | }), 29 | createNote: readWriteProcedure 30 | .input(z.object({ note_text: z.string() })) 31 | .mutation(async ({ ctx, input }) => { 32 | const note = ctx.activeAccountId 33 | ? await NotesService.createNote(ctx.activeAccountId, input.note_text) 34 | : null; 35 | return { 36 | note 37 | }; 38 | }), 39 | deleteNote: adminProcedure 40 | .input(z.object({ note_id: z.number() })) 41 | .mutation(async ({ ctx, input }) => { 42 | const note = ctx.activeAccountId 43 | ? await NotesService.deleteNote(input.note_id) 44 | : null; 45 | return { 46 | note 47 | }; 48 | }), 49 | generateAINoteFromPrompt: readWriteProcedure 50 | .use(accountHasSpecialFeature) 51 | .input(z.object({ user_prompt: z.string() })) 52 | .query(async ({ ctx, input }) => { 53 | const noteText = ctx.activeAccountId 54 | ? await NotesService.generateAINoteFromPrompt( 55 | input.user_prompt, 56 | ctx.activeAccountId 57 | ) 58 | : null; 59 | return { 60 | noteText 61 | }; 62 | }) 63 | }); 64 | -------------------------------------------------------------------------------- /components/AppHeader.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 54 | -------------------------------------------------------------------------------- /components/Notifications.client.vue: -------------------------------------------------------------------------------- 1 | 21 | 50 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /components/Modal.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 72 | -------------------------------------------------------------------------------- /server/routes/create-checkout-session.post.ts: -------------------------------------------------------------------------------- 1 | import { ACCOUNT_ACCESS } from '~~/prisma/account-access-enum'; 2 | import Stripe from 'stripe'; 3 | import { AccountService } from '~~/lib/services/account.service'; 4 | import type { AccountWithMembers } from '~~/lib/services/service.types'; 5 | 6 | const config = useRuntimeConfig(); 7 | const stripe = new Stripe(config.stripeSecretKey, { apiVersion: '2023-10-16' }); 8 | 9 | export default defineEventHandler(async event => { 10 | const body = await readBody(event); 11 | let { price_id, account_id } = body; 12 | account_id = +account_id; 13 | console.log( 14 | `session.post.ts recieved price_id:${price_id}, account_id:${account_id}` 15 | ); 16 | 17 | const account: AccountWithMembers = await AccountService.getAccountById( 18 | account_id 19 | ); 20 | let customer_id: string; 21 | if (!account.stripe_customer_id) { 22 | // need to pre-emptively create a Stripe user for this account so we know who they are when the webhook comes back 23 | const owner = account.members.find( 24 | member => member.access == ACCOUNT_ACCESS.OWNER 25 | ); 26 | console.log( 27 | `Creating account with name ${account.name} and email ${owner?.user.email}` 28 | ); 29 | const customer = await stripe.customers.create({ 30 | name: account.name, 31 | email: owner?.user.email 32 | }); 33 | customer_id = customer.id; 34 | AccountService.updateAccountStipeCustomerId(account_id, customer.id); 35 | } else { 36 | customer_id = account.stripe_customer_id; 37 | } 38 | 39 | const session = await stripe.checkout.sessions.create({ 40 | mode: 'subscription', 41 | line_items: [ 42 | { 43 | price: price_id, 44 | quantity: 1 45 | } 46 | ], 47 | // {CHECKOUT_SESSION_ID} is a string literal; do not change it! 48 | // the actual Session ID is returned in the query parameter when your customer 49 | // is redirected to the success page. 50 | success_url: `${config.public.siteRootUrl}/success?session_id={CHECKOUT_SESSION_ID}`, 51 | cancel_url: `${config.public.siteRootUrl}/cancel`, 52 | customer: customer_id 53 | }); 54 | 55 | if (session?.url) { 56 | return sendRedirect(event, session.url, 303); 57 | } else { 58 | return sendRedirect(event, `${config.public.siteRootUrl}/fail`, 303); 59 | } 60 | }); 61 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /pages/resetpassword.vue: -------------------------------------------------------------------------------- 1 | 28 | 67 | -------------------------------------------------------------------------------- /plugins/cookieconsent.client.ts: -------------------------------------------------------------------------------- 1 | import 'vanilla-cookieconsent/dist/cookieconsent.css'; 2 | import * as CookieConsent from 'vanilla-cookieconsent'; 3 | 4 | export default defineNuxtPlugin(nuxtApp => { 5 | /** 6 | * All config. options available here: 7 | * https://cookieconsent.orestbida.com/reference/configuration-reference.html 8 | */ 9 | CookieConsent.run({ 10 | categories: { 11 | necessary: { 12 | enabled: true, // this category is enabled by default 13 | readOnly: true // this category cannot be disabled 14 | }, 15 | analytics: {} 16 | }, 17 | 18 | language: { 19 | default: 'en', 20 | translations: { 21 | en: { 22 | consentModal: { 23 | title: 'We use cookies', 24 | description: 'Cookie modal description', 25 | acceptAllBtn: 'Accept all', 26 | acceptNecessaryBtn: 'Reject all', 27 | showPreferencesBtn: 'Manage Individual preferences' 28 | }, 29 | preferencesModal: { 30 | title: 'Manage cookie preferences', 31 | acceptAllBtn: 'Accept all', 32 | acceptNecessaryBtn: 'Reject all', 33 | savePreferencesBtn: 'Accept current selection', 34 | closeIconLabel: 'Close modal', 35 | sections: [ 36 | { 37 | title: 'Somebody said ... cookies?', 38 | description: 'I want one!' 39 | }, 40 | { 41 | title: 'Strictly Necessary cookies', 42 | description: 43 | 'These cookies are essential for the proper functioning of the website and cannot be disabled.', 44 | 45 | //this field will generate a toggle linked to the 'necessary' category 46 | linkedCategory: 'necessary' 47 | }, 48 | { 49 | title: 'Performance and Analytics', 50 | description: 51 | 'These cookies collect information about how you use our website. All of the data is anonymized and cannot be used to identify you.', 52 | linkedCategory: 'analytics' 53 | }, 54 | { 55 | title: 'More information', 56 | description: 57 | 'For any queries in relation to my policy on cookies and your choices, please contact us' 58 | } 59 | ] 60 | } 61 | } 62 | } 63 | } 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /pages/deletemyaccount.vue: -------------------------------------------------------------------------------- 1 | 17 | 64 | -------------------------------------------------------------------------------- /server/middleware/authContext.ts: -------------------------------------------------------------------------------- 1 | import { defineEventHandler, parseCookies, setCookie, getCookie } from 'h3'; 2 | import { serverSupabaseUser } from '#supabase/server'; 3 | import { AuthService } from '~/lib/services/auth.service'; 4 | 5 | import type { User } from '@supabase/supabase-js'; 6 | import type { FullDBUser } from '~~/lib/services/service.types'; 7 | 8 | // Explicitly type our context by 'Merging' our custom types with the H3EventContext (https://stackoverflow.com/a/76349232/95242) 9 | declare module 'h3' { 10 | interface H3EventContext { 11 | user?: User; // the Supabase User 12 | dbUser?: FullDBUser; // the corresponding Database User 13 | activeAccountId?: number; // the account ID that is active for the user 14 | } 15 | } 16 | 17 | export default defineEventHandler(async event => { 18 | if ( 19 | !(event.path.startsWith('/api/trpc') || event.path.startsWith('/api/note')) 20 | ) { 21 | return; // only apply middleware to working routes 22 | } 23 | 24 | const cookies = parseCookies(event); 25 | if (cookies && cookies['sb-access-token']) { 26 | const user = await serverSupabaseUser(event); 27 | if (user) { 28 | event.context.user = user; 29 | 30 | let dbUser = await AuthService.getFullUserBySupabaseId(user.id); 31 | 32 | if (!dbUser && user) { 33 | dbUser = await AuthService.createUser( 34 | user.id, 35 | user.user_metadata.full_name 36 | ? user.user_metadata.full_name 37 | : 'no name supplied', 38 | user.email ? user.email : 'no@email.supplied' 39 | ); 40 | console.log(`\n Created DB User \n ${JSON.stringify(dbUser)}\n`); 41 | } 42 | 43 | if (dbUser) { 44 | event.context.dbUser = dbUser; 45 | let activeAccountId; 46 | const preferredAccountId = getCookie( 47 | event, 48 | 'preferred-active-account-id' 49 | ); 50 | if ( 51 | preferredAccountId && 52 | dbUser?.memberships.find( 53 | m => m.account_id === +preferredAccountId && !m.pending 54 | ) 55 | ) { 56 | activeAccountId = +preferredAccountId; 57 | } else { 58 | const defaultActive = dbUser.memberships[0].account_id.toString(); 59 | setCookie(event, 'preferred-active-account-id', defaultActive, { 60 | expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365 * 10) 61 | }); 62 | activeAccountId = +defaultActive; 63 | } 64 | if (activeAccountId) { 65 | event.context.activeAccountId = activeAccountId; 66 | } 67 | } 68 | } 69 | } 70 | }); 71 | -------------------------------------------------------------------------------- /server/routes/webhook.post.ts: -------------------------------------------------------------------------------- 1 | import Stripe from 'stripe'; 2 | import { AccountService } from '~~/lib/services/account.service'; 3 | 4 | const config = useRuntimeConfig(); 5 | const stripe = new Stripe(config.stripeSecretKey, { apiVersion: '2023-10-16' }); 6 | 7 | export default defineEventHandler(async event => { 8 | const stripeSignature = getRequestHeader(event, 'stripe-signature'); 9 | if (!stripeSignature) { 10 | throw createError({ 11 | statusCode: 400, 12 | statusMessage: 'Webhook Error: No stripe signature in header' 13 | }); 14 | } 15 | 16 | const rawBody = await readRawBody(event); 17 | if (!rawBody) { 18 | throw createError({ 19 | statusCode: 400, 20 | statusMessage: 'Webhook Error: No body' 21 | }); 22 | } 23 | let stripeEvent: Stripe.Event; 24 | 25 | try { 26 | stripeEvent = stripe.webhooks.constructEvent( 27 | rawBody, 28 | stripeSignature, 29 | config.stripeEndpointSecret 30 | ); 31 | } catch (err) { 32 | console.log(err); 33 | throw createError({ 34 | statusCode: 400, 35 | statusMessage: `Error validating Webhook Event` 36 | }); 37 | } 38 | 39 | if ( 40 | stripeEvent.type && 41 | stripeEvent.type.startsWith('customer.subscription') 42 | ) { 43 | console.log(`****** Web Hook Recieved (${stripeEvent.type}) ******`); 44 | 45 | let subscription = stripeEvent.data.object as Stripe.Subscription; 46 | if (subscription.status == 'active') { 47 | const sub_item = subscription.items.data.find( 48 | item => item?.object && item?.object == 'subscription_item' 49 | ); 50 | 51 | const stripe_product_id = sub_item?.plan.product?.toString(); // TODO - is the product ever a product object and in that case should I check for deleted? 52 | if (!stripe_product_id) { 53 | throw createError({ 54 | statusCode: 400, 55 | statusMessage: `Error validating Webhook Event` 56 | }); 57 | } 58 | 59 | let current_period_ends: Date = new Date( 60 | subscription.current_period_end * 1000 61 | ); 62 | current_period_ends.setDate( 63 | current_period_ends.getDate() + config.subscriptionGraceDays 64 | ); 65 | 66 | console.log( 67 | `updating stripe sub details subscription.current_period_end:${subscription.current_period_end}, subscription.id:${subscription.id}, stripe_product_id:${stripe_product_id}` 68 | ); 69 | AccountService.updateStripeSubscriptionDetailsForAccount( 70 | subscription.customer.toString(), 71 | subscription.id, 72 | current_period_ends, 73 | stripe_product_id 74 | ); 75 | } 76 | } 77 | return `handled ${stripeEvent.type}.`; 78 | }); 79 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "postgresql" 10 | url = env("DATABASE_URL") 11 | } 12 | 13 | model User { 14 | id Int @id @default(autoincrement()) 15 | supabase_uid String 16 | email String 17 | display_name String? 18 | 19 | memberships Membership[] 20 | 21 | @@map("users") 22 | } 23 | 24 | enum ACCOUNT_ACCESS { 25 | READ_ONLY 26 | READ_WRITE 27 | ADMIN 28 | OWNER 29 | } 30 | 31 | model Membership { 32 | id Int @id @default(autoincrement()) 33 | user_id Int 34 | account_id Int 35 | account Account @relation(fields: [account_id], references: [id]) 36 | user User @relation(fields: [user_id], references: [id]) 37 | access ACCOUNT_ACCESS @default(READ_ONLY) 38 | pending Boolean @default(false) 39 | 40 | @@map("membership") 41 | @@unique([user_id, account_id]) 42 | } 43 | 44 | model Account { 45 | id Int @id @default(autoincrement()) 46 | name String 47 | current_period_ends DateTime @default(now()) 48 | features String[] 49 | plan_id Int 50 | plan Plan @relation(fields: [plan_id], references: [id]) 51 | plan_name String 52 | members Membership[] 53 | notes Note[] 54 | max_notes Int @default(100) 55 | stripe_subscription_id String? 56 | stripe_customer_id String? 57 | max_members Int @default(1) 58 | join_password String @unique 59 | ai_gen_max_pm Int @default(7) 60 | ai_gen_count Int @default(0) 61 | 62 | @@map("account") 63 | } 64 | 65 | model Plan { 66 | id Int @id @default(autoincrement()) 67 | name String @unique 68 | features String[] 69 | accounts Account[] 70 | max_notes Int @default(100) 71 | stripe_product_id String? 72 | max_members Int @default(1) 73 | ai_gen_max_pm Int @default(7) 74 | 75 | @@map("plan") 76 | } 77 | 78 | model Note { 79 | id Int @id @default(autoincrement()) 80 | account_id Int? 81 | account Account? @relation(fields: [account_id], references: [id]) 82 | 83 | note_text String 84 | 85 | @@map("note") 86 | } 87 | -------------------------------------------------------------------------------- /test/TEST.md: -------------------------------------------------------------------------------- 1 | # Manual test for Admin Functions Scenario 2 | ## Pre-req 3 | - Site configured for free plan 4 | - Neither User1 or User2 are present in DB 5 | ## Main Flow (Happy Path) 6 | This scenario covers most of the Site Auth and Account admin functions. 7 | 8 | (User 1) 9 | - Front page - Get Started 10 | - Signup with google - should drop to dashboard 11 | - Check account page via nav 12 | - Go to pricing page via nav 13 | - Click on 'Subscribe' button under team account 14 | - Fill in Credit card details and sub in Stripe - Should come back to Dashboard page (comes to success page but no customer info??) 15 | - Add a Note or 2 in the Dashboard page - make it clear user1 has entered 16 | - Check Account view - Should be OWNER of this account, max members should be updated to 10 17 | - Update Team account Name using button 18 | - Copy Join Link 19 | - Signout 20 | 21 | (User 2) 22 | - Open Join Link - Should prompt for signup to new account name 23 | - Signup with email/password - should drop to dashboard (some fucking bullshit error with signin + no avatar link + how the fuck to deal with non confirmed emails) 24 | - Open join link again - should prompt to Join (Note, doing navigateTo and saving a returnURL seems to be difficult in Nuxt) 25 | - Click Join - should redirect to dashboard 26 | - Check 'Switch to' accounts, team account should be (pending) and not clickable 27 | - Sign out 28 | 29 | (User 1) 30 | - Front Page - Sign in - Note Signin page subtly different to signup page, no password conf and no 'if you proceed' warning 31 | - Sign in with google - Should drop to dashboard page 32 | - navigate to Account page 33 | - Look at members, should show User 2 as 'Pending' with approve/reject buttons 34 | - Click approve, should update user item in list and display 'Upgrade to read/write' and 'Delete' 35 | 36 | (User 2) 37 | - Signin -> Dashboard should now show notes but no 'Delete' or 'Add' buttons 38 | 39 | (User 1) 40 | - Signin -> Dashboard 41 | - Navigate to Account Page 42 | - Click 'Upgrade to read/write' - Should update user and now show 'Upgrade to Admin' button 43 | - sign out 44 | 45 | (User 2) 46 | - Signin -> Dashboard - should see 'Add' button now 47 | - Add a Note 48 | - Sign Out 49 | 50 | (User 1) 51 | - Signin -> Dashboard 52 | - Navigate to Account Page 53 | - Click 'Upgrade to Admin' - Should see just the 'Delete' button now 54 | - sign out 55 | 56 | (User 2) 57 | - Signin -> Dashboard - should now see 'Delete' button on notes 58 | - Click on 'Delete' for an existing Note 59 | - Go to Account Page - should now see 'Claim ownership' button next to access 60 | - Click on 'Claim Ownership' - Button should dissappear and member list should be updated - Delete button should be visible against 61 | User 1 62 | - Click 'Delete' for user 1 63 | - You are now king of the world 64 | - navigate to Notes, verify you can see/crud notes on dashboard. 65 | 66 | ## Unchecked things 67 | - Admin can approve pending membership 68 | 69 | ## Alternate Flow (Pricing First) 70 | - Front Page - Pricing 71 | - get started for free (TODO - should be button to go to signup under free plan) -------------------------------------------------------------------------------- /test/account.store.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, afterEach, beforeEach, it, vi } from 'vitest'; 2 | import { useAccountStore } from '../stores/account.store'; 3 | import { setActivePinia, createPinia } from 'pinia'; 4 | 5 | import type { FullDBUser } from '~/lib/services/service.types'; 6 | 7 | const fakeInitAccountStoreAdmin = (accountStore: any) => { 8 | const dbUser: FullDBUser = { 9 | id: 1, 10 | name: 'John Doe', 11 | memberships: [ 12 | { account_id: 1, access: 'ADMIN' }, 13 | { account_id: 2, access: 'READ_ONLY' } 14 | ] 15 | } as any; 16 | accountStore.dbUser = dbUser; 17 | accountStore.activeAccountId = 1; 18 | }; 19 | 20 | describe('Account Store', async () => { 21 | beforeEach(() => { 22 | setActivePinia(createPinia()); 23 | }); 24 | 25 | it('should initialize the store', async () => { 26 | // stub the useNuxtApp function with a mock client 27 | vi.stubGlobal('useNuxtApp', () => ({ 28 | $client: { 29 | auth: { 30 | getDBUser: { 31 | query: () => ({ 32 | dbUser: { 33 | id: 1, 34 | name: 'John Doe', 35 | memberships: [] 36 | } 37 | }) 38 | } 39 | }, 40 | account: { 41 | getActiveAccountId: { 42 | query: () => ({ activeAccountId: 1 }) 43 | } 44 | } 45 | } 46 | })); 47 | 48 | const accountStore = useAccountStore(); 49 | 50 | // method under test 51 | await accountStore.init(); 52 | 53 | expect(accountStore.dbUser).toEqual({ 54 | id: 1, 55 | name: 'John Doe', 56 | memberships: [] 57 | }); 58 | 59 | expect(accountStore.activeAccountId).toEqual(1); 60 | }); 61 | 62 | it('should get active account members', async () => { 63 | // stub the useNuxtApp function with a mock client 64 | vi.stubGlobal('useNuxtApp', () => ({ 65 | $client: { 66 | account: { 67 | getAccountMembers: { 68 | useQuery: () => ({ 69 | data: { value: { memberships: [new Object() as any] } } 70 | }) 71 | } 72 | } 73 | } 74 | })); 75 | 76 | const accountStore = useAccountStore(); 77 | fakeInitAccountStoreAdmin(accountStore); 78 | 79 | // method under test 80 | await accountStore.getActiveAccountMembers(); 81 | 82 | expect(accountStore.activeAccountMembers.length).toEqual(1); 83 | }); 84 | 85 | it('should get an active membership', async () => { 86 | const accountStore = useAccountStore(); 87 | fakeInitAccountStoreAdmin(accountStore); 88 | 89 | expect(accountStore.activeMembership).toEqual({ 90 | account_id: 1, 91 | access: 'ADMIN' 92 | }); 93 | }); 94 | 95 | it('should signout', async () => { 96 | const accountStore = useAccountStore(); 97 | fakeInitAccountStoreAdmin(accountStore); 98 | 99 | await accountStore.signout(); 100 | 101 | expect(accountStore.dbUser).toBeNull(); 102 | expect(accountStore.activeAccountId).toBeNull(); 103 | expect(accountStore.activeAccountMembers.length).toEqual(0); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /pages/join/[join_password].vue: -------------------------------------------------------------------------------- 1 | 30 | 75 | -------------------------------------------------------------------------------- /pages/dashboard.vue: -------------------------------------------------------------------------------- 1 | 32 |