diff --git a/apps/api/prisma/migrations/20240422205030_alter_user_id_to_author_id_on_invites/migration.sql b/apps/api/prisma/migrations/20240422205030_alter_user_id_to_author_id_on_invites/migration.sql new file mode 100644 index 0000000..4f3ec2d --- /dev/null +++ b/apps/api/prisma/migrations/20240422205030_alter_user_id_to_author_id_on_invites/migration.sql @@ -0,0 +1,15 @@ +/* + Warnings: + + - You are about to drop the column `user_id` on the `invites` table. All the data in the column will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "invites" DROP CONSTRAINT "invites_user_id_fkey"; + +-- AlterTable +ALTER TABLE "invites" DROP COLUMN "user_id", +ADD COLUMN "author_id" TEXT; + +-- AddForeignKey +ALTER TABLE "invites" ADD CONSTRAINT "invites_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20240423175646_enable_cascades/migration.sql b/apps/api/prisma/migrations/20240423175646_enable_cascades/migration.sql new file mode 100644 index 0000000..0638647 --- /dev/null +++ b/apps/api/prisma/migrations/20240423175646_enable_cascades/migration.sql @@ -0,0 +1,35 @@ +-- DropForeignKey +ALTER TABLE "accounts" DROP CONSTRAINT "accounts_user_id_fkey"; + +-- DropForeignKey +ALTER TABLE "invites" DROP CONSTRAINT "invites_organization_id_fkey"; + +-- DropForeignKey +ALTER TABLE "members" DROP CONSTRAINT "members_organization_id_fkey"; + +-- DropForeignKey +ALTER TABLE "members" DROP CONSTRAINT "members_user_id_fkey"; + +-- DropForeignKey +ALTER TABLE "projects" DROP CONSTRAINT "projects_organization_id_fkey"; + +-- DropForeignKey +ALTER TABLE "tokens" DROP CONSTRAINT "tokens_user_id_fkey"; + +-- AddForeignKey +ALTER TABLE "tokens" ADD CONSTRAINT "tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "invites" ADD CONSTRAINT "invites_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "members" ADD CONSTRAINT "members_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "members" ADD CONSTRAINT "members_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "projects" ADD CONSTRAINT "projects_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 47717ba..c113b0e 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -35,7 +35,7 @@ model Token { type TokenType createdAt DateTime @default(now()) @map("created_at") - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) userId String @map("user_id") @@map("tokens") @@ -50,7 +50,7 @@ model Account { provider AccountProvider providerAccountId String @unique @map("provider_account_id") - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) userId String @map("user_id") @@unique([provider, userId]) @@ -69,10 +69,10 @@ model Invite { role Role createdAt DateTime @default(now()) @map("created_at") - author User? @relation(fields: [userId], references: [id]) - userId String? @map("user_id") + author User? @relation(fields: [authorId], references: [id], onDelete: SetNull) + authorId String? @map("author_id") - organization Organization @relation(fields: [organizationId], references: [id]) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organizationId String @map("organization_id") @@unique([email, organizationId]) @@ -84,10 +84,10 @@ model Member { id String @id @default(uuid()) role Role @default(MEMBER) - organization Organization @relation(fields: [organizationId], references: [id]) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organizationId String @map("organization_id") - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) userId String @map("user_id") @@unique([organizationId, userId]) @@ -123,7 +123,7 @@ model Project { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - organization Organization @relation(fields: [organizationId], references: [id]) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organizationId String @map("organization_id") owner User @relation(fields: [ownerId], references: [id]) diff --git a/apps/api/src/http/routes/auth/reset-password.ts b/apps/api/src/http/routes/auth/reset-password.ts index a8276b3..e26b849 100644 --- a/apps/api/src/http/routes/auth/reset-password.ts +++ b/apps/api/src/http/routes/auth/reset-password.ts @@ -35,14 +35,21 @@ export async function resetPassword(app: FastifyInstance) { const passwordHash = await hash(password, 6) - await prisma.user.update({ - where: { - id: tokenFromCode.userId, - }, - data: { - passwordHash, - }, - }) + await prisma.$transaction([ + prisma.user.update({ + where: { + id: tokenFromCode.userId, + }, + data: { + passwordHash, + }, + }), + prisma.token.delete({ + where: { + id: code, + }, + }), + ]) return reply.status(204).send() }, diff --git a/apps/api/src/http/routes/billing/get-organization-billing.ts b/apps/api/src/http/routes/billing/get-organization-billing.ts new file mode 100644 index 0000000..a32d29d --- /dev/null +++ b/apps/api/src/http/routes/billing/get-organization-billing.ts @@ -0,0 +1,88 @@ +import type { FastifyInstance } from 'fastify' +import type { ZodTypeProvider } from 'fastify-type-provider-zod' +import { z } from 'zod' + +import { auth } from '@/http/middlewares/auth' +import { UnauthorizedError } from '@/http/routes/_errors/unauthorized-error' +import { prisma } from '@/lib/prisma' +import { getUserPermissions } from '@/utils/get-user-permissions' + +export async function getOrganizationBilling(app: FastifyInstance) { + app + .withTypeProvider() + .register(auth) + .get( + '/organizations/:slug/billing', + { + schema: { + tags: ['Billing'], + summary: 'Get billing information from organization', + security: [{ bearerAuth: [] }], + params: z.object({ + slug: z.string(), + }), + response: { + 200: z.object({ + billing: z.object({ + seats: z.object({ + amount: z.number(), + unit: z.number(), + price: z.number(), + }), + projects: z.object({ + amount: z.number(), + unit: z.number(), + price: z.number(), + }), + total: z.number(), + }), + }), + }, + }, + }, + async (request) => { + const { slug } = request.params + const userId = await request.getCurrentUserId() + const { organization, membership } = + await request.getUserMembership(slug) + + const { cannot } = getUserPermissions(userId, membership.role) + + if (cannot('get', 'Billing')) { + throw new UnauthorizedError( + `You're not allowed to get billing details from this organization.`, + ) + } + + const [amountOfMembers, amountOfProjects] = await Promise.all([ + prisma.member.count({ + where: { + organizationId: organization.id, + role: { not: 'BILLING' }, + }, + }), + prisma.project.count({ + where: { + organizationId: organization.id, + }, + }), + ]) + + return { + billing: { + seats: { + amount: amountOfMembers, + unit: 10, + price: amountOfMembers * 10, + }, + projects: { + amount: amountOfProjects, + unit: 20, + price: amountOfProjects * 20, + }, + total: amountOfMembers * 10 + amountOfProjects * 20, + }, + } + }, + ) +} diff --git a/apps/api/src/http/routes/invites/accept-invite.ts b/apps/api/src/http/routes/invites/accept-invite.ts new file mode 100644 index 0000000..287828f --- /dev/null +++ b/apps/api/src/http/routes/invites/accept-invite.ts @@ -0,0 +1,74 @@ +import type { FastifyInstance } from 'fastify' +import type { ZodTypeProvider } from 'fastify-type-provider-zod' +import { z } from 'zod' + +import { auth } from '@/http/middlewares/auth' +import { BadRequestError } from '@/http/routes/_errors/bad-request-error' +import { prisma } from '@/lib/prisma' + +export async function acceptInvite(app: FastifyInstance) { + app + .withTypeProvider() + .register(auth) + .post( + '/invites/:inviteId', + { + schema: { + tags: ['Invites'], + summary: 'Accept an invite', + params: z.object({ + inviteId: z.string().uuid(), + }), + response: { + 204: z.null(), + }, + }, + }, + async (request, reply) => { + const userId = await request.getCurrentUserId() + const { inviteId } = request.params + + const invite = await prisma.invite.findUnique({ + where: { + id: inviteId, + }, + }) + + if (!invite) { + throw new BadRequestError('Invite not found or expired.') + } + + const user = await prisma.user.findUnique({ + where: { + id: userId, + }, + }) + + if (!user) { + throw new BadRequestError('User not found.') + } + + if (invite.email !== user.email) { + throw new BadRequestError('This invite belongs to another user.') + } + + await prisma.$transaction([ + prisma.member.create({ + data: { + userId, + organizationId: invite.organizationId, + role: invite.role, + }, + }), + + prisma.invite.delete({ + where: { + id: invite.id, + }, + }), + ]) + + return reply.status(204).send() + }, + ) +} diff --git a/apps/api/src/http/routes/invites/create-invite.ts b/apps/api/src/http/routes/invites/create-invite.ts new file mode 100644 index 0000000..67bc7e6 --- /dev/null +++ b/apps/api/src/http/routes/invites/create-invite.ts @@ -0,0 +1,108 @@ +import { roleSchema } from '@saas/auth' +import type { FastifyInstance } from 'fastify' +import type { ZodTypeProvider } from 'fastify-type-provider-zod' +import { z } from 'zod' + +import { auth } from '@/http/middlewares/auth' +import { BadRequestError } from '@/http/routes/_errors/bad-request-error' +import { UnauthorizedError } from '@/http/routes/_errors/unauthorized-error' +import { prisma } from '@/lib/prisma' +import { getUserPermissions } from '@/utils/get-user-permissions' + +export async function createInvite(app: FastifyInstance) { + app + .withTypeProvider() + .register(auth) + .post( + '/organizations/:slug/invites', + { + schema: { + tags: ['Invites'], + summary: 'Create a new invite', + security: [{ bearerAuth: [] }], + body: z.object({ + email: z.string().email(), + role: roleSchema, + }), + params: z.object({ + slug: z.string(), + }), + response: { + 201: z.object({ + inviteId: z.string().uuid(), + }), + }, + }, + }, + async (request, reply) => { + const { slug } = request.params + const userId = await request.getCurrentUserId() + const { organization, membership } = + await request.getUserMembership(slug) + + const { cannot } = getUserPermissions(userId, membership.role) + + if (cannot('create', 'Invite')) { + throw new UnauthorizedError( + `You're not allowed to create new invites.`, + ) + } + + const { email, role } = request.body + + const [, domain] = email.split('@') + + if ( + organization.shouldAttachUsersByDomain && + domain !== organization.domain + ) { + throw new BadRequestError( + `Users with '${domain}' domain will join your organization automatically on login.`, + ) + } + + const inviteWithSameEmail = await prisma.invite.findUnique({ + where: { + email_organizationId: { + email, + organizationId: organization.id, + }, + }, + }) + + if (inviteWithSameEmail) { + throw new BadRequestError( + 'Another invite with same e-mail already exists.', + ) + } + + const memberWithSameEmail = await prisma.member.findFirst({ + where: { + organizationId: organization.id, + user: { + email, + }, + }, + }) + + if (memberWithSameEmail) { + throw new BadRequestError( + 'A member with this e-mail already belongs to your organization.', + ) + } + + const invite = await prisma.invite.create({ + data: { + organizationId: organization.id, + email, + role, + authorId: userId, + }, + }) + + return reply.status(201).send({ + inviteId: invite.id, + }) + }, + ) +} diff --git a/apps/api/src/http/routes/invites/get-invite.ts b/apps/api/src/http/routes/invites/get-invite.ts new file mode 100644 index 0000000..bc0feb3 --- /dev/null +++ b/apps/api/src/http/routes/invites/get-invite.ts @@ -0,0 +1,75 @@ +import { roleSchema } from '@saas/auth' +import type { FastifyInstance } from 'fastify' +import type { ZodTypeProvider } from 'fastify-type-provider-zod' +import { z } from 'zod' + +import { BadRequestError } from '@/http/routes/_errors/bad-request-error' +import { prisma } from '@/lib/prisma' + +export async function getInvite(app: FastifyInstance) { + app.withTypeProvider().get( + '/invites/:inviteId', + { + schema: { + tags: ['Invites'], + summary: 'Get an invite', + params: z.object({ + inviteId: z.string().uuid(), + }), + response: { + 200: z.object({ + invite: z.object({ + id: z.string().uuid(), + role: roleSchema, + email: z.string().email(), + createdAt: z.date(), + organization: z.object({ + name: z.string(), + }), + author: z + .object({ + id: z.string().uuid(), + name: z.string().nullable(), + avatarUrl: z.string().url().nullable(), + }) + .nullable(), + }), + }), + }, + }, + }, + async (request) => { + const { inviteId } = request.params + + const invite = await prisma.invite.findUnique({ + where: { + id: inviteId, + }, + select: { + id: true, + email: true, + role: true, + createdAt: true, + author: { + select: { + id: true, + name: true, + avatarUrl: true, + }, + }, + organization: { + select: { + name: true, + }, + }, + }, + }) + + if (!invite) { + throw new BadRequestError('Invite not found') + } + + return { invite } + }, + ) +} diff --git a/apps/api/src/http/routes/invites/get-invites.ts b/apps/api/src/http/routes/invites/get-invites.ts new file mode 100644 index 0000000..2b1e71b --- /dev/null +++ b/apps/api/src/http/routes/invites/get-invites.ts @@ -0,0 +1,83 @@ +import { roleSchema } from '@saas/auth' +import type { FastifyInstance } from 'fastify' +import type { ZodTypeProvider } from 'fastify-type-provider-zod' +import { z } from 'zod' + +import { auth } from '@/http/middlewares/auth' +import { UnauthorizedError } from '@/http/routes/_errors/unauthorized-error' +import { prisma } from '@/lib/prisma' +import { getUserPermissions } from '@/utils/get-user-permissions' + +export async function getInvites(app: FastifyInstance) { + app + .withTypeProvider() + .register(auth) + .get( + '/organizations/:slug/invites', + { + schema: { + tags: ['Invites'], + summary: 'Get all organization invites', + security: [{ bearerAuth: [] }], + params: z.object({ + slug: z.string(), + }), + response: { + 200: z.object({ + invites: z.array( + z.object({ + id: z.string().uuid(), + role: roleSchema, + email: z.string().email(), + createdAt: z.date(), + author: z + .object({ + id: z.string().uuid(), + name: z.string().nullable(), + }) + .nullable(), + }), + ), + }), + }, + }, + }, + async (request) => { + const { slug } = request.params + const userId = await request.getCurrentUserId() + const { organization, membership } = + await request.getUserMembership(slug) + + const { cannot } = getUserPermissions(userId, membership.role) + + if (cannot('get', 'Invite')) { + throw new UnauthorizedError( + `You're not allowed to get organization invites.`, + ) + } + + const invites = await prisma.invite.findMany({ + where: { + organizationId: organization.id, + }, + select: { + id: true, + email: true, + role: true, + createdAt: true, + author: { + select: { + id: true, + name: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }) + + return { invites } + }, + ) +} diff --git a/apps/api/src/http/routes/invites/get-pending-invites.ts b/apps/api/src/http/routes/invites/get-pending-invites.ts new file mode 100644 index 0000000..a82cab9 --- /dev/null +++ b/apps/api/src/http/routes/invites/get-pending-invites.ts @@ -0,0 +1,84 @@ +import { roleSchema } from '@saas/auth' +import type { FastifyInstance } from 'fastify' +import type { ZodTypeProvider } from 'fastify-type-provider-zod' +import { z } from 'zod' + +import { auth } from '@/http/middlewares/auth' +import { BadRequestError } from '@/http/routes/_errors/bad-request-error' +import { prisma } from '@/lib/prisma' + +export async function getPendingInvites(app: FastifyInstance) { + app + .withTypeProvider() + .register(auth) + .get( + '/pending-invites', + { + schema: { + tags: ['Invites'], + summary: 'Get all user pending invites', + response: { + 200: z.object({ + invites: z.array( + z.object({ + id: z.string().uuid(), + role: roleSchema, + email: z.string().email(), + createdAt: z.date(), + organization: z.object({ + name: z.string(), + }), + author: z + .object({ + id: z.string().uuid(), + name: z.string().nullable(), + avatarUrl: z.string().url().nullable(), + }) + .nullable(), + }), + ), + }), + }, + }, + }, + async (request) => { + const userId = await request.getCurrentUserId() + + const user = await prisma.user.findUnique({ + where: { + id: userId, + }, + }) + + if (!user) { + throw new BadRequestError('User not found.') + } + + const invites = await prisma.invite.findMany({ + select: { + id: true, + email: true, + role: true, + createdAt: true, + author: { + select: { + id: true, + name: true, + avatarUrl: true, + }, + }, + organization: { + select: { + name: true, + }, + }, + }, + where: { + email: user.email, + }, + }) + + return { invites } + }, + ) +} diff --git a/apps/api/src/http/routes/invites/reject-invite.ts b/apps/api/src/http/routes/invites/reject-invite.ts new file mode 100644 index 0000000..327d8fc --- /dev/null +++ b/apps/api/src/http/routes/invites/reject-invite.ts @@ -0,0 +1,64 @@ +import type { FastifyInstance } from 'fastify' +import type { ZodTypeProvider } from 'fastify-type-provider-zod' +import { z } from 'zod' + +import { auth } from '@/http/middlewares/auth' +import { BadRequestError } from '@/http/routes/_errors/bad-request-error' +import { prisma } from '@/lib/prisma' + +export async function rejectInvite(app: FastifyInstance) { + app + .withTypeProvider() + .register(auth) + .post( + '/invites/:inviteId/reject', + { + schema: { + tags: ['Invites'], + summary: 'Reject an invite', + params: z.object({ + inviteId: z.string().uuid(), + }), + response: { + 204: z.null(), + }, + }, + }, + async (request, reply) => { + const userId = await request.getCurrentUserId() + const { inviteId } = request.params + + const invite = await prisma.invite.findUnique({ + where: { + id: inviteId, + }, + }) + + if (!invite) { + throw new BadRequestError('Invite not found or expired.') + } + + const user = await prisma.user.findUnique({ + where: { + id: userId, + }, + }) + + if (!user) { + throw new BadRequestError('User not found.') + } + + if (invite.email !== user.email) { + throw new BadRequestError('This invite belongs to another user.') + } + + await prisma.invite.delete({ + where: { + id: invite.id, + }, + }) + + return reply.status(204).send() + }, + ) +} diff --git a/apps/api/src/http/routes/invites/revoke-invite.ts b/apps/api/src/http/routes/invites/revoke-invite.ts new file mode 100644 index 0000000..10105f3 --- /dev/null +++ b/apps/api/src/http/routes/invites/revoke-invite.ts @@ -0,0 +1,63 @@ +import type { FastifyInstance } from 'fastify' +import type { ZodTypeProvider } from 'fastify-type-provider-zod' +import { z } from 'zod' + +import { auth } from '@/http/middlewares/auth' +import { BadRequestError } from '@/http/routes/_errors/bad-request-error' +import { UnauthorizedError } from '@/http/routes/_errors/unauthorized-error' +import { prisma } from '@/lib/prisma' +import { getUserPermissions } from '@/utils/get-user-permissions' + +export async function revokeInvite(app: FastifyInstance) { + app + .withTypeProvider() + .register(auth) + .post( + '/organizations/:slug/invites/:inviteId', + { + schema: { + tags: ['Invites'], + summary: 'Revoke a invite', + security: [{ bearerAuth: [] }], + params: z.object({ + slug: z.string(), + inviteId: z.string().uuid(), + }), + response: { + 204: z.null(), + }, + }, + }, + async (request, reply) => { + const { slug, inviteId } = request.params + const userId = await request.getCurrentUserId() + const { organization, membership } = + await request.getUserMembership(slug) + + const { cannot } = getUserPermissions(userId, membership.role) + + if (cannot('delete', 'Invite')) { + throw new UnauthorizedError(`You're not allowed to delete an invite.`) + } + + const invite = await prisma.invite.findUnique({ + where: { + id: inviteId, + organizationId: organization.id, + }, + }) + + if (!invite) { + throw new BadRequestError('Invite not found.') + } + + await prisma.invite.delete({ + where: { + id: inviteId, + }, + }) + + reply.code(204).send() + }, + ) +} diff --git a/apps/api/src/http/server.ts b/apps/api/src/http/server.ts index 3fe86a6..d54edd2 100644 --- a/apps/api/src/http/server.ts +++ b/apps/api/src/http/server.ts @@ -17,6 +17,13 @@ import { authenticateWithPassword } from '@/http/routes/auth/authenticate-with-p import { getProfile } from '@/http/routes/auth/get-profile' import { requestPasswordRecover } from '@/http/routes/auth/request-password-recover' import { resetPassword } from '@/http/routes/auth/reset-password' +import { getOrganizationBilling } from '@/http/routes/billing/get-organization-billing' +import { acceptInvite } from '@/http/routes/invites/accept-invite' +import { createInvite } from '@/http/routes/invites/create-invite' +import { getInvite } from '@/http/routes/invites/get-invite' +import { getPendingInvites } from '@/http/routes/invites/get-pending-invites' +import { rejectInvite } from '@/http/routes/invites/reject-invite' +import { revokeInvite } from '@/http/routes/invites/revoke-invite' import { getMembers } from '@/http/routes/members/get-members' import { removeMember } from '@/http/routes/members/remove-member' import { updateMember } from '@/http/routes/members/update-member' @@ -97,6 +104,15 @@ app.register(getMembers) app.register(updateMember) app.register(removeMember) +app.register(createInvite) +app.register(getInvite) +app.register(acceptInvite) +app.register(rejectInvite) +app.register(revokeInvite) +app.register(getPendingInvites) + +app.register(getOrganizationBilling) + app.listen({ port: env.SERVER_PORT }).then(() => { console.log('HTTP server running!') })