From f90164d2b94de9355b512bbe034eb26abe8b1b4e Mon Sep 17 00:00:00 2001 From: Dustin Do Date: Sat, 8 Jun 2024 16:00:36 +0700 Subject: [PATCH] feat(api): add budget API routes (#18) ## Added APIs - `GET /budgets?permission` - `POST /budgets` - `GET /budgets/:budgetId` - `PUT /budgets/:budgetId` - `DELETE /budgets/:budgetId` --- apps/api/v1/index.ts | 2 + apps/api/v1/routes/budgets.ts | 121 +++++++++++ .../v1/services/budget-invitation.service.ts | 64 ++++++ apps/api/v1/services/budget.service.ts | 201 ++++++++++++++++++ apps/api/v1/validation/budget.zod.ts | 34 +++ apps/api/v1/validation/index.ts | 1 + 6 files changed, 423 insertions(+) create mode 100644 apps/api/v1/routes/budgets.ts create mode 100644 apps/api/v1/services/budget-invitation.service.ts create mode 100644 apps/api/v1/services/budget.service.ts create mode 100644 apps/api/v1/validation/budget.zod.ts diff --git a/apps/api/v1/index.ts b/apps/api/v1/index.ts index 7d295ab3..d00028b1 100644 --- a/apps/api/v1/index.ts +++ b/apps/api/v1/index.ts @@ -1,6 +1,7 @@ import { Hono } from 'hono' import { authMiddleware } from './middlewares/auth' import authApp from './routes/auth' +import budgetsApp from './routes/budgets' import usersApp from './routes/users' import walletsApp from './routes/wallets' @@ -9,5 +10,6 @@ export const hono = new Hono() hono.use('*', authMiddleware) hono.route('/auth', authApp) +hono.route('/budgets', budgetsApp) hono.route('/users', usersApp) hono.route('/wallets', walletsApp) diff --git a/apps/api/v1/routes/budgets.ts b/apps/api/v1/routes/budgets.ts new file mode 100644 index 00000000..18fc2de8 --- /dev/null +++ b/apps/api/v1/routes/budgets.ts @@ -0,0 +1,121 @@ +import { BudgetUserPermissionSchema } from '@/prisma/generated/zod' +import { zValidator } from '@hono/zod-validator' +import { Hono } from 'hono' +import { z } from 'zod' +import { getAuthUserStrict } from '../middlewares/auth' +import { + canUserCreateBudget, + canUserDeleteBudget, + canUserReadBudget, + canUserUpdateBudget, + createBudget, + deleteBudget, + findBudget, + findBudgetsOfUser, + updateBudget, +} from '../services/budget.service' +import { zCreateBudget, zUpdateBudget } from '../validation' + +const router = new Hono() + +const zBudgetIdParamValidator = zValidator( + 'param', + z.object({ + budgetId: z.string(), + }), +) + +router.get( + '/', + zValidator( + 'query', + z.object({ + permission: BudgetUserPermissionSchema.optional(), + }), + ), + async (c) => { + const user = getAuthUserStrict(c) + const { permission } = c.req.valid('query') + + const budgets = await findBudgetsOfUser({ user, permission }) + + return c.json(budgets) + }, +) + +router.post('/', zValidator('json', zCreateBudget), async (c) => { + const user = getAuthUserStrict(c) + + if (!(await canUserCreateBudget({ user }))) { + return c.json({ message: 'User cannot create budget' }, 403) + } + + const createBudgetData = c.req.valid('json') + + const budget = await createBudget({ user, data: createBudgetData }) + + return c.json(budget, 201) +}) + +router.get('/:budgetId', zBudgetIdParamValidator, async (c) => { + const user = getAuthUserStrict(c) + const { budgetId } = c.req.valid('param') + + const budget = await findBudget({ budgetId }) + + if (!(budget && (await canUserReadBudget({ user, budget })))) { + return c.json(null, 404) + } + + return c.json(budget) +}) + +router.put( + '/:budgetId', + zBudgetIdParamValidator, + zValidator('json', zUpdateBudget), + async (c) => { + const user = getAuthUserStrict(c) + const { budgetId } = c.req.valid('param') + + const budget = await findBudget({ budgetId }) + + if (!(budget && (await canUserReadBudget({ user, budget })))) { + return c.json(null, 404) + } + + if (!(await canUserUpdateBudget({ user, budget }))) { + return c.json({ message: 'User cannot update budget' }, 403) + } + + const updateBudgetData = c.req.valid('json') + + const updatedBudget = await updateBudget({ + budgetId, + data: updateBudgetData, + }) + + return c.json(updatedBudget) + }, +) + +router.delete('/:budgetId', zBudgetIdParamValidator, async (c) => { + const user = getAuthUserStrict(c) + const { budgetId } = c.req.valid('param') + + const budget = await findBudget({ budgetId }) + + if (!(budget && (await canUserReadBudget({ user, budget })))) { + return c.json(null, 404) + } + + if (!(await canUserDeleteBudget({ user, budget }))) { + return c.json({ message: 'User cannot delete budget' }, 403) + } + + await deleteBudget({ budgetId }) + + return c.json(budget) +}) + +export default router diff --git a/apps/api/v1/services/budget-invitation.service.ts b/apps/api/v1/services/budget-invitation.service.ts new file mode 100644 index 00000000..43e1853d --- /dev/null +++ b/apps/api/v1/services/budget-invitation.service.ts @@ -0,0 +1,64 @@ +import prisma from '@/lib/prisma' +import { + type Budget, + type BudgetUserInvitation, + BudgetUserPermission, + type User, +} from '@prisma/client' +import { isUserBudgetOwner } from './budget.service' + +export async function canUserInviteUserToBudget({ + user, + budget, +}: { + user: User + budget: Budget +}): Promise { + return isUserBudgetOwner({ user, budget }) +} + +export async function inviteUserToBudget({ + inviter, + budget, + email, + permission = BudgetUserPermission.MEMBER, +}: { + inviter: User + budget: Budget + email: string + permission?: BudgetUserPermission +}) { + // Check if inviter has permission to invite user + if (!(await canUserInviteUserToBudget({ user: inviter, budget }))) { + throw new Error( + 'User does not have permission to invite users to this budget', + ) + } + + let invitation: BudgetUserInvitation | null = + await prisma.budgetUserInvitation.findFirst({ + where: { + budgetId: budget.id, + email, + }, + }) + + if (invitation) { + return invitation + } + + invitation = await prisma.budgetUserInvitation.create({ + data: { + email, + permission, + budgetId: budget.id, + createdByUserId: inviter.id, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days + token: Math.random().toString(36).substring(2), // uuid-like + }, + }) + + // TODO: Send email to invitee + + return invitation +} diff --git a/apps/api/v1/services/budget.service.ts b/apps/api/v1/services/budget.service.ts new file mode 100644 index 00000000..9401fe2d --- /dev/null +++ b/apps/api/v1/services/budget.service.ts @@ -0,0 +1,201 @@ +import prisma from '@/lib/prisma' +import { type Budget, BudgetUserPermission, type User } from '@prisma/client' +import type { CreateBudget, UpdateBudget } from '../validation' +import { inviteUserToBudget } from './budget-invitation.service' + +export async function canUserCreateBudget({ + // biome-ignore lint/correctness/noUnusedVariables: + user, +}: { + user: User +}): Promise { + return true +} + +export async function canUserReadBudget({ + user, + budget, +}: { + user: User + budget: Budget +}): Promise { + return isUserBudgetMember({ user, budget }) +} + +export async function canUserUpdateBudget({ + user, + budget, +}: { + user: User + budget: Budget +}): Promise { + return isUserBudgetOwner({ user, budget }) +} + +export async function canUserDeleteBudget({ + user, + budget, +}: { + user: User + budget: Budget +}): Promise { + return isUserBudgetOwner({ user, budget }) +} + +export async function isUserBudgetMember({ + user, + budget, +}: { + user: User + budget: Budget +}): Promise { + const budgetUser = await prisma.budgetUser.findUnique({ + where: { + // biome-ignore lint/style/useNamingConvention: + userId_budgetId: { + userId: user.id, + budgetId: budget.id, + }, + }, + }) + + return !!budgetUser +} + +export async function isUserBudgetOwner({ + user, + budget, +}: { + user: User + budget: Budget +}): Promise { + const budgetUser = await prisma.budgetUser.findUnique({ + where: { + // biome-ignore lint/style/useNamingConvention: + userId_budgetId: { + userId: user.id, + budgetId: budget.id, + }, + permission: BudgetUserPermission.OWNER, + }, + }) + + return !!budgetUser +} + +export async function findBudget({ budgetId }: { budgetId: string }) { + return prisma.budget.findUnique({ + where: { id: budgetId }, + }) +} + +export async function createBudget({ + user, + data, +}: { + user: User + data: CreateBudget +}) { + const { + name, + type, + description, + preferredCurrency, + period, + inviteeEmails = [], + } = data + + const budget = await prisma.budget.create({ + data: { + name, + type, + description, + preferredCurrency, + periodConfig: { + create: { + type: period.type, + amount: period.amount, + startDate: period.startDate, + endDate: period.endDate, + }, + }, + budgetUsers: { + create: { + userId: user.id, + permission: BudgetUserPermission.OWNER, + }, + }, + }, + }) + + // Invite users as members + try { + await Promise.all( + inviteeEmails.map((email) => + inviteUserToBudget({ + inviter: user, + budget, + email, + permission: BudgetUserPermission.MEMBER, + }), + ), + ) + } catch (error) { + await deleteBudget({ budgetId: budget.id }) + throw error + } + + return budget +} + +export async function updateBudget({ + budgetId, + data, +}: { + budgetId: string + data: UpdateBudget +}) { + const { name, description, type, preferredCurrency, period } = data + + const budget = await prisma.budget.update({ + where: { id: budgetId }, + data: { + name, + description, + type, + preferredCurrency, + periodConfig: { + update: { + type: period?.type, + amount: period?.amount, + startDate: period?.startDate, + endDate: period?.endDate, + }, + }, + }, + }) + + return budget +} + +export async function deleteBudget({ budgetId }: { budgetId: string }) { + await prisma.budget.delete({ + where: { id: budgetId }, + }) +} + +export async function findBudgetsOfUser({ + user, + permission, +}: { user: User; permission?: BudgetUserPermission }) { + return prisma.budget.findMany({ + where: { + budgetUsers: { + some: { + permission: permission ?? undefined, + userId: user.id, + }, + }, + }, + }) +} diff --git a/apps/api/v1/validation/budget.zod.ts b/apps/api/v1/validation/budget.zod.ts new file mode 100644 index 00000000..99d1ed3c --- /dev/null +++ b/apps/api/v1/validation/budget.zod.ts @@ -0,0 +1,34 @@ +import { + BudgetPeriodTypeSchema, + BudgetTypeSchema, +} from '@/prisma/generated/zod' +import { z } from 'zod' + +export const zCreateBudget = z.object({ + name: z.string(), + description: z.string().optional(), + preferredCurrency: z.string(), + type: BudgetTypeSchema, + inviteeEmails: z.array(z.string().email()).optional(), + period: z.object({ + type: BudgetPeriodTypeSchema, + amount: z.number().min(0), + startDate: z.date().optional(), + endDate: z.date().optional(), + }), +}) +export type CreateBudget = z.infer + +export const zUpdateBudget = z.object({ + name: z.string().optional(), + description: z.string().optional(), + preferredCurrency: z.string().optional(), + type: BudgetTypeSchema.optional(), + period: z.object({ + type: BudgetPeriodTypeSchema.optional(), + amount: z.number().min(0).optional(), + startDate: z.date().optional(), + endDate: z.date().optional(), + }), +}) +export type UpdateBudget = z.infer diff --git a/apps/api/v1/validation/index.ts b/apps/api/v1/validation/index.ts index 70eb364b..95a3697f 100644 --- a/apps/api/v1/validation/index.ts +++ b/apps/api/v1/validation/index.ts @@ -1,3 +1,4 @@ export * from './auth.zod' +export * from './budget.zod' export * from './user.zod' export * from './wallet.zod'