diff --git a/README.md b/README.md index 1f5d30b..71423be 100644 --- a/README.md +++ b/README.md @@ -91,4 +91,13 @@ Then I manually hand coded the schema.prisma file based on something else I alre npx prisma db push npm install @prisma/client --save-dev npx prisma generate -``` \ No newline at end of file +``` + +# TODO +- add role to membership and have methods for changing role, making sure one owner etc (done) +- remove @unique so users can have multiple accounts (done) +- add concept of 'current' account for user.. maybe put account on context or session. maybe just on DB...'current' boolean on membership? +- add max_notes property to plan and account as an example of a 'limit' property (done) +- add spinup script somehow to create plans???.... should I use some sort of generator like sidebase? +- team invitation thingy +- integration with stripe including web hooks. \ No newline at end of file diff --git a/lib/services/user.account.service.ts b/lib/services/user.account.service.ts new file mode 100644 index 0000000..e5dfdcf --- /dev/null +++ b/lib/services/user.account.service.ts @@ -0,0 +1,158 @@ +import { ACCOUNT_ACCESS, PrismaClient } from '@prisma/client'; +import { UtilService } from './util.service'; + +const TRIAL_PLAN_NAME = '3 Month Trial'; // TODO - some sort of config.. this will change for every use of the boilerplate + +export default class UserAccountService { + private prisma: PrismaClient; + + constructor( prisma: PrismaClient) { + this.prisma = prisma; + } + + async getUserBySupabaseId(supabase_uid: string) { + return this.prisma.user.findFirst({ where: { supabase_uid }, include: { memberships: true } }); + } + + async getUserById(user_id: number) { + return this.prisma.user.findFirstOrThrow({ where: { id: user_id }, include: { memberships: true } }); + } + + async createUser( supabase_uid: string, display_name: string ) { + const trialPlan = await this.prisma.plan.findFirstOrThrow({ where: { name: TRIAL_PLAN_NAME}}); + return this.prisma.user.create({ + data:{ + supabase_uid: supabase_uid, + display_name: display_name, + memberships: { + create: { + account: { + create: { + plan_id: trialPlan.id, + name: display_name, + features: trialPlan.features, //copy in features from the plan, plan_id is a loose association and settings can change independently + current_period_ends: UtilService.addMonths(new Date(),3), + max_notes: trialPlan.max_notes, + } + }, + access: ACCOUNT_ACCESS.OWNER + } + } + }, + include: { memberships: true }, + }); + } + + async deleteUser(user_id: number) { + return this.prisma.user.delete({ where: { id: user_id } }); + } + + async joinUserToAccount(user_id: number, account_id: number) { + return this.prisma.membership.create({ + data: { + user_id: user_id, + account_id: account_id + } + }); + } + + async changeAccountPlan(account_id: number, plan_id: number) { + const plan = await this.prisma.plan.findFirstOrThrow({ where: {id: plan_id}}); + return this.prisma.account.update({ + where: { id: account_id}, + data: { + plan_id: plan_id, + features: plan.features, + max_notes: plan.max_notes, + } + }); + } + + + // Claim ownership of an account. + // User must already be an ADMIN for the Account + // Existing OWNER memberships are downgraded to ADMIN + // In future, some sort of Billing/Stripe tie in here e.g. changing email details on the Account, not sure. + async claimOwnershipOfAccount(user_id: number, account_id: number) { + const membership = await this.prisma.membership.findUniqueOrThrow({ + where: { + user_id_account_id: { + user_id: user_id, + account_id: account_id, + } + }, + }); + + if (membership.access === ACCOUNT_ACCESS.OWNER) { + return; // already owner + } else if (membership.access !== ACCOUNT_ACCESS.ADMIN) { + throw new Error('UNAUTHORISED: only Admins can claim ownership'); + } + + const existing_owner_memberships = await this.prisma.membership.findMany({ + where: { + account_id: account_id, + access: ACCOUNT_ACCESS.OWNER, + }, + }); + + for(const existing_owner_membership of existing_owner_memberships) { + await this.prisma.membership.update({ + where: { + user_id_account_id: { + user_id: existing_owner_membership.user_id, + account_id: account_id, + } + }, + data: { + access: ACCOUNT_ACCESS.ADMIN, // Downgrade OWNER to ADMIN + } + }); + } + + // finally update the ADMIN member to OWNER + return this.prisma.membership.update({ + where: { + user_id_account_id: { + user_id: user_id, + account_id: account_id, + } + }, + data: { + access: ACCOUNT_ACCESS.OWNER, + } + }); + } + + // Upgrade access of a membership. Cannot use this method to upgrade to or downgrade from OWNER access + async changeUserAccessWithinAccount(user_id: number, account_id: number, access: ACCOUNT_ACCESS) { + if (access === ACCOUNT_ACCESS.OWNER) { + throw new Error('UNABLE TO UPDATE MEMBERSHIP: use claimOwnershipOfAccount method to change ownership'); + } + + const membership = await this.prisma.membership.findUniqueOrThrow({ + where: { + user_id_account_id: { + user_id: user_id, + account_id: account_id, + } + }, + }); + + if (membership.access === ACCOUNT_ACCESS.OWNER) { + throw new Error('UNABLE TO UPDATE MEMBERSHIP: use claimOwnershipOfAccount method to change ownership'); + } + + return this.prisma.membership.update({ + where: { + user_id_account_id: { + user_id: user_id, + account_id: account_id, + } + }, + data: { + access: access, + } + }); + } +} diff --git a/lib/services/user.service.ts b/lib/services/user.service.ts deleted file mode 100644 index 831e88b..0000000 --- a/lib/services/user.service.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { PrismaClient } from '@prisma/client'; -import { UtilService } from './util.service'; - -const TRIAL_PLAN_NAME = '3 Month Trial'; // TODO - some sort of config.. this will change for every use of the boilerplate - -export default class UserService { - private prisma: PrismaClient; - - constructor( prisma: PrismaClient) { - this.prisma = prisma; - } - - async getUserBySupabaseId(supabase_uid: string) { - return this.prisma.user.findFirst({ where: { supabase_uid }, include: { membership: true } }); - } - - async getUserById(id: number) { - return this.prisma.user.findFirstOrThrow({ where: { id }, include: { membership: true } }); - } - - async createUser( supabase_uid: string, display_name: string ) { - const trialPlan = await this.prisma.plan.findFirstOrThrow({ where: { name: TRIAL_PLAN_NAME}}); - return this.prisma.user.create({ - data:{ - supabase_uid: supabase_uid, - display_name: display_name, - membership: { - create: { - account: { - create: { - plan_id: trialPlan.id, - name: display_name, - features: trialPlan.features, //copy in features from the plan, plan_id is a loose association and settings can change independently - current_period_ends: UtilService.addMonths(new Date(),3), - } - } - } - } - }, - include: { membership: true }, - }); - } - - async deleteUser(id: number) { - return this.prisma.user.delete({ where: { id } }); - } -} diff --git a/pages/dashboard.vue b/pages/dashboard.vue index 4229cd8..fc65a96 100644 --- a/pages/dashboard.vue +++ b/pages/dashboard.vue @@ -4,13 +4,38 @@ middleware: ['auth'], }); - const { $client } = useNuxtApp() - const { data: notes } = await $client.notes.useQuery({ text: 'client' }) + const { $client } = useNuxtApp(); + const { data: notes } = await $client.notes.getForCurrentUser.useQuery(); + + async function changeAccountPlan(){ + const { data: account } = await $client.userAccount.changeAccountPlan.useQuery(); + console.log(`account with updated plan: ${JSON.stringify(account)}`); + } + + async function joinUserToAccount(){ + const { data: membership } = await $client.userAccount.joinUserToAccount.useQuery(); + console.log(`added membership on current account: ${JSON.stringify(membership)}`); + } + + async function changeUserAccessWithinAccount(){ + const { data: membership } = await $client.userAccount.changeUserAccessWithinAccount.useQuery(); + console.log(`updated membership on current account: ${JSON.stringify(membership)}`); + } + + async function claimOwnershipOfAccount(){ + const { data: membership } = await $client.userAccount.claimOwnershipOfAccount.useQuery(); + console.log(`updated membership on current account: ${JSON.stringify(membership)}`); + } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c23bd3e..8c6d269 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -15,19 +15,28 @@ model User { supabase_uid String display_name String? - membership Membership? + memberships Membership[] @@map("users") } +enum ACCOUNT_ACCESS { + READ_ONLY + READ_WRITE + ADMIN + OWNER +} + model Membership { id Int @id @default(autoincrement()) - user_id Int @unique + user_id Int account_id Int account Account @relation(fields: [account_id], references: [id]) user User @relation(fields: [user_id], references: [id]) + access ACCOUNT_ACCESS @default(READ_ONLY) @@map("membership") + @@unique([user_id, account_id]) } model Account { @@ -39,6 +48,7 @@ model Account { plan Plan @relation(fields: [plan_id], references: [id]) members Membership[] notes Note[] + max_notes Int @default(100) @@map("account") } @@ -48,6 +58,7 @@ model Plan { name String features String[] accounts Account[] + max_notes Int @default(100) @@map("plan") } diff --git a/server/api/trpc/[trpc].ts b/server/api/trpc/[trpc].ts index 0d9e5fc..61a1c5e 100644 --- a/server/api/trpc/[trpc].ts +++ b/server/api/trpc/[trpc].ts @@ -3,26 +3,15 @@ * On a bigger app, you will probably want to split this file up into multiple files. */ import { createNuxtApiHandler } from 'trpc-nuxt' -import { z } from 'zod' import { publicProcedure, router } from '~/server/trpc/trpc' import { createContext } from '~~/server/trpc/context'; -import NotesService from '~~/lib/services/notes.service'; +import { notesRouter } from '~~/server/trpc/routers/notes.router'; +import { userAccountRouter } from '~~/server/trpc/routers/user.account.router'; export const appRouter = router({ - notes: publicProcedure - .input( - z.object({ - text: z.string().nullish(), - }), - ) - .query(async ({ ctx, input }) => { - const notesService = new NotesService(ctx.prisma); - const notes = await notesService.getNotesForAccountId(ctx.dbUser.membership?.account_id); - return { - notes, - } - }), + notes: notesRouter, + userAccount: userAccountRouter, }) // export only the type definition of the API diff --git a/server/trpc/context.ts b/server/trpc/context.ts index 7b3b257..5d59eed 100644 --- a/server/trpc/context.ts +++ b/server/trpc/context.ts @@ -4,7 +4,7 @@ import { H3Event } from 'h3'; import { serverSupabaseClient } from '#supabase/server'; import SupabaseClient from '@supabase/supabase-js/dist/module/SupabaseClient'; import { User } from '@supabase/supabase-js'; -import UserService from '~~/lib/services/user.service'; +import UserAccountService from '~~/lib/services/user.account.service'; let prisma: PrismaClient | undefined let supabase: SupabaseClient | undefined @@ -22,7 +22,7 @@ export async function createContext(event: H3Event){ prisma = new PrismaClient() } if (!dbUser && user) { - const userService = new UserService(prisma); + const userService = new UserAccountService(prisma); dbUser = await userService.getUserBySupabaseId(user.id); if (!dbUser && user) { diff --git a/server/trpc/routers/notes.router.ts b/server/trpc/routers/notes.router.ts new file mode 100644 index 0000000..6e62162 --- /dev/null +++ b/server/trpc/routers/notes.router.ts @@ -0,0 +1,14 @@ +import NotesService from '~~/lib/services/notes.service'; +import { protectedProcedure, router } from '../trpc' + +export const notesRouter = router({ + getForCurrentUser: protectedProcedure + .query(async ({ ctx }) => { + const notesService = new NotesService(ctx.prisma); + console.log(`fetching notes for account_id: ${ctx.dbUser.memberships[0].account_id}`); + const notes = await notesService.getNotesForAccountId(ctx.dbUser.memberships[0].account_id); + return { + notes, + } + }), +}) \ No newline at end of file diff --git a/server/trpc/routers/user.account.router.ts b/server/trpc/routers/user.account.router.ts new file mode 100644 index 0000000..f2c3037 --- /dev/null +++ b/server/trpc/routers/user.account.router.ts @@ -0,0 +1,38 @@ +import UserAccountService from '~~/lib/services/user.account.service'; +import { protectedProcedure, router } from '../trpc' +import { ACCOUNT_ACCESS } from '@prisma/client'; + +export const userAccountRouter = router({ + changeAccountPlan: protectedProcedure + .query(async ({ ctx }) => { + const uaService = new UserAccountService(ctx.prisma); + const account = await uaService.changeAccountPlan(ctx.dbUser.memberships[0].account_id, 2); // todo - plan should be an in put param + return { + account, + } + }), + joinUserToAccount: protectedProcedure + .query(async ({ ctx }) => { + const uaService = new UserAccountService(ctx.prisma); + const membership = await uaService.joinUserToAccount(ctx.dbUser.id, 5); // todo - account should be an input param + return { + membership, + } + }), + changeUserAccessWithinAccount: protectedProcedure // TODO - should be protectedAdmin (i.e. ctx.dbUser.id should be admin within the session account) + .query(async ({ ctx }) => { + const uaService = new UserAccountService(ctx.prisma); + const membership = await uaService.changeUserAccessWithinAccount(3, 5, ACCOUNT_ACCESS.ADMIN); // todo - member and access should be an input param (from UI) account should be the session account + return { + membership, + } + }), + claimOwnershipOfAccount: protectedProcedure // TODO - should be protectedAdmin (i.e. ctx.dbUser.id should be admin within the session account) + .query(async ({ ctx }) => { + const uaService = new UserAccountService(ctx.prisma); + const membership = await uaService.claimOwnershipOfAccount(3, 5); // todo - member should be an input param (from UI) account should be the session account + return { + membership, + } + }), +}) \ No newline at end of file diff --git a/server/trpc/trpc.ts b/server/trpc/trpc.ts index 8fceee3..833ca92 100644 --- a/server/trpc/trpc.ts +++ b/server/trpc/trpc.ts @@ -7,15 +7,29 @@ * @see https://trpc.io/docs/v10/router * @see https://trpc.io/docs/v10/procedures */ -import { initTRPC } from '@trpc/server' +import { initTRPC, TRPCError } from '@trpc/server' import { Context } from './context'; const t = initTRPC.context().create() +/** + * auth middleware + **/ +const isAuthed = t.middleware(({ next, ctx }) => { + if (!ctx.user) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + return next({ + ctx: { + user: ctx.user, + }, + }); +}); + /** * Unprotected procedure **/ export const publicProcedure = t.procedure; - +export const protectedProcedure = t.procedure.use(isAuthed); export const router = t.router; export const middleware = t.middleware;