Skip to content

Commit

Permalink
Merge pull request #24 from mindset-labs/feat/invites-api
Browse files Browse the repository at this point in the history
add Invite model, router, controller and service
  • Loading branch information
FaisalAl-Tameemi authored Sep 15, 2024
2 parents 650c484 + b9ae68d commit 76eb05a
Show file tree
Hide file tree
Showing 17 changed files with 1,616 additions and 120 deletions.
31 changes: 31 additions & 0 deletions api/prisma/migrations/20240915032040_init/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
-- CreateEnum
CREATE TYPE "InviteStatus" AS ENUM ('OPEN', 'CLOSED');

-- AlterEnum
ALTER TYPE "CommunityRole" ADD VALUE 'MANAGED_MEMBER';

-- CreateTable
CREATE TABLE "Invite" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"email" TEXT,
"communityId" TEXT NOT NULL,
"walletId" TEXT,
"inviteById" TEXT NOT NULL,
"status" "InviteStatus" NOT NULL DEFAULT 'OPEN',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3),
"maxUses" INTEGER DEFAULT 999,
"uses" INTEGER NOT NULL DEFAULT 0,

CONSTRAINT "Invite_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "Invite_code_key" ON "Invite"("code");

-- AddForeignKey
ALTER TABLE "Invite" ADD CONSTRAINT "Invite_communityId_fkey" FOREIGN KEY ("communityId") REFERENCES "Community"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "Invite" ADD CONSTRAINT "Invite_inviteById_fkey" FOREIGN KEY ("inviteById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
1 change: 1 addition & 0 deletions api/prisma/schema/communityModel.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ model Community {
events Event[]
achievements Achievement[]
EventLog EventLog[]
invites Invite[]
}

enum CommunityStatus {
Expand Down
1,336 changes: 1,237 additions & 99 deletions api/prisma/schema/generated/zod/index.ts

Large diffs are not rendered by default.

22 changes: 22 additions & 0 deletions api/prisma/schema/inviteModel.prisma
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
model Invite {
id String @id @default(uuid())
code String @unique
email String?
communityId String
walletId String?
inviteById String
status InviteStatus @default(OPEN)
createdAt DateTime @default(now())
expiresAt DateTime?
maxUses Int? @default(999)
uses Int @default(0)
// relationships
community Community @relation(fields: [communityId], references: [id])
inviteBy User @relation(fields: [inviteById], references: [id])
}

enum InviteStatus {
OPEN
CLOSED
}
25 changes: 13 additions & 12 deletions api/prisma/schema/membershipModel.prisma
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
model Membership {
id String @id @default(uuid())
userId String
communityId String
teir MembershipTier @default(BASIC)
communityRole CommunityRole @default(MEMBER)
tags String[] @default([])
nftTokenId String?
nftMetadata Json?
id String @id @default(uuid())
userId String
communityId String
teir MembershipTier @default(BASIC)
communityRole CommunityRole @default(MEMBER)
tags String[] @default([])
nftTokenId String?
nftMetadata Json?
membershipMetadata Json?
membershipStatus MembershipStatus @default(PENDING)
createdAt DateTime @default(now())
updatedAt DateTime @default(now())
membershipStatus MembershipStatus @default(PENDING)
createdAt DateTime @default(now())
updatedAt DateTime @default(now())
// relationships
user User @relation(fields: [userId], references: [id])
community Community @relation(fields: [communityId], references: [id])
Expand All @@ -26,6 +26,7 @@ enum MembershipTier {

enum CommunityRole {
MEMBER
MANAGED_MEMBER
MODERATOR
ADMIN
}
Expand Down
1 change: 1 addition & 0 deletions api/prisma/schema/userModel.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ model User {
eventLogs EventLog[]
createdEventLogs EventLog[] @relation("createdBy")
rewards AchievementReward[]
invites Invite[]
}

enum Role {
Expand Down
10 changes: 10 additions & 0 deletions api/src/api/community/communityController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,16 @@ class CommunityController {
.catch((error) => handleErrorResponse(error, res, StatusCodes.INTERNAL_SERVER_ERROR))
}

public addMembership: RequestHandler = async (req: Request, res: Response) => {
return communityService
.createMembership(req.userId!, {
...req.body,
communityId: req.params.id,
})
.then((membership) => handleSuccessResponse({ membership }, res, StatusCodes.OK))
.catch((error) => handleErrorResponse(error, res, StatusCodes.INTERNAL_SERVER_ERROR))
}

public updateMembership: RequestHandler = async (req: Request, res: Response) => {
return communityService
.updateMembership(req.userId!, req.params.communityId, req.params.membershipId, req.body)
Expand Down
14 changes: 13 additions & 1 deletion api/src/api/community/communityRequestValidation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'
import { MembershipStatus, MembershipTier } from '@prisma/client'
import { CommunityRole, MembershipStatus, MembershipTier } from '@prisma/client'
import { CommunityIncludeSchema, CommunityWhereInputSchema } from '@zodSchema/index'
import { z } from 'zod'

Expand Down Expand Up @@ -60,3 +60,15 @@ export const UpdateMembershipSchema = z.object({
tags: z.array(z.string()).optional(),
})
})

export const CreateMembershipSchema = z.object({
params: z.object({
communityId: z.string().uuid(),
}),
body: z.object({
userId: z.string().uuid(),
role: z.nativeEnum(CommunityRole).default(CommunityRole.MEMBER),
tags: z.array(z.string()).optional(),
status: z.nativeEnum(MembershipStatus).default(MembershipStatus.ACTIVE),
})
})
14 changes: 12 additions & 2 deletions api/src/api/community/communityRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import { createApiResponse } from "@/api-docs/openAPIResponseBuilders"
import verifyJWT, { verifyJWTAndRole } from "@/common/middleware/verifyJWT"
import { validateRequest } from "@/common/utils/httpHandlers"
import { communityController } from "./communityController"
import { CommunitySchema, MembershipSchema } from '@zodSchema/index'
import { CreateOrUpdateCommunitySchema, GetCommunitySchema, IssueCommunityPointsSchema, QueryAllCommunitiesSchema, UpdateMembershipSchema } from './communityRequestValidation'
import { CommunitySchema, MembershipCreateInputSchema, MembershipSchema } from '@zodSchema/index'
import { CreateMembershipSchema, CreateOrUpdateCommunitySchema, GetCommunitySchema, IssueCommunityPointsSchema, QueryAllCommunitiesSchema, UpdateMembershipSchema } from './communityRequestValidation'
import { Role } from '@prisma/client'

export const communityRegistry = new OpenAPIRegistry()
Expand Down Expand Up @@ -59,6 +59,16 @@ communityRegistry.registerPath({

communityRouter.post("/", verifyJWT, validateRequest(CreateOrUpdateCommunitySchema), communityController.createCommunity)

// Add a member to a community as a managed member
communityRegistry.registerPath({
method: "post",
path: "/communities/{id}/add-member",
tags: ["Community"],
responses: createApiResponse(MembershipWithoutMetadata, "Success"),
})

communityRouter.post("/:id/add-member", verifyJWT, validateRequest(CreateMembershipSchema), communityController.addMembership)

// Update a community
communityRegistry.registerPath({
method: "put",
Expand Down
62 changes: 59 additions & 3 deletions api/src/api/community/communityService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { type QueryPaging } from '@/common/utils/commonTypes'
type JoinCommunityOptions = {
createWallet?: boolean,
walletName?: string,
ignorePublicCheck?: boolean,
}

export class CommunityService {
Expand Down Expand Up @@ -160,8 +161,6 @@ export class CommunityService {
}
})

// TODO: add ability to join community via invite code

if (!community) {
throw new CustomError('Community not found', CustomErrorCode.INVALID_COMMUNITY)
} else if (community.memberships.length > 0) {
Expand All @@ -170,7 +169,7 @@ export class CommunityService {
})
} else if (community.status !== CommunityStatus.ACTIVE) {
throw new CustomError('Community is not active', CustomErrorCode.COMMUNITY_NOT_ACTIVE)
} else if (!community.isPublic) {
} else if (!community.isPublic && !options?.ignorePublicCheck) {
throw new CustomError('Community is not public', CustomErrorCode.COMMUNITY_NOT_PUBLIC)
}

Expand Down Expand Up @@ -283,6 +282,63 @@ export class CommunityService {
return transaction
}

/**
* Create a membership for a user in a community
* @param userId: the user performing the action
* @param data: the data to create the membership with
* @param data.userId: the user to create the membership for
* @param data.communityId: the community to create the membership for
* @param data.role: (optional) the role of the member in the community
* @param data.status: (optional) the status of the membership
* @param data.tags: (optional) the tags of the membership
* @returns the membership
*/
async createMembership(userId: string, data: Prisma.MembershipUncheckedCreateInput): Promise<Membership> {
// check the user has access to the community
const community = await this.findByIdAndCheckAccess(data.communityId, userId)

if (!community) {
throw new CustomError('Community not found or user does not have access', CustomErrorCode.INVALID_COMMUNITY_ACCESS, {
communityId: data.communityId,
userId,
})
}

// check if the user to be added is a managed user
const managedUser = await dbClient.user.findFirst({
where: {
id: data.userId,
managedById: userId,
}
})

if (!managedUser) {
throw new CustomError('User is not a managed user', CustomErrorCode.USER_NOT_MANAGED)
}

// check if the user is already a member of the community
const membership = await dbClient.membership.findFirst({
where: {
userId: data.userId,
communityId: data.communityId,
}
})

if (membership) {
throw new CustomError('User is already a member of the community', CustomErrorCode.USER_ALREADY_MEMBER, {
membership,
})
}

// create the membership
return dbClient.membership.create({
data: {
...data,
communityRole: CommunityRole.MANAGED_MEMBER,
},
})
}

/**
* Update a membership within a community
* @param userId: the user performing the action
Expand Down
42 changes: 42 additions & 0 deletions api/src/api/invite/inviteController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { Request, RequestHandler, Response } from "express"
import { inviteService } from "./inviteService"
import { handleSuccessResponse, handleErrorResponse } from '@/common/utils/httpHandlers'
import { generateApiKey, generateUUID } from '@/common/utils/random'

export class InviteController {
queryInvites: RequestHandler = async (req: Request, res: Response) => {
const { communityId } = req.query

return inviteService
.queryInvites(
{ communityId: communityId as string },
(req.query.include || {}) as Record<string, string>,
(req.query.paging || {}) as Record<string, string>
)
.then((invites) => handleSuccessResponse({ invites }, res))
.catch((error) => handleErrorResponse(error, res))
}

createInvite: RequestHandler = async (req: Request, res: Response) => {
const { code, communityId } = req.body

return inviteService
.createInvite(req.userId!, {
communityId,
code: code || generateApiKey()
})
.then((invite) => handleSuccessResponse({ invite }, res))
.catch((error) => handleErrorResponse(error, res))
}

acceptInvite: RequestHandler = async (req: Request, res: Response) => {
const { inviteCode } = req.body

return inviteService
.acceptInvite(inviteCode, req.userId!)
.then((invite) => handleSuccessResponse({ invite }, res))
.catch((error) => handleErrorResponse(error, res))
}
}

export const inviteController = new InviteController()
17 changes: 17 additions & 0 deletions api/src/api/invite/inviteRequestValidation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { InviteIncludeSchema, InviteWhereInputSchema } from '@zodSchema/index'
import { z } from "zod"

export const QueryInviteSchema = z.object({
query: z.object({
where: InviteWhereInputSchema.optional(),
include: InviteIncludeSchema.optional(),
paging: z.object({
take: z.string().optional(),
skip: z.string().optional(),
}).optional(),
}),
})

export const AcceptInviteSchema = z.object({
inviteCode: z.string(),
})
51 changes: 51 additions & 0 deletions api/src/api/invite/inviteRouter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { OpenAPIRegistry } from "@asteasolutions/zod-to-openapi"
import express, { type Router } from "express"
import { z } from "zod"

import { createApiResponse } from "@/api-docs/openAPIResponseBuilders"
import verifyJWT, { verifyJWTAndRole } from "@/common/middleware/verifyJWT"
import { inviteController } from "./inviteController"
import { InviteSchema } from '@zodSchema/index'
import { Role } from '@prisma/client'
import { AcceptInviteSchema, QueryInviteSchema } from './inviteRequestValidation'
import { validateRequest } from '@/common/utils/httpHandlers'

export const inviteRegistry = new OpenAPIRegistry()
export const inviteRouter: Router = express.Router()

inviteRegistry.register("Invite", InviteSchema)

// Get invites
inviteRegistry.registerPath({
method: "get",
path: "/invites",
tags: ["Invite"],
responses: createApiResponse(z.object({
invites: z.array(InviteSchema),
total: z.number(),
}), "Success"),
})

inviteRouter.get("/", verifyJWT, validateRequest(QueryInviteSchema), inviteController.queryInvites)

// Create an invite
inviteRegistry.registerPath({
method: "post",
path: "/invites",
tags: ["Invite"],
responses: createApiResponse(InviteSchema, "Success"),
})

inviteRouter.post("/", verifyJWTAndRole([Role.ADMIN]), inviteController.createInvite)

// Accept an invite
inviteRegistry.registerPath({
method: "post",
path: "/invites/accept",
tags: ["Invite"],
responses: createApiResponse(z.object({
invite: InviteSchema,
}), "Success"),
})

inviteRouter.post("/accept", verifyJWT, validateRequest(AcceptInviteSchema), inviteController.acceptInvite)
Loading

0 comments on commit 76eb05a

Please sign in to comment.