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)}`);
+ }
{{ user?.user_metadata.full_name }}'s Notes Dashboard
{{ note.note_text }}
+
+
+
+
+
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;