From afed1731d2f84d543892c8f0e9cae8366c36e109 Mon Sep 17 00:00:00 2001 From: sauravdhakal12 Date: Sun, 22 Feb 2026 15:47:45 +0545 Subject: [PATCH] feat: Organization services --- common/exceptions/exception-filter.ts | 5 + common/interceptors/response.interceptor.ts | 2 +- common/validators/at-least-one-field.ts | 26 +++ common/validators/index.ts | 1 + common/validators/query-uuid.dto.ts | 6 + core/als/request-context.service.ts | 6 +- prisma/generated/prisma/commonInputTypes.ts | 34 ++++ prisma/generated/prisma/enums.ts | 13 +- prisma/generated/prisma/internal/class.ts | 4 +- .../prisma/internal/prismaNamespace.ts | 15 ++ .../prisma/internal/prismaNamespaceBrowser.ts | 1 + .../prisma/models/OrganizationJoinRequest.ts | 34 +++- .../migration.sql | 35 +++++ .../20260221131903_new_enum/migration.sql | 1 + .../migration.sql | 11 ++ .../models/organization-join-request.prisma | 11 +- prisma/models/organization-user-join.prisma | 5 +- prisma/models/user.prisma | 4 +- src/app.module.ts | 6 + src/auth/auth.controller.ts | 2 + src/auth/auth.module.ts | 3 +- src/auth/guards/auth.guard.ts | 6 +- src/authorization/authorization.module.ts | 10 ++ .../authorization.service.spec.ts | 18 +++ src/authorization/authorization.service.ts | 71 +++++++++ src/authorization/operations.ts | 5 + src/organization-membership/dto/index.ts | 1 + .../dto/invite-to-org.dto.ts | 16 ++ ...organization-membership.controller.spec.ts | 18 +++ .../organization-membership.controller.ts | 4 + .../organization-membership.module.ts | 14 ++ .../organization-membership.service.spec.ts | 18 +++ .../organization-membership.service.ts | 148 ++++++++++++++++++ src/organization/dtos/index.ts | 2 + .../dtos/organization-response.dto.ts | 15 ++ src/organization/dtos/organization.dto.ts | 30 ++++ .../organization.controller.spec.ts | 18 +++ src/organization/organization.controller.ts | 94 +++++++++++ src/organization/organization.module.ts | 20 +++ src/organization/organization.service.spec.ts | 18 +++ src/organization/organization.service.ts | 120 ++++++++++++++ src/user/user.service.ts | 8 + 42 files changed, 862 insertions(+), 17 deletions(-) create mode 100644 common/validators/at-least-one-field.ts create mode 100644 common/validators/index.ts create mode 100644 common/validators/query-uuid.dto.ts create mode 100644 prisma/migrations/20260221125946_change_enum_value/migration.sql create mode 100644 prisma/migrations/20260221131903_new_enum/migration.sql create mode 100644 prisma/migrations/20260221151600_new_org_request_type/migration.sql create mode 100644 src/authorization/authorization.module.ts create mode 100644 src/authorization/authorization.service.spec.ts create mode 100644 src/authorization/authorization.service.ts create mode 100644 src/authorization/operations.ts create mode 100644 src/organization-membership/dto/index.ts create mode 100644 src/organization-membership/dto/invite-to-org.dto.ts create mode 100644 src/organization-membership/organization-membership.controller.spec.ts create mode 100644 src/organization-membership/organization-membership.controller.ts create mode 100644 src/organization-membership/organization-membership.module.ts create mode 100644 src/organization-membership/organization-membership.service.spec.ts create mode 100644 src/organization-membership/organization-membership.service.ts create mode 100644 src/organization/dtos/index.ts create mode 100644 src/organization/dtos/organization-response.dto.ts create mode 100644 src/organization/dtos/organization.dto.ts create mode 100644 src/organization/organization.controller.spec.ts create mode 100644 src/organization/organization.controller.ts create mode 100644 src/organization/organization.module.ts create mode 100644 src/organization/organization.service.spec.ts create mode 100644 src/organization/organization.service.ts diff --git a/common/exceptions/exception-filter.ts b/common/exceptions/exception-filter.ts index cb29fde..34e8fcc 100644 --- a/common/exceptions/exception-filter.ts +++ b/common/exceptions/exception-filter.ts @@ -24,7 +24,12 @@ export class HttpExceptionFilter implements ExceptionFilter { }); } + if (status === 404) { + exception.message = `${exception.message} not found`; + } + response.status(status).json({ + success: false, message: exception.message, statusCode: status, }); diff --git a/common/interceptors/response.interceptor.ts b/common/interceptors/response.interceptor.ts index 9927eab..b2ba899 100644 --- a/common/interceptors/response.interceptor.ts +++ b/common/interceptors/response.interceptor.ts @@ -6,7 +6,7 @@ import { } from '@nestjs/common'; import { DataResponse, MessageResponse } from 'common/http'; import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { catchError, map } from 'rxjs/operators'; @Injectable() export class ResponseInterceptor implements NestInterceptor { diff --git a/common/validators/at-least-one-field.ts b/common/validators/at-least-one-field.ts new file mode 100644 index 0000000..84698cf --- /dev/null +++ b/common/validators/at-least-one-field.ts @@ -0,0 +1,26 @@ +import { + registerDecorator, + ValidationArguments, + ValidationOptions, +} from 'class-validator'; + +export function AtLeastOneField(validationOptions?: ValidationOptions) { + return function (constructor: Function) { + registerDecorator({ + name: 'atLeastOneField', + target: constructor, + propertyName: undefined as any, // important for class-level + options: validationOptions, + validator: { + validate(_: any, args: ValidationArguments) { + const object = args.object as Record; + + return Object.values(object).some((value) => value !== undefined); + }, + defaultMessage() { + return 'At least one field must be provided'; + }, + }, + }); + }; +} diff --git a/common/validators/index.ts b/common/validators/index.ts new file mode 100644 index 0000000..3501d4a --- /dev/null +++ b/common/validators/index.ts @@ -0,0 +1 @@ +export * from './at-least-one-field'; diff --git a/common/validators/query-uuid.dto.ts b/common/validators/query-uuid.dto.ts new file mode 100644 index 0000000..fd1e5ca --- /dev/null +++ b/common/validators/query-uuid.dto.ts @@ -0,0 +1,6 @@ +import { IsUUID } from 'class-validator'; + +export class UUIDQueryDTO { + @IsUUID() + id: string; +} diff --git a/core/als/request-context.service.ts b/core/als/request-context.service.ts index 314beef..691a491 100644 --- a/core/als/request-context.service.ts +++ b/core/als/request-context.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; import { AsyncLocalStorage } from 'async_hooks'; import { RequestContext } from './request-context.type'; @@ -31,7 +31,9 @@ export class RequestContextService { // Helpers get user() { - return this.get().user; + const user = this.get().user; + if (!user) throw new UnauthorizedException(); + return user; } get tx() { diff --git a/prisma/generated/prisma/commonInputTypes.ts b/prisma/generated/prisma/commonInputTypes.ts index 5d74916..53fba16 100644 --- a/prisma/generated/prisma/commonInputTypes.ts +++ b/prisma/generated/prisma/commonInputTypes.ts @@ -36,6 +36,13 @@ export type EnumORGANIZATION_JOIN_REQUESTFilter<$PrismaModel = never> = { not?: Prisma.NestedEnumORGANIZATION_JOIN_REQUESTFilter<$PrismaModel> | $Enums.ORGANIZATION_JOIN_REQUEST } +export type EnumORGANIZATION_JOIN_REQUEST_TYPEFilter<$PrismaModel = never> = { + equals?: $Enums.ORGANIZATION_JOIN_REQUEST_TYPE | Prisma.EnumORGANIZATION_JOIN_REQUEST_TYPEFieldRefInput<$PrismaModel> + in?: $Enums.ORGANIZATION_JOIN_REQUEST_TYPE[] | Prisma.ListEnumORGANIZATION_JOIN_REQUEST_TYPEFieldRefInput<$PrismaModel> + notIn?: $Enums.ORGANIZATION_JOIN_REQUEST_TYPE[] | Prisma.ListEnumORGANIZATION_JOIN_REQUEST_TYPEFieldRefInput<$PrismaModel> + not?: Prisma.NestedEnumORGANIZATION_JOIN_REQUEST_TYPEFilter<$PrismaModel> | $Enums.ORGANIZATION_JOIN_REQUEST_TYPE +} + export type DateTimeFilter<$PrismaModel = never> = { equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> @@ -95,6 +102,16 @@ export type EnumORGANIZATION_JOIN_REQUESTWithAggregatesFilter<$PrismaModel = nev _max?: Prisma.NestedEnumORGANIZATION_JOIN_REQUESTFilter<$PrismaModel> } +export type EnumORGANIZATION_JOIN_REQUEST_TYPEWithAggregatesFilter<$PrismaModel = never> = { + equals?: $Enums.ORGANIZATION_JOIN_REQUEST_TYPE | Prisma.EnumORGANIZATION_JOIN_REQUEST_TYPEFieldRefInput<$PrismaModel> + in?: $Enums.ORGANIZATION_JOIN_REQUEST_TYPE[] | Prisma.ListEnumORGANIZATION_JOIN_REQUEST_TYPEFieldRefInput<$PrismaModel> + notIn?: $Enums.ORGANIZATION_JOIN_REQUEST_TYPE[] | Prisma.ListEnumORGANIZATION_JOIN_REQUEST_TYPEFieldRefInput<$PrismaModel> + not?: Prisma.NestedEnumORGANIZATION_JOIN_REQUEST_TYPEWithAggregatesFilter<$PrismaModel> | $Enums.ORGANIZATION_JOIN_REQUEST_TYPE + _count?: Prisma.NestedIntFilter<$PrismaModel> + _min?: Prisma.NestedEnumORGANIZATION_JOIN_REQUEST_TYPEFilter<$PrismaModel> + _max?: Prisma.NestedEnumORGANIZATION_JOIN_REQUEST_TYPEFilter<$PrismaModel> +} + export type DateTimeWithAggregatesFilter<$PrismaModel = never> = { equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> @@ -220,6 +237,13 @@ export type NestedEnumORGANIZATION_JOIN_REQUESTFilter<$PrismaModel = never> = { not?: Prisma.NestedEnumORGANIZATION_JOIN_REQUESTFilter<$PrismaModel> | $Enums.ORGANIZATION_JOIN_REQUEST } +export type NestedEnumORGANIZATION_JOIN_REQUEST_TYPEFilter<$PrismaModel = never> = { + equals?: $Enums.ORGANIZATION_JOIN_REQUEST_TYPE | Prisma.EnumORGANIZATION_JOIN_REQUEST_TYPEFieldRefInput<$PrismaModel> + in?: $Enums.ORGANIZATION_JOIN_REQUEST_TYPE[] | Prisma.ListEnumORGANIZATION_JOIN_REQUEST_TYPEFieldRefInput<$PrismaModel> + notIn?: $Enums.ORGANIZATION_JOIN_REQUEST_TYPE[] | Prisma.ListEnumORGANIZATION_JOIN_REQUEST_TYPEFieldRefInput<$PrismaModel> + not?: Prisma.NestedEnumORGANIZATION_JOIN_REQUEST_TYPEFilter<$PrismaModel> | $Enums.ORGANIZATION_JOIN_REQUEST_TYPE +} + export type NestedDateTimeFilter<$PrismaModel = never> = { equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> @@ -283,6 +307,16 @@ export type NestedEnumORGANIZATION_JOIN_REQUESTWithAggregatesFilter<$PrismaModel _max?: Prisma.NestedEnumORGANIZATION_JOIN_REQUESTFilter<$PrismaModel> } +export type NestedEnumORGANIZATION_JOIN_REQUEST_TYPEWithAggregatesFilter<$PrismaModel = never> = { + equals?: $Enums.ORGANIZATION_JOIN_REQUEST_TYPE | Prisma.EnumORGANIZATION_JOIN_REQUEST_TYPEFieldRefInput<$PrismaModel> + in?: $Enums.ORGANIZATION_JOIN_REQUEST_TYPE[] | Prisma.ListEnumORGANIZATION_JOIN_REQUEST_TYPEFieldRefInput<$PrismaModel> + notIn?: $Enums.ORGANIZATION_JOIN_REQUEST_TYPE[] | Prisma.ListEnumORGANIZATION_JOIN_REQUEST_TYPEFieldRefInput<$PrismaModel> + not?: Prisma.NestedEnumORGANIZATION_JOIN_REQUEST_TYPEWithAggregatesFilter<$PrismaModel> | $Enums.ORGANIZATION_JOIN_REQUEST_TYPE + _count?: Prisma.NestedIntFilter<$PrismaModel> + _min?: Prisma.NestedEnumORGANIZATION_JOIN_REQUEST_TYPEFilter<$PrismaModel> + _max?: Prisma.NestedEnumORGANIZATION_JOIN_REQUEST_TYPEFilter<$PrismaModel> +} + export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = { equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> diff --git a/prisma/generated/prisma/enums.ts b/prisma/generated/prisma/enums.ts index e0c7674..095fe01 100644 --- a/prisma/generated/prisma/enums.ts +++ b/prisma/generated/prisma/enums.ts @@ -18,9 +18,18 @@ export const ORGANIZATION_JOIN_REQUEST = { export type ORGANIZATION_JOIN_REQUEST = (typeof ORGANIZATION_JOIN_REQUEST)[keyof typeof ORGANIZATION_JOIN_REQUEST] +export const ORGANIZATION_JOIN_REQUEST_TYPE = { + INVITED: 'INVITED', + REQUESTED: 'REQUESTED' +} as const + +export type ORGANIZATION_JOIN_REQUEST_TYPE = (typeof ORGANIZATION_JOIN_REQUEST_TYPE)[keyof typeof ORGANIZATION_JOIN_REQUEST_TYPE] + + export const ORG_ROLE = { + owner: 'owner', admin: 'admin', - user: 'user' + member: 'member' } as const export type ORG_ROLE = (typeof ORG_ROLE)[keyof typeof ORG_ROLE] @@ -28,7 +37,7 @@ export type ORG_ROLE = (typeof ORG_ROLE)[keyof typeof ORG_ROLE] export const USER_ROLE = { superadmin: 'superadmin', - ordinary: 'ordinary' + user: 'user' } as const export type USER_ROLE = (typeof USER_ROLE)[keyof typeof USER_ROLE] diff --git a/prisma/generated/prisma/internal/class.ts b/prisma/generated/prisma/internal/class.ts index 8c43ca0..b21bd72 100644 --- a/prisma/generated/prisma/internal/class.ts +++ b/prisma/generated/prisma/internal/class.ts @@ -20,7 +20,7 @@ const config: runtime.GetPrismaClientConfig = { "clientVersion": "7.3.0", "engineVersion": "9d6ad21cbbceab97458517b147a6a09ff43aa735", "activeProvider": "postgresql", - "inlineSchema": "model OrganizationJoinRequest {\n userId String\n orgId String\n status ORGANIZATION_JOIN_REQUEST @default(PENDING)\n requestedOn DateTime @default(now())\n rejectReason String?\n\n @@unique([userId, orgId])\n @@map(\"organization_join_request\")\n}\n\nenum ORGANIZATION_JOIN_REQUEST {\n PENDING\n ACCEPTED\n REJECTED\n}\n\nmodel OrganizationUserJoinTable {\n userId String\n orgId String\n role ORG_ROLE @default(user)\n joinedDate DateTime @default(now())\n\n @@unique([userId, orgId])\n @@map(\"organization_user_join\")\n}\n\nenum ORG_ROLE {\n admin\n user\n}\n\nmodel Organization {\n id String @id @default(uuid())\n name String\n description String?\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@map(\"organization\")\n}\n\n// This is your Prisma schema file,\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\n\n// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?\n// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"../generated/prisma\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\nmodel User {\n id String @id @default(uuid())\n firstName String\n middleName String?\n lastName String\n email String @unique\n password String\n role USER_ROLE @default(ordinary)\n isVerified Boolean? @default(false) // TODO: Email using queue\n refreshToken String?\n profilePicture String?\n isDeleted Boolean? @default(false)\n deletedAt DateTime?\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@map(\"user\")\n}\n\nenum USER_ROLE {\n superadmin\n ordinary\n}\n", + "inlineSchema": "model OrganizationJoinRequest {\n userId String\n orgId String\n status ORGANIZATION_JOIN_REQUEST @default(PENDING)\n requestType ORGANIZATION_JOIN_REQUEST_TYPE\n requestedOn DateTime @default(now())\n rejectReason String?\n\n @@unique([userId, orgId])\n @@map(\"organization_join_request\")\n}\n\nenum ORGANIZATION_JOIN_REQUEST {\n PENDING\n ACCEPTED\n REJECTED\n}\n\nenum ORGANIZATION_JOIN_REQUEST_TYPE {\n INVITED\n REQUESTED\n}\n\nmodel OrganizationUserJoinTable {\n userId String\n orgId String\n role ORG_ROLE @default(member)\n joinedDate DateTime @default(now())\n\n @@unique([userId, orgId])\n @@map(\"organization_user_join\")\n}\n\nenum ORG_ROLE {\n owner\n admin\n member\n}\n\nmodel Organization {\n id String @id @default(uuid())\n name String\n description String?\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@map(\"organization\")\n}\n\n// This is your Prisma schema file,\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\n\n// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?\n// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"../generated/prisma\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\nmodel User {\n id String @id @default(uuid())\n firstName String\n middleName String?\n lastName String\n email String @unique\n password String\n role USER_ROLE @default(user)\n isVerified Boolean? @default(false) // TODO: Email using queue\n refreshToken String?\n profilePicture String?\n isDeleted Boolean? @default(false)\n deletedAt DateTime?\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@map(\"user\")\n}\n\nenum USER_ROLE {\n superadmin\n user\n}\n", "runtimeDataModel": { "models": {}, "enums": {}, @@ -28,7 +28,7 @@ const config: runtime.GetPrismaClientConfig = { } } -config.runtimeDataModel = JSON.parse("{\"models\":{\"OrganizationJoinRequest\":{\"fields\":[{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"orgId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"status\",\"kind\":\"enum\",\"type\":\"ORGANIZATION_JOIN_REQUEST\"},{\"name\":\"requestedOn\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"rejectReason\",\"kind\":\"scalar\",\"type\":\"String\"}],\"dbName\":\"organization_join_request\"},\"OrganizationUserJoinTable\":{\"fields\":[{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"orgId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"role\",\"kind\":\"enum\",\"type\":\"ORG_ROLE\"},{\"name\":\"joinedDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":\"organization_user_join\"},\"Organization\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"description\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":\"organization\"},\"User\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"firstName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"middleName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"lastName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"password\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"role\",\"kind\":\"enum\",\"type\":\"USER_ROLE\"},{\"name\":\"isVerified\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"refreshToken\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"profilePicture\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"isDeleted\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"deletedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":\"user\"}},\"enums\":{},\"types\":{}}") +config.runtimeDataModel = JSON.parse("{\"models\":{\"OrganizationJoinRequest\":{\"fields\":[{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"orgId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"status\",\"kind\":\"enum\",\"type\":\"ORGANIZATION_JOIN_REQUEST\"},{\"name\":\"requestType\",\"kind\":\"enum\",\"type\":\"ORGANIZATION_JOIN_REQUEST_TYPE\"},{\"name\":\"requestedOn\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"rejectReason\",\"kind\":\"scalar\",\"type\":\"String\"}],\"dbName\":\"organization_join_request\"},\"OrganizationUserJoinTable\":{\"fields\":[{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"orgId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"role\",\"kind\":\"enum\",\"type\":\"ORG_ROLE\"},{\"name\":\"joinedDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":\"organization_user_join\"},\"Organization\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"description\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":\"organization\"},\"User\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"firstName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"middleName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"lastName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"password\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"role\",\"kind\":\"enum\",\"type\":\"USER_ROLE\"},{\"name\":\"isVerified\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"refreshToken\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"profilePicture\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"isDeleted\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"deletedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":\"user\"}},\"enums\":{},\"types\":{}}") async function decodeBase64AsWasm(wasmBase64: string): Promise { const { Buffer } = await import('node:buffer') diff --git a/prisma/generated/prisma/internal/prismaNamespace.ts b/prisma/generated/prisma/internal/prismaNamespace.ts index 32f3dbe..f6c25ee 100644 --- a/prisma/generated/prisma/internal/prismaNamespace.ts +++ b/prisma/generated/prisma/internal/prismaNamespace.ts @@ -746,6 +746,7 @@ export const OrganizationJoinRequestScalarFieldEnum = { userId: 'userId', orgId: 'orgId', status: 'status', + requestType: 'requestType', requestedOn: 'requestedOn', rejectReason: 'rejectReason' } as const @@ -852,6 +853,20 @@ export type ListEnumORGANIZATION_JOIN_REQUESTFieldRefInput<$PrismaModel> = Field +/** + * Reference to a field of type 'ORGANIZATION_JOIN_REQUEST_TYPE' + */ +export type EnumORGANIZATION_JOIN_REQUEST_TYPEFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'ORGANIZATION_JOIN_REQUEST_TYPE'> + + + +/** + * Reference to a field of type 'ORGANIZATION_JOIN_REQUEST_TYPE[]' + */ +export type ListEnumORGANIZATION_JOIN_REQUEST_TYPEFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'ORGANIZATION_JOIN_REQUEST_TYPE[]'> + + + /** * Reference to a field of type 'DateTime' */ diff --git a/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts b/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts index 3f79be1..a9932fc 100644 --- a/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts +++ b/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts @@ -77,6 +77,7 @@ export const OrganizationJoinRequestScalarFieldEnum = { userId: 'userId', orgId: 'orgId', status: 'status', + requestType: 'requestType', requestedOn: 'requestedOn', rejectReason: 'rejectReason' } as const diff --git a/prisma/generated/prisma/models/OrganizationJoinRequest.ts b/prisma/generated/prisma/models/OrganizationJoinRequest.ts index 9993820..6f4477a 100644 --- a/prisma/generated/prisma/models/OrganizationJoinRequest.ts +++ b/prisma/generated/prisma/models/OrganizationJoinRequest.ts @@ -28,6 +28,7 @@ export type OrganizationJoinRequestMinAggregateOutputType = { userId: string | null orgId: string | null status: $Enums.ORGANIZATION_JOIN_REQUEST | null + requestType: $Enums.ORGANIZATION_JOIN_REQUEST_TYPE | null requestedOn: Date | null rejectReason: string | null } @@ -36,6 +37,7 @@ export type OrganizationJoinRequestMaxAggregateOutputType = { userId: string | null orgId: string | null status: $Enums.ORGANIZATION_JOIN_REQUEST | null + requestType: $Enums.ORGANIZATION_JOIN_REQUEST_TYPE | null requestedOn: Date | null rejectReason: string | null } @@ -44,6 +46,7 @@ export type OrganizationJoinRequestCountAggregateOutputType = { userId: number orgId: number status: number + requestType: number requestedOn: number rejectReason: number _all: number @@ -54,6 +57,7 @@ export type OrganizationJoinRequestMinAggregateInputType = { userId?: true orgId?: true status?: true + requestType?: true requestedOn?: true rejectReason?: true } @@ -62,6 +66,7 @@ export type OrganizationJoinRequestMaxAggregateInputType = { userId?: true orgId?: true status?: true + requestType?: true requestedOn?: true rejectReason?: true } @@ -70,6 +75,7 @@ export type OrganizationJoinRequestCountAggregateInputType = { userId?: true orgId?: true status?: true + requestType?: true requestedOn?: true rejectReason?: true _all?: true @@ -151,6 +157,7 @@ export type OrganizationJoinRequestGroupByOutputType = { userId: string orgId: string status: $Enums.ORGANIZATION_JOIN_REQUEST + requestType: $Enums.ORGANIZATION_JOIN_REQUEST_TYPE requestedOn: Date rejectReason: string | null _count: OrganizationJoinRequestCountAggregateOutputType | null @@ -180,6 +187,7 @@ export type OrganizationJoinRequestWhereInput = { userId?: Prisma.StringFilter<"OrganizationJoinRequest"> | string orgId?: Prisma.StringFilter<"OrganizationJoinRequest"> | string status?: Prisma.EnumORGANIZATION_JOIN_REQUESTFilter<"OrganizationJoinRequest"> | $Enums.ORGANIZATION_JOIN_REQUEST + requestType?: Prisma.EnumORGANIZATION_JOIN_REQUEST_TYPEFilter<"OrganizationJoinRequest"> | $Enums.ORGANIZATION_JOIN_REQUEST_TYPE requestedOn?: Prisma.DateTimeFilter<"OrganizationJoinRequest"> | Date | string rejectReason?: Prisma.StringNullableFilter<"OrganizationJoinRequest"> | string | null } @@ -188,6 +196,7 @@ export type OrganizationJoinRequestOrderByWithRelationInput = { userId?: Prisma.SortOrder orgId?: Prisma.SortOrder status?: Prisma.SortOrder + requestType?: Prisma.SortOrder requestedOn?: Prisma.SortOrder rejectReason?: Prisma.SortOrderInput | Prisma.SortOrder } @@ -200,6 +209,7 @@ export type OrganizationJoinRequestWhereUniqueInput = Prisma.AtLeast<{ userId?: Prisma.StringFilter<"OrganizationJoinRequest"> | string orgId?: Prisma.StringFilter<"OrganizationJoinRequest"> | string status?: Prisma.EnumORGANIZATION_JOIN_REQUESTFilter<"OrganizationJoinRequest"> | $Enums.ORGANIZATION_JOIN_REQUEST + requestType?: Prisma.EnumORGANIZATION_JOIN_REQUEST_TYPEFilter<"OrganizationJoinRequest"> | $Enums.ORGANIZATION_JOIN_REQUEST_TYPE requestedOn?: Prisma.DateTimeFilter<"OrganizationJoinRequest"> | Date | string rejectReason?: Prisma.StringNullableFilter<"OrganizationJoinRequest"> | string | null }, "userId_orgId"> @@ -208,6 +218,7 @@ export type OrganizationJoinRequestOrderByWithAggregationInput = { userId?: Prisma.SortOrder orgId?: Prisma.SortOrder status?: Prisma.SortOrder + requestType?: Prisma.SortOrder requestedOn?: Prisma.SortOrder rejectReason?: Prisma.SortOrderInput | Prisma.SortOrder _count?: Prisma.OrganizationJoinRequestCountOrderByAggregateInput @@ -222,6 +233,7 @@ export type OrganizationJoinRequestScalarWhereWithAggregatesInput = { userId?: Prisma.StringWithAggregatesFilter<"OrganizationJoinRequest"> | string orgId?: Prisma.StringWithAggregatesFilter<"OrganizationJoinRequest"> | string status?: Prisma.EnumORGANIZATION_JOIN_REQUESTWithAggregatesFilter<"OrganizationJoinRequest"> | $Enums.ORGANIZATION_JOIN_REQUEST + requestType?: Prisma.EnumORGANIZATION_JOIN_REQUEST_TYPEWithAggregatesFilter<"OrganizationJoinRequest"> | $Enums.ORGANIZATION_JOIN_REQUEST_TYPE requestedOn?: Prisma.DateTimeWithAggregatesFilter<"OrganizationJoinRequest"> | Date | string rejectReason?: Prisma.StringNullableWithAggregatesFilter<"OrganizationJoinRequest"> | string | null } @@ -230,6 +242,7 @@ export type OrganizationJoinRequestCreateInput = { userId: string orgId: string status?: $Enums.ORGANIZATION_JOIN_REQUEST + requestType: $Enums.ORGANIZATION_JOIN_REQUEST_TYPE requestedOn?: Date | string rejectReason?: string | null } @@ -238,6 +251,7 @@ export type OrganizationJoinRequestUncheckedCreateInput = { userId: string orgId: string status?: $Enums.ORGANIZATION_JOIN_REQUEST + requestType: $Enums.ORGANIZATION_JOIN_REQUEST_TYPE requestedOn?: Date | string rejectReason?: string | null } @@ -246,6 +260,7 @@ export type OrganizationJoinRequestUpdateInput = { userId?: Prisma.StringFieldUpdateOperationsInput | string orgId?: Prisma.StringFieldUpdateOperationsInput | string status?: Prisma.EnumORGANIZATION_JOIN_REQUESTFieldUpdateOperationsInput | $Enums.ORGANIZATION_JOIN_REQUEST + requestType?: Prisma.EnumORGANIZATION_JOIN_REQUEST_TYPEFieldUpdateOperationsInput | $Enums.ORGANIZATION_JOIN_REQUEST_TYPE requestedOn?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string rejectReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null } @@ -254,6 +269,7 @@ export type OrganizationJoinRequestUncheckedUpdateInput = { userId?: Prisma.StringFieldUpdateOperationsInput | string orgId?: Prisma.StringFieldUpdateOperationsInput | string status?: Prisma.EnumORGANIZATION_JOIN_REQUESTFieldUpdateOperationsInput | $Enums.ORGANIZATION_JOIN_REQUEST + requestType?: Prisma.EnumORGANIZATION_JOIN_REQUEST_TYPEFieldUpdateOperationsInput | $Enums.ORGANIZATION_JOIN_REQUEST_TYPE requestedOn?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string rejectReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null } @@ -262,6 +278,7 @@ export type OrganizationJoinRequestCreateManyInput = { userId: string orgId: string status?: $Enums.ORGANIZATION_JOIN_REQUEST + requestType: $Enums.ORGANIZATION_JOIN_REQUEST_TYPE requestedOn?: Date | string rejectReason?: string | null } @@ -270,6 +287,7 @@ export type OrganizationJoinRequestUpdateManyMutationInput = { userId?: Prisma.StringFieldUpdateOperationsInput | string orgId?: Prisma.StringFieldUpdateOperationsInput | string status?: Prisma.EnumORGANIZATION_JOIN_REQUESTFieldUpdateOperationsInput | $Enums.ORGANIZATION_JOIN_REQUEST + requestType?: Prisma.EnumORGANIZATION_JOIN_REQUEST_TYPEFieldUpdateOperationsInput | $Enums.ORGANIZATION_JOIN_REQUEST_TYPE requestedOn?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string rejectReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null } @@ -278,6 +296,7 @@ export type OrganizationJoinRequestUncheckedUpdateManyInput = { userId?: Prisma.StringFieldUpdateOperationsInput | string orgId?: Prisma.StringFieldUpdateOperationsInput | string status?: Prisma.EnumORGANIZATION_JOIN_REQUESTFieldUpdateOperationsInput | $Enums.ORGANIZATION_JOIN_REQUEST + requestType?: Prisma.EnumORGANIZATION_JOIN_REQUEST_TYPEFieldUpdateOperationsInput | $Enums.ORGANIZATION_JOIN_REQUEST_TYPE requestedOn?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string rejectReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null } @@ -291,6 +310,7 @@ export type OrganizationJoinRequestCountOrderByAggregateInput = { userId?: Prisma.SortOrder orgId?: Prisma.SortOrder status?: Prisma.SortOrder + requestType?: Prisma.SortOrder requestedOn?: Prisma.SortOrder rejectReason?: Prisma.SortOrder } @@ -299,6 +319,7 @@ export type OrganizationJoinRequestMaxOrderByAggregateInput = { userId?: Prisma.SortOrder orgId?: Prisma.SortOrder status?: Prisma.SortOrder + requestType?: Prisma.SortOrder requestedOn?: Prisma.SortOrder rejectReason?: Prisma.SortOrder } @@ -307,6 +328,7 @@ export type OrganizationJoinRequestMinOrderByAggregateInput = { userId?: Prisma.SortOrder orgId?: Prisma.SortOrder status?: Prisma.SortOrder + requestType?: Prisma.SortOrder requestedOn?: Prisma.SortOrder rejectReason?: Prisma.SortOrder } @@ -319,6 +341,10 @@ export type EnumORGANIZATION_JOIN_REQUESTFieldUpdateOperationsInput = { set?: $Enums.ORGANIZATION_JOIN_REQUEST } +export type EnumORGANIZATION_JOIN_REQUEST_TYPEFieldUpdateOperationsInput = { + set?: $Enums.ORGANIZATION_JOIN_REQUEST_TYPE +} + export type DateTimeFieldUpdateOperationsInput = { set?: Date | string } @@ -333,6 +359,7 @@ export type OrganizationJoinRequestSelect @@ -341,6 +368,7 @@ export type OrganizationJoinRequestSelectCreateManyAndReturn @@ -349,6 +377,7 @@ export type OrganizationJoinRequestSelectUpdateManyAndReturn @@ -357,11 +386,12 @@ export type OrganizationJoinRequestSelectScalar = { userId?: boolean orgId?: boolean status?: boolean + requestType?: boolean requestedOn?: boolean rejectReason?: boolean } -export type OrganizationJoinRequestOmit = runtime.Types.Extensions.GetOmit<"userId" | "orgId" | "status" | "requestedOn" | "rejectReason", ExtArgs["result"]["organizationJoinRequest"]> +export type OrganizationJoinRequestOmit = runtime.Types.Extensions.GetOmit<"userId" | "orgId" | "status" | "requestType" | "requestedOn" | "rejectReason", ExtArgs["result"]["organizationJoinRequest"]> export type $OrganizationJoinRequestPayload = { name: "OrganizationJoinRequest" @@ -370,6 +400,7 @@ export type $OrganizationJoinRequestPayload @@ -798,6 +829,7 @@ export interface OrganizationJoinRequestFieldRefs { readonly userId: Prisma.FieldRef<"OrganizationJoinRequest", 'String'> readonly orgId: Prisma.FieldRef<"OrganizationJoinRequest", 'String'> readonly status: Prisma.FieldRef<"OrganizationJoinRequest", 'ORGANIZATION_JOIN_REQUEST'> + readonly requestType: Prisma.FieldRef<"OrganizationJoinRequest", 'ORGANIZATION_JOIN_REQUEST_TYPE'> readonly requestedOn: Prisma.FieldRef<"OrganizationJoinRequest", 'DateTime'> readonly rejectReason: Prisma.FieldRef<"OrganizationJoinRequest", 'String'> } diff --git a/prisma/migrations/20260221125946_change_enum_value/migration.sql b/prisma/migrations/20260221125946_change_enum_value/migration.sql new file mode 100644 index 0000000..a0d399a --- /dev/null +++ b/prisma/migrations/20260221125946_change_enum_value/migration.sql @@ -0,0 +1,35 @@ +/* + Warnings: + + - The values [user] on the enum `ORG_ROLE` will be removed. If these variants are still used in the database, this will fail. + - The values [ordinary] on the enum `USER_ROLE` will be removed. If these variants are still used in the database, this will fail. + +*/ + +-- AlterEnum +BEGIN; +CREATE TYPE "ORG_ROLE_new" AS ENUM ('owner', 'admin', 'member'); +ALTER TABLE "public"."organization_user_join" ALTER COLUMN "role" DROP DEFAULT; +ALTER TABLE "organization_user_join" ALTER COLUMN "role" TYPE "ORG_ROLE_new" USING ("role"::text::"ORG_ROLE_new"); +ALTER TYPE "ORG_ROLE" RENAME TO "ORG_ROLE_old"; +ALTER TYPE "ORG_ROLE_new" RENAME TO "ORG_ROLE"; +DROP TYPE "public"."ORG_ROLE_old"; +ALTER TABLE "organization_user_join" ALTER COLUMN "role" SET DEFAULT 'member'; +COMMIT; + +-- AlterEnum +BEGIN; +CREATE TYPE "USER_ROLE_new" AS ENUM ('superadmin', 'user'); +ALTER TABLE "public"."user" ALTER COLUMN "role" DROP DEFAULT; +ALTER TABLE "user" ALTER COLUMN "role" TYPE "USER_ROLE_new" USING ("role"::text::"USER_ROLE_new"); +ALTER TYPE "USER_ROLE" RENAME TO "USER_ROLE_old"; +ALTER TYPE "USER_ROLE_new" RENAME TO "USER_ROLE"; +DROP TYPE "public"."USER_ROLE_old"; +ALTER TABLE "user" ALTER COLUMN "role" SET DEFAULT 'user'; +COMMIT; + +-- AlterTable +ALTER TABLE "organization_user_join" ALTER COLUMN "role" SET DEFAULT 'member'; + +-- AlterTable +ALTER TABLE "user" ALTER COLUMN "role" SET DEFAULT 'user'; diff --git a/prisma/migrations/20260221131903_new_enum/migration.sql b/prisma/migrations/20260221131903_new_enum/migration.sql new file mode 100644 index 0000000..af5102c --- /dev/null +++ b/prisma/migrations/20260221131903_new_enum/migration.sql @@ -0,0 +1 @@ +-- This is an empty migration. \ No newline at end of file diff --git a/prisma/migrations/20260221151600_new_org_request_type/migration.sql b/prisma/migrations/20260221151600_new_org_request_type/migration.sql new file mode 100644 index 0000000..1dc3d69 --- /dev/null +++ b/prisma/migrations/20260221151600_new_org_request_type/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - Added the required column `requestType` to the `organization_join_request` table without a default value. This is not possible if the table is not empty. + +*/ +-- CreateEnum +CREATE TYPE "ORGANIZATION_JOIN_REQUEST_TYPE" AS ENUM ('INVITED', 'REQUESTED'); + +-- AlterTable +ALTER TABLE "organization_join_request" ADD COLUMN "requestType" "ORGANIZATION_JOIN_REQUEST_TYPE" NOT NULL; diff --git a/prisma/models/organization-join-request.prisma b/prisma/models/organization-join-request.prisma index 473d025..37a60d3 100644 --- a/prisma/models/organization-join-request.prisma +++ b/prisma/models/organization-join-request.prisma @@ -1,8 +1,10 @@ model OrganizationJoinRequest { userId String orgId String - status ORGANIZATION_JOIN_REQUEST @default(PENDING) - requestedOn DateTime @default(now()) + status ORGANIZATION_JOIN_REQUEST @default(PENDING) + requestType ORGANIZATION_JOIN_REQUEST_TYPE + requestedOn DateTime @default(now()) + updatedAt DateTime @updatedAt rejectReason String? @@unique([userId, orgId]) @@ -14,3 +16,8 @@ enum ORGANIZATION_JOIN_REQUEST { ACCEPTED REJECTED } + +enum ORGANIZATION_JOIN_REQUEST_TYPE { + INVITED + REQUESTED +} diff --git a/prisma/models/organization-user-join.prisma b/prisma/models/organization-user-join.prisma index 6d2b919..65c5ed9 100644 --- a/prisma/models/organization-user-join.prisma +++ b/prisma/models/organization-user-join.prisma @@ -1,7 +1,7 @@ model OrganizationUserJoinTable { userId String orgId String - role ORG_ROLE @default(user) + role ORG_ROLE @default(member) joinedDate DateTime @default(now()) @@unique([userId, orgId]) @@ -9,6 +9,7 @@ model OrganizationUserJoinTable { } enum ORG_ROLE { + owner admin - user + member } diff --git a/prisma/models/user.prisma b/prisma/models/user.prisma index e9f8a0a..ff420ea 100644 --- a/prisma/models/user.prisma +++ b/prisma/models/user.prisma @@ -5,7 +5,7 @@ model User { lastName String email String @unique password String - role USER_ROLE @default(ordinary) + role USER_ROLE @default(user) isVerified Boolean? @default(false) // TODO: Email using queue refreshToken String? profilePicture String? @@ -20,5 +20,5 @@ model User { enum USER_ROLE { superadmin - ordinary + user } diff --git a/src/app.module.ts b/src/app.module.ts index 0dff09d..b18caf1 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -11,6 +11,9 @@ import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core'; import { ResponseInterceptor } from 'common/interceptors/response.interceptor'; import { ExceptionsHandler } from '@nestjs/core/exceptions/exceptions-handler'; import { HttpExceptionFilter } from 'common/exceptions/exception-filter'; +import { OrganizationModule } from './organization/organization.module'; +import { OrganizationMembershipModule } from './organization-membership/organization-membership.module'; +import { AuthorizationModule } from './authorization/authorization.module'; @Module({ imports: [ @@ -21,6 +24,9 @@ import { HttpExceptionFilter } from 'common/exceptions/exception-filter'; AuthModule, RequestContextModule, PrismaModule, + OrganizationModule, + OrganizationMembershipModule, + AuthorizationModule, ], controllers: [AppController], providers: [ diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index bb98ab5..b4e7aef 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -15,8 +15,10 @@ import { } from './dto'; import { Response } from 'express'; import { DataResponse } from 'common/http'; +import { Public } from './decorators'; @Controller('auth') +@Public() export class AuthController { constructor(private readonly authService: AuthService) {} diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index d6f3dc1..f303cdf 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -13,8 +13,7 @@ import { RequestContextModule } from 'core/als/request-context.module'; AuthService, { provide: APP_GUARD, - useFactory: () => AuthGuard, - inject: [Reflector], + useClass: AuthGuard, }, ], controllers: [AuthController], diff --git a/src/auth/guards/auth.guard.ts b/src/auth/guards/auth.guard.ts index 9a0873b..9aa1b8e 100644 --- a/src/auth/guards/auth.guard.ts +++ b/src/auth/guards/auth.guard.ts @@ -1,6 +1,7 @@ import { CanActivate, ExecutionContext, + Injectable, UnauthorizedException, } from '@nestjs/common'; import { RequestContextService } from 'core/als/request-context.service'; @@ -10,6 +11,7 @@ import { Request } from 'express'; import { Reflector } from '@nestjs/core'; import { PUBLIC_KEY } from 'common/keys'; +@Injectable() export class AuthGuard implements CanActivate { constructor( private readonly reflector: Reflector, @@ -30,7 +32,9 @@ export class AuthGuard implements CanActivate { if (!token) throw new UnauthorizedException(); try { - const payload: JwtPayload = await this.jwtService.verifyAsync(token); + const payload: JwtPayload = await this.jwtService.verifyAsync(token, { + secret: 'demo', + }); this.requestContext.set('user', payload); return true; diff --git a/src/authorization/authorization.module.ts b/src/authorization/authorization.module.ts new file mode 100644 index 0000000..4ecfd20 --- /dev/null +++ b/src/authorization/authorization.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { AuthorizationService } from './authorization.service'; +import { PrismaModule } from 'src/prisma/prisma.module'; + +@Module({ + providers: [AuthorizationService], + imports: [PrismaModule], + exports: [AuthorizationService], +}) +export class AuthorizationModule {} diff --git a/src/authorization/authorization.service.spec.ts b/src/authorization/authorization.service.spec.ts new file mode 100644 index 0000000..912ea08 --- /dev/null +++ b/src/authorization/authorization.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AuthorizationService } from './authorization.service'; + +describe('AuthorizationService', () => { + let service: AuthorizationService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [AuthorizationService], + }).compile(); + + service = module.get(AuthorizationService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/authorization/authorization.service.ts b/src/authorization/authorization.service.ts new file mode 100644 index 0000000..f8f43c8 --- /dev/null +++ b/src/authorization/authorization.service.ts @@ -0,0 +1,71 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { USER_ORGANIZATION_OPERATIONS } from './operations'; +import { ORG_ROLE } from 'prisma/generated/prisma/enums'; + +@Injectable() +export class AuthorizationService { + constructor(private readonly prisma: PrismaService) {} + + // can perform operation + async canPerformOperation( + userId: string, + orgId: string, + operation: USER_ORGANIZATION_OPERATIONS, + ) { + switch (operation) { + case USER_ORGANIZATION_OPERATIONS.DELETE_ORGANIZATION: + return await this.isOwner(userId, orgId); + case USER_ORGANIZATION_OPERATIONS.UPDATE_ORGANIZATION: + return await this.isOwner(userId, orgId); + case USER_ORGANIZATION_OPERATIONS.INVITE_USERS: + return await this.canInvite(userId, orgId); + } + } + + private async isOwner(userId: string, orgId: string) { + const isUserPartOfOrganization = await this.isUserPartOfOrganization( + userId, + orgId, + ); + + return isUserPartOfOrganization + ? isUserPartOfOrganization.role === ORG_ROLE.owner + : !!isUserPartOfOrganization; + } + + private async canInvite(userId: string, orgId: string) { + const isUserPartOfOrganization = await this.isUserPartOfOrganization( + userId, + orgId, + ); + + return isUserPartOfOrganization + ? isUserPartOfOrganization.role === ORG_ROLE.admin || + isUserPartOfOrganization.role === ORG_ROLE.owner + : !!isUserPartOfOrganization; + } + + private async isAdmin(userId: string, orgId: string) { + const isUserPartOfOrganization = await this.isUserPartOfOrganization( + userId, + orgId, + ); + + return isUserPartOfOrganization + ? isUserPartOfOrganization.role === ORG_ROLE.admin + : !!isUserPartOfOrganization; + } + + // HELPER FUNCTION + private async isUserPartOfOrganization(userId: string, orgId: string) { + return await this.prisma.organizationUserJoinTable.findUnique({ + where: { + userId_orgId: { + orgId, + userId, + }, + }, + }); + } +} diff --git a/src/authorization/operations.ts b/src/authorization/operations.ts new file mode 100644 index 0000000..30200d3 --- /dev/null +++ b/src/authorization/operations.ts @@ -0,0 +1,5 @@ +export enum USER_ORGANIZATION_OPERATIONS { + UPDATE_ORGANIZATION = 'update_organization', + DELETE_ORGANIZATION = 'delete_organization', + INVITE_USERS = 'invite_users', +} diff --git a/src/organization-membership/dto/index.ts b/src/organization-membership/dto/index.ts new file mode 100644 index 0000000..8d635fe --- /dev/null +++ b/src/organization-membership/dto/index.ts @@ -0,0 +1 @@ +export * from './invite-to-org.dto'; diff --git a/src/organization-membership/dto/invite-to-org.dto.ts b/src/organization-membership/dto/invite-to-org.dto.ts new file mode 100644 index 0000000..8fb8665 --- /dev/null +++ b/src/organization-membership/dto/invite-to-org.dto.ts @@ -0,0 +1,16 @@ +import { IsEnum, IsNotEmpty, IsUUID } from 'class-validator'; +import { ORG_ROLE } from 'prisma/generated/prisma/enums'; + +export class InviteUserToOrganizationRequestDTO { + @IsUUID() + @IsNotEmpty() + invitedUserId: string; + + @IsUUID() + @IsNotEmpty() + orgId: string; + + @IsEnum(ORG_ROLE) + @IsNotEmpty() + role: ORG_ROLE; +} diff --git a/src/organization-membership/organization-membership.controller.spec.ts b/src/organization-membership/organization-membership.controller.spec.ts new file mode 100644 index 0000000..ef52991 --- /dev/null +++ b/src/organization-membership/organization-membership.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { OrganizationMembershipController } from './organization-membership.controller'; + +describe('OrganizationMembershipController', () => { + let controller: OrganizationMembershipController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [OrganizationMembershipController], + }).compile(); + + controller = module.get(OrganizationMembershipController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/organization-membership/organization-membership.controller.ts b/src/organization-membership/organization-membership.controller.ts new file mode 100644 index 0000000..f2bdec3 --- /dev/null +++ b/src/organization-membership/organization-membership.controller.ts @@ -0,0 +1,4 @@ +import { Controller } from '@nestjs/common'; + +@Controller('organization-membership') +export class OrganizationMembershipController {} diff --git a/src/organization-membership/organization-membership.module.ts b/src/organization-membership/organization-membership.module.ts new file mode 100644 index 0000000..d9dcf83 --- /dev/null +++ b/src/organization-membership/organization-membership.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { OrganizationMembershipController } from './organization-membership.controller'; +import { OrganizationMembershipService } from './organization-membership.service'; +import { OrganizationModule } from 'src/organization/organization.module'; +import { UserModule } from 'src/user/user.module'; +import { PrismaModule } from 'src/prisma/prisma.module'; +import { AuthorizationModule } from 'src/authorization/authorization.module'; + +@Module({ + controllers: [OrganizationMembershipController], + providers: [OrganizationMembershipService], + imports: [OrganizationModule, UserModule, PrismaModule, AuthorizationModule], +}) +export class OrganizationMembershipModule {} diff --git a/src/organization-membership/organization-membership.service.spec.ts b/src/organization-membership/organization-membership.service.spec.ts new file mode 100644 index 0000000..a8d16ed --- /dev/null +++ b/src/organization-membership/organization-membership.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { OrganizationMembershipService } from './organization-membership.service'; + +describe('OrganizationMembershipService', () => { + let service: OrganizationMembershipService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [OrganizationMembershipService], + }).compile(); + + service = module.get(OrganizationMembershipService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/organization-membership/organization-membership.service.ts b/src/organization-membership/organization-membership.service.ts new file mode 100644 index 0000000..6bee1cb --- /dev/null +++ b/src/organization-membership/organization-membership.service.ts @@ -0,0 +1,148 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { OrganizationService } from 'src/organization/organization.service'; +import { UserService } from 'src/user/user.service'; +import { InviteUserToOrganizationRequestDTO } from './dto'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { + ORGANIZATION_JOIN_REQUEST, + ORGANIZATION_JOIN_REQUEST_TYPE, +} from 'prisma/generated/prisma/enums'; +import { AuthorizationService } from 'src/authorization/authorization.service'; +import { USER_ORGANIZATION_OPERATIONS } from 'src/authorization/operations'; +import { Prisma } from 'prisma/generated/prisma/client'; + +@Injectable() +export class OrganizationMembershipService { + constructor( + private readonly userService: UserService, + private readonly orgService: OrganizationService, + private readonly prisma: PrismaService, + private readonly authorization: AuthorizationService, + ) {} + async inviteUserToOrg( + userId: string, + dto: InviteUserToOrganizationRequestDTO, + ) { + const [orgExists, invitedUser] = await Promise.all([ + this.orgService.findById(dto.orgId), + this.userService.getById(dto.invitedUserId), + ]); + + if (!orgExists) throw new NotFoundException('Organization'); + if (!invitedUser) throw new NotFoundException('User'); + + const userAlreadyPart = + await this.prisma.organizationUserJoinTable.findUnique({ + where: { + userId_orgId: { + orgId: dto.orgId, + userId, + }, + }, + }); + if (userAlreadyPart) + throw new BadRequestException('User already part of this organization'); + + const canInviteUser = await this.authorization.canPerformOperation( + userId, + dto.orgId, + USER_ORGANIZATION_OPERATIONS.INVITE_USERS, + ); + if (!canInviteUser) throw new ForbiddenException('Insufficient Permission'); + + try { + const invitation = await this.prisma.organizationJoinRequest.create({ + data: { + ...dto, + userId: dto.invitedUserId, + requestType: ORGANIZATION_JOIN_REQUEST_TYPE.INVITED, + }, + }); + + return invitation; + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError) { + if (err.code === 'P2002') + throw new BadRequestException('User invitation already sent.'); + } + + throw err; + } + } + + requestToJoin() {} + + // TODO: reject, rejectReason + async acceptInvite(userId: string, orgId: string) { + const orgExists = await this.orgService.findById(orgId); + if (!orgExists) throw new NotFoundException('Organization'); + + const [userAlreadyPart, isUserInvited] = await Promise.all([ + this.prisma.organizationUserJoinTable.findUnique({ + where: { + userId_orgId: { + orgId, + userId, + }, + }, + }), + + this.prisma.organizationJoinRequest.findUnique({ + where: { + userId_orgId: { + orgId, + userId, + }, + status: ORGANIZATION_JOIN_REQUEST.PENDING, + }, + }), + ]); + + if (userAlreadyPart) + throw new BadRequestException('User already part of this organization'); + if (!isUserInvited) + throw new BadRequestException( + 'User has no invitations from this organization', + ); + + return await this.prisma.$transaction(async (tx) => { + await tx.organizationJoinRequest.update({ + where: { + userId_orgId: { + userId, + orgId, + }, + }, + data: { + status: ORGANIZATION_JOIN_REQUEST.ACCEPTED, + }, + }); + + return await tx.organizationUserJoinTable.create({ + data: { + orgId, + userId, + }, + }); + }); + } + + async getUserInvitations( + userId: string, + requestType: ORGANIZATION_JOIN_REQUEST_TYPE, + status: ORGANIZATION_JOIN_REQUEST = ORGANIZATION_JOIN_REQUEST.PENDING, + ) { + return await this.prisma.organizationJoinRequest.findMany({ + where: { + userId: userId, + status: status, + requestType: requestType, + }, + }); + } +} diff --git a/src/organization/dtos/index.ts b/src/organization/dtos/index.ts new file mode 100644 index 0000000..476a593 --- /dev/null +++ b/src/organization/dtos/index.ts @@ -0,0 +1,2 @@ +export * from './organization.dto'; +export * from './organization-response.dto'; diff --git a/src/organization/dtos/organization-response.dto.ts b/src/organization/dtos/organization-response.dto.ts new file mode 100644 index 0000000..1dd226c --- /dev/null +++ b/src/organization/dtos/organization-response.dto.ts @@ -0,0 +1,15 @@ +import { Organization } from 'prisma/generated/prisma/client'; + +export class OrganizationDTO { + readonly name: string; + readonly description: string | null; + readonly createdAt: Date; + + constructor(organization: Organization) { + this.name = organization.name; + this.description = organization.description; + this.createdAt = organization.createdAt; + } +} + +export class CreateNewOrganizationResponseDTO extends OrganizationDTO {} diff --git a/src/organization/dtos/organization.dto.ts b/src/organization/dtos/organization.dto.ts new file mode 100644 index 0000000..8e83dfb --- /dev/null +++ b/src/organization/dtos/organization.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { AtLeastOneField } from 'common/validators'; + +export class CreateNewOrganizationRequestDTO { + @ApiProperty({ + description: "Organization's name", + example: 'Lions', + type: 'string', + }) + @IsString() + @IsNotEmpty() + name: string; + + @ApiPropertyOptional({ + description: 'Short description for organization', + example: 'A cool organization active for over 10 years.', + type: 'string', + }) + @IsOptional() + @IsString() + description?: string; +} + +@AtLeastOneField({ + message: 'Provide at least one field to update', +}) +export class UpdateOrganizationRequestDTO extends PartialType( + CreateNewOrganizationRequestDTO, +) {} diff --git a/src/organization/organization.controller.spec.ts b/src/organization/organization.controller.spec.ts new file mode 100644 index 0000000..599b32d --- /dev/null +++ b/src/organization/organization.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { OrganizationController } from './organization.controller'; + +describe('OrganizationController', () => { + let controller: OrganizationController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [OrganizationController], + }).compile(); + + controller = module.get(OrganizationController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/organization/organization.controller.ts b/src/organization/organization.controller.ts new file mode 100644 index 0000000..834331e --- /dev/null +++ b/src/organization/organization.controller.ts @@ -0,0 +1,94 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, +} from '@nestjs/common'; +import { + CreateNewOrganizationRequestDTO, + OrganizationDTO, + UpdateOrganizationRequestDTO, +} from './dtos'; +import { OrganizationService } from './organization.service'; +import { RequestContextService } from 'core/als/request-context.service'; +import { ApiBearerAuth } from '@nestjs/swagger'; +import { DataResponse } from 'common/http'; + +@Controller('organization') +@ApiBearerAuth('access-token') +export class OrganizationController { + constructor( + private readonly orgService: OrganizationService, + private readonly requestContext: RequestContextService, + ) {} + + @Post('') + async createNewOrganization( + @Body() body: CreateNewOrganizationRequestDTO, + ): Promise> { + const user = this.requestContext.user; + const newOrg = await this.orgService.createNewOrganization( + user.userId, + body, + ); + + return new DataResponse( + new OrganizationDTO(newOrg), + 'Organization created successfully.', + ); + } + + @Get('') + async getOrganizations(): Promise> { + const organizations = await this.orgService.getOrganizations(); + + return new DataResponse( + organizations.map((organization) => new OrganizationDTO(organization)), + ); + } + + @Get(':id') + async getAnOrganization( + @Param('id') id: string, + ): Promise> { + const organization = await this.orgService.getOrganizationById(id); + return new DataResponse(new OrganizationDTO(organization)); + } + + @Put(':id') + async updateAnOrganization( + @Param('id') orgId: string, + @Body() body: UpdateOrganizationRequestDTO, + ): Promise> { + const user = this.requestContext.user; + const updatedOrg = await this.orgService.updateAnOrganization( + user.userId, + orgId, + body, + ); + + return new DataResponse( + new OrganizationDTO(updatedOrg), + 'Organization updated successfully', + ); + } + + @Delete(':id') + async deleteAnOrganization( + @Param('id') orgId: string, + ): Promise> { + const user = this.requestContext.user; + const deletedOrg = await this.orgService.deleteAnOrganization( + user.userId, + orgId, + ); + + return new DataResponse( + new OrganizationDTO(deletedOrg), + 'Organization deleted successfully', + ); + } +} diff --git a/src/organization/organization.module.ts b/src/organization/organization.module.ts new file mode 100644 index 0000000..efb809c --- /dev/null +++ b/src/organization/organization.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { OrganizationController } from './organization.controller'; +import { OrganizationService } from './organization.service'; +import { PrismaModule } from 'src/prisma/prisma.module'; +import { RequestContextModule } from 'core/als/request-context.module'; +import { UserModule } from 'src/user/user.module'; +import { AuthorizationModule } from 'src/authorization/authorization.module'; + +@Module({ + controllers: [OrganizationController], + providers: [OrganizationService], + imports: [ + PrismaModule, + RequestContextModule, + UserModule, + AuthorizationModule, + ], + exports: [OrganizationService], +}) +export class OrganizationModule {} diff --git a/src/organization/organization.service.spec.ts b/src/organization/organization.service.spec.ts new file mode 100644 index 0000000..dfa3602 --- /dev/null +++ b/src/organization/organization.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { OrganizationService } from './organization.service'; + +describe('OrganizationService', () => { + let service: OrganizationService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [OrganizationService], + }).compile(); + + service = module.get(OrganizationService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/organization/organization.service.ts b/src/organization/organization.service.ts new file mode 100644 index 0000000..e2f6a6d --- /dev/null +++ b/src/organization/organization.service.ts @@ -0,0 +1,120 @@ +import { + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { + CreateNewOrganizationRequestDTO, + UpdateOrganizationRequestDTO, +} from './dtos'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { RequestContextService } from 'core/als/request-context.service'; +import { ORG_ROLE } from 'prisma/generated/prisma/enums'; +import { AuthorizationService } from 'src/authorization/authorization.service'; +import { USER_ORGANIZATION_OPERATIONS } from 'src/authorization/operations'; + +@Injectable() +export class OrganizationService { + constructor( + private readonly prisma: PrismaService, + // private readonly reqContext: RequestContextService, + private readonly authorization: AuthorizationService, + ) {} + async createNewOrganization( + userId: string, + dto: CreateNewOrganizationRequestDTO, + ) { + return await this.prisma.$transaction(async (tx) => { + const newOrganization = await tx.organization.create({ + data: dto, + }); + + await tx.organizationUserJoinTable.create({ + data: { + orgId: newOrganization.id, + userId: userId, + role: ORG_ROLE.owner, + }, + }); + + return newOrganization; + }); + } + + // NOTE: Pagination + async getOrganizations() { + return await this.prisma.organization.findMany(); + } + + async getOrganizationById(orgId: string) { + const orgExists = await this.findById(orgId); + if (!orgExists) throw new NotFoundException('Organization'); + + return orgExists; + } + + async updateAnOrganization( + userId: string, + orgId: string, + dto: UpdateOrganizationRequestDTO, + ) { + const orgExists = await this.findById(orgId); + if (!orgExists) throw new NotFoundException('Organization'); + + const canUserUpdateOrganization = + await this.authorization.canPerformOperation( + userId, + orgId, + USER_ORGANIZATION_OPERATIONS.UPDATE_ORGANIZATION, + ); + + if (!canUserUpdateOrganization) + throw new ForbiddenException('Not enough permission'); + + return this.prisma.organization.update({ + where: { + id: orgId, + }, + data: dto, + }); + } + + // TODO: Either empty or choose someone to be owner + async deleteAnOrganization(userId: string, orgId: string) { + const orgExists = await this.findById(orgId); + if (!orgExists) throw new NotFoundException('Organization'); + + const canUserDeleteOrganization = + await this.authorization.canPerformOperation( + userId, + orgId, + USER_ORGANIZATION_OPERATIONS.DELETE_ORGANIZATION, + ); + + if (!canUserDeleteOrganization) + throw new ForbiddenException('Not enough permission'); + + return await this.prisma.$transaction(async (tx) => { + const deletedOrganization = await tx.organization.delete({ + where: { + id: orgId, + }, + }); + + await tx.organizationUserJoinTable.delete({ + where: { + userId_orgId: { + userId, + orgId, + }, + }, + }); + + return deletedOrganization; + }); + } + + async findById(orgId: string) { + return await this.prisma.organization.findUnique({ where: { id: orgId } }); + } +} diff --git a/src/user/user.service.ts b/src/user/user.service.ts index 26b5062..e5aaadc 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -28,6 +28,14 @@ export class UserService { }); } + async getById(id: string) { + return await this.prisma.user.findUnique({ + where: { + id: id, + }, + }); + } + async updateRefreshToken(id: string, refreshToken: string) { return await this.prisma.user.update({ where: { id },