diff --git a/apps/api/v1/routes/budgets.ts b/apps/api/v1/routes/budgets.ts index 18fc2de8..6d8dc2e8 100644 --- a/apps/api/v1/routes/budgets.ts +++ b/apps/api/v1/routes/budgets.ts @@ -2,7 +2,18 @@ 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 { getAuthUser, getAuthUserStrict } from '../middlewares/auth' +import { + canUserDeleteBudgetInvitation, + canUserGenerateBudgetInvitation, + canUserInviteUserToBudget, + deleteBudgetInvitation, + findBudgetInvitation, + generateBudgetInvitation, + inviteUserToBudget, + respondToBudgetInvitation, + verifyBudgetInvitationToken, +} from '../services/budget-invitation.service' import { canUserCreateBudget, canUserDeleteBudget, @@ -14,14 +25,22 @@ import { findBudgetsOfUser, updateBudget, } from '../services/budget.service' -import { zCreateBudget, zUpdateBudget } from '../validation' +import { zCreateBudget, zCreateUser, zUpdateBudget } from '../validation' const router = new Hono() -const zBudgetIdParamValidator = zValidator( +const zBudgetParamValidator = zValidator( + 'param', + z.object({ + budgetId: z.string(), + }), +) + +const zInvitationParamValidator = zValidator( 'param', z.object({ budgetId: z.string(), + invitationId: z.string(), }), ) @@ -47,7 +66,7 @@ router.post('/', zValidator('json', zCreateBudget), async (c) => { const user = getAuthUserStrict(c) if (!(await canUserCreateBudget({ user }))) { - return c.json({ message: 'User cannot create budget' }, 403) + return c.json({ message: 'user cannot create budget' }, 403) } const createBudgetData = c.req.valid('json') @@ -57,14 +76,14 @@ router.post('/', zValidator('json', zCreateBudget), async (c) => { return c.json(budget, 201) }) -router.get('/:budgetId', zBudgetIdParamValidator, async (c) => { +router.get('/:budgetId', zBudgetParamValidator, 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({ message: 'budget not found' }, 404) } return c.json(budget) @@ -72,7 +91,7 @@ router.get('/:budgetId', zBudgetIdParamValidator, async (c) => { router.put( '/:budgetId', - zBudgetIdParamValidator, + zBudgetParamValidator, zValidator('json', zUpdateBudget), async (c) => { const user = getAuthUserStrict(c) @@ -81,11 +100,11 @@ router.put( const budget = await findBudget({ budgetId }) if (!(budget && (await canUserReadBudget({ user, budget })))) { - return c.json(null, 404) + return c.json({ message: 'budget not found' }, 404) } if (!(await canUserUpdateBudget({ user, budget }))) { - return c.json({ message: 'User cannot update budget' }, 403) + return c.json({ message: 'user cannot update budget' }, 403) } const updateBudgetData = c.req.valid('json') @@ -99,18 +118,18 @@ router.put( }, ) -router.delete('/:budgetId', zBudgetIdParamValidator, async (c) => { +router.delete('/:budgetId', zBudgetParamValidator, 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({ message: 'budget not found' }, 404) } if (!(await canUserDeleteBudget({ user, budget }))) { - return c.json({ message: 'User cannot delete budget' }, 403) + return c.json({ message: 'user cannot delete budget' }, 403) } await deleteBudget({ budgetId }) @@ -118,4 +137,146 @@ router.delete('/:budgetId', zBudgetIdParamValidator, async (c) => { return c.json(budget) }) +/** Generate sharable invitation link */ +router.post( + '/:budgetId/invitations/generate', + zBudgetParamValidator, + 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({ message: 'budget not found' }, 404) + } + + if (!(await canUserGenerateBudgetInvitation({ user, budget }))) { + return c.json( + { message: 'user cannot generate invite link to budget' }, + 403, + ) + } + + const invitation = await generateBudgetInvitation({ + budgetId, + userId: user.id, + }) + + return c.json(invitation) + }, +) + +/** Invite user to budget by email */ +router.post( + '/:budgetId/invitations', + zBudgetParamValidator, + zValidator( + 'json', + z.object({ + email: z.string().email(), + permission: BudgetUserPermissionSchema.optional(), + }), + ), + 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({ message: 'budget not found' }, 404) + } + + if (!(await canUserInviteUserToBudget({ user, budget }))) { + return c.json({ message: 'user cannot invite users to this budget' }, 403) + } + + const { email, permission } = c.req.valid('json') + + const invitation = await inviteUserToBudget({ + inviter: user, + budget, + email, + permission, + }) + + return c.json(invitation, 201) + }, +) + +/** Delete/revoke invitation */ +router.delete( + '/:budgetId/invitations/:invitationId', + zInvitationParamValidator, + async (c) => { + const user = getAuthUserStrict(c) + const { budgetId, invitationId } = c.req.valid('param') + + const budget = await findBudget({ budgetId }) + + if (!(budget && (await canUserReadBudget({ user, budget })))) { + return c.json({ message: 'budget not found' }, 404) + } + + const invitation = await findBudgetInvitation({ invitationId }) + + if (!invitation) { + return c.json({ message: 'invitation not found' }, 404) + } + + if (!(await canUserDeleteBudgetInvitation({ user, invitation }))) { + return c.json({ message: 'user cannot delete this invitation' }, 403) + } + + await deleteBudgetInvitation({ invitationId }) + + return c.json(invitation) + }, +) + +/** Join budget with token */ +router.post( + '/response-invitation', + zValidator( + 'json', + z.object({ + token: z.string(), + userData: zCreateUser.optional(), + accept: z.boolean(), + }), + ), + async (c) => { + const user = getAuthUser(c) + const { token, userData, accept } = c.req.valid('json') + + if (!user && !userData) { + return c.json({ message: 'user data is required' }, 400) + } + + const invitation = await verifyBudgetInvitationToken({ token }) + + if (!invitation) { + return c.json( + { + message: 'invalid or expired invitation token', + }, + 404, + ) + } + + const response = await respondToBudgetInvitation({ + invitation, + accept, + userData: { + id: user?.id, + email: (invitation.email ?? user?.email ?? userData?.email)!, + name: (user?.name ?? userData?.name)!, + }, + }) + + return c.json(response) + }, +) + export default router diff --git a/apps/api/v1/services/budget-invitation.service.ts b/apps/api/v1/services/budget-invitation.service.ts index 2ad29c78..ee3a8eca 100644 --- a/apps/api/v1/services/budget-invitation.service.ts +++ b/apps/api/v1/services/budget-invitation.service.ts @@ -121,6 +121,16 @@ export async function generateBudgetInvitation({ return invitation } +export async function findBudgetInvitation({ + invitationId, +}: { + invitationId: string +}): Promise<BudgetUserInvitation | null> { + return prisma.budgetUserInvitation.findUnique({ + where: { id: invitationId }, + }) +} + export async function findBudgetInvitations({ budgetId, permission,