From 496d689ec16ef8e2733a3825e9974fdd918c4306 Mon Sep 17 00:00:00 2001 From: SauravDhakal Date: Wed, 4 Mar 2026 22:26:20 +0545 Subject: [PATCH] feat: User operations on join org --- CODE_REVIEW.md | 389 ++++++++++++++++++ common/interceptors/response.interceptor.ts | 2 +- prisma/generated/prisma/enums.ts | 3 +- prisma/generated/prisma/internal/class.ts | 4 +- .../prisma/internal/prismaNamespace.ts | 3 +- .../prisma/internal/prismaNamespaceBrowser.ts | 3 +- .../prisma/models/OrganizationJoinRequest.ts | 43 +- .../migration.sql | 2 + .../migration.sql | 1 + .../migration.sql | 2 + .../models/organization-join-request.prisma | 18 +- src/cache/cache.service.ts | 14 +- src/main.ts | 7 +- .../constants/index.ts | 1 + src/organization-membership/constants/type.ts | 4 + src/organization-membership/dto/index.ts | 2 + .../dto/invite-to-org.dto.ts | 9 - .../dto/join-request.dto.ts | 13 + .../dto/user-invitation-action.dto.ts | 24 ++ .../organization-membership.controller.ts | 163 ++++++-- .../organization-membership.service.ts | 310 +++++++++++--- src/organization/organization.service.ts | 21 +- 22 files changed, 911 insertions(+), 127 deletions(-) create mode 100644 CODE_REVIEW.md create mode 100644 prisma/migrations/20260304063144_request_reason_in_join_request/migration.sql create mode 100644 prisma/migrations/20260304064831_add_new_cancelled_status_in_organization_join_req/migration.sql create mode 100644 prisma/migrations/20260304065334_add_cancelled_status_in_org_join_req/migration.sql create mode 100644 src/organization-membership/constants/index.ts create mode 100644 src/organization-membership/constants/type.ts create mode 100644 src/organization-membership/dto/join-request.dto.ts create mode 100644 src/organization-membership/dto/user-invitation-action.dto.ts diff --git a/CODE_REVIEW.md b/CODE_REVIEW.md new file mode 100644 index 0000000..819924c --- /dev/null +++ b/CODE_REVIEW.md @@ -0,0 +1,389 @@ +# Code Review — Kaa Khane (Multi-Tenant SaaS Backend) + +## What This Is + +A NestJS backend for a multi-tenant SaaS platform. It models the core of any team-based product: users belong to organizations, organizations have roles (`owner`, `admin`, `member`), and membership is managed through a request/invitation flow. Stack: NestJS + Prisma + PostgreSQL + Redis (via Keyv). This is a learning project but the ambition is real — you've touched AsyncLocalStorage, operation-based authorization, cache resilience, and transactional writes. That's not beginner stuff. + +--- + +## What You're Doing Well + +**1. AsyncLocalStorage for request context** +This is a legitimately good pattern. Instead of threading `req.user` through every function signature, you store it in ALS and services pull it from context. This is how production NestJS apps at scale handle per-request state. Most people learning NestJS never get here. + +**2. Cache-aside with graceful degradation** +`CacheService.getOrSet()` is well thought out. It tracks `redisAvailable`, silently falls back to DB, and doesn't let a Redis outage crash the app. The pattern is correct. The placement of `console.warn` in a service is slightly off (more on that below) but the intent is solid. + +**3. Parallel DB checks with `Promise.all`** +You're doing this in multiple places — running independent queries concurrently instead of awaiting them in sequence. This is the right instinct and most junior devs miss it. + +**4. Operation-based authorization** +`AuthorizationService.canPerformOperation()` with a switch statement and named operations (`INVITE_USERS`, `DELETE_ORGANIZATION`) is cleaner than scattering raw role checks everywhere. It's a solid foundation. + +**5. Transactions where they matter** +`createNewOrganization` and `userOrganiaztionRequestAction` use `$transaction` to ensure atomicity. You understand when to use them. + +**6. DTO validation layer** +Using `class-validator` + `@nestjs/swagger` together is standard and correct. `@AtLeastOneField()` is a good custom validator — you're not reaching for a library when a simple decorator does the job. + +**7. Global `AuthGuard` with `@Public()` opt-out** +Secure by default. Making all routes protected and requiring explicit `@Public()` to open them is the right model. A lot of apps do it backwards. + +--- + +## Issues and What to Improve + +Organized by severity: `[critical]`, `[high]`, `[medium]`, `[low]`. + +--- + +### 1. Hardcoded JWT secret `[critical]` + +**File:** `src/auth/auth.service.ts:60`, `src/auth/guards/auth.guard.ts:38` + +```ts +secret: 'demo'; +``` + +You know this is wrong (you left a TODO). But let me explain _why_ it's critical: if this ever reaches a staging environment and someone rotates the secret, every user is logged out. Worse, the secret is in version control. Fix this before you add anything else. + +**Fix:** + +```ts +// in JwtModule registration (auth.module.ts) +JwtModule.registerAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + secret: config.getOrThrow('JWT_SECRET'), + signOptions: { expiresIn: '15m' }, + }), +}), +``` + +Then remove the `secret` override from every `signAsync` / `verifyAsync` call. The module-level secret is used automatically. + +--- + +### 2. `userOrganiaztionRequestAction` uses `this.prisma` inside a `$transaction` `[critical]` + +**File:** `src/organization-membership/organization-membership.service.ts:226` + +```ts +return await this.prisma.$transaction(async (tx) => { + await tx.organizationJoinRequest.update(...) // ✅ uses tx + + if (userAction === ORGANIZATION_JOIN_REQUEST.ACCEPTED) + await this.prisma.organizationUserJoinTable.create(...) // ❌ uses this.prisma — NOT the tx +}) +``` + +The `create` inside the transaction is using `this.prisma` (the global client) instead of `tx` (the transaction client). If the second write fails, the first write is NOT rolled back. The whole point of wrapping in `$transaction` is broken here. + +**Fix:** + +```ts +await tx.organizationUserJoinTable.create({ ... }) +``` + +--- + +### 3. Two flows conflated into one method — and the semantics are wrong `[high]` + +**File:** `src/organization-membership/organization-membership.service.ts:180` + +`userOrganiaztionRequestAction` is currently called by the user (the invitee) to accept or reject an invitation. But based on your plan, you also want an org admin to accept/reject a join request. These are fundamentally different actors: + +| Flow | Actor | What they act on | +| -------------------------- | ---------------------- | ---------------------------------- | +| Accept/reject invitation | The invited **user** | Their own pending `INVITED` record | +| Accept/reject join request | An **org admin/owner** | Someone else's `REQUESTED` record | + +Right now the method doesn't distinguish by `requestType`. An org admin could use it to act on _their own_ data when they should be acting on _another user's_ data. They need to be two methods: + +- `userRespondToInvitation(userId, dto)` — user accepts/rejects where `requestType = INVITED` +- `adminRespondToJoinRequest(adminUserId, targetUserId, orgId, action)` — admin acts on `requestType = REQUESTED`, with a `canPerformOperation` check first + +This is not a minor naming issue. It's a modeling issue. The current code would let a user "accept" their own join request. + +--- + +### 4. `organizationExists` vs `findById` — two methods doing nearly the same thing `[high]` + +**File:** `src/organization/organization.service.ts:121` + +```ts +async organizationExists(orgId: string) { + return await this.prisma.organization.findUnique({ + where: { id: orgId }, + select: { id: true }, // minimal select — just checking existence + }); +} + +async findById(orgId: string) { + return await this.cacheService.getOrSet(orgId, async () => + await this.prisma.organization.findUnique({ where: { id: orgId } }), + ); + // returns full org object, goes through cache +} +``` + +These have different purposes (one is a lightweight existence check, one returns full data with caching) but the distinction is opaque from the call site. `OrganizationMembershipService` calls both, inconsistently. + +More importantly: `organizationExists` bypasses the cache entirely. If you call `findById` first (which caches), then call `organizationExists`, you've now made an extra DB round-trip for no reason. + +**What to do:** + +- Consolidate. Use `findById` (cached) everywhere. +- If you need a boolean check, add a thin wrapper: `async assertOrganizationExists(orgId)` that calls `findById` and throws `NotFoundException` if null. Then services call that one line instead of the two-line check-then-throw pattern you repeat everywhere. +- Delete `organizationExists` or make it call `findById` internally. + +--- + +### 5. Repeated guard pattern — check existence, throw — everywhere `[high]` + +You do this in almost every method: + +```ts +const orgExists = await this.orgService.findById(dto.orgId); +if (!orgExists) throw new NotFoundException('Organization'); +``` + +And separately: + +```ts +const canUserDeleteOrganization = await this.authorization.canPerformOperation(...); +if (!canUserDeleteOrganization) throw new ForbiddenException('Not enough permission'); +``` + +This is the "guard clause" pattern and the repetition itself isn't wrong — guard clauses are good. What's wrong is that the _authorization check_ is manually wired in every service method instead of being declarative. This will become painful as you add more operations. + +**Better pattern — make `canPerformOperation` throw directly:** + +```ts +// authorization.service.ts +async assertCanPerformOperation( + userId: string, + orgId: string, + operation: USER_ORGANIZATION_OPERATIONS, +): Promise { + const allowed = await this.canPerformOperation(userId, orgId, operation); + if (!allowed) throw new ForbiddenException('Insufficient permissions'); +} +``` + +Now the call site is one line with no `if`: + +```ts +await this.authorization.assertCanPerformOperation(userId, orgId, INVITE_USERS); +``` + +Clean. Unambiguous. And you can add logging, metrics, or audit trail in one place. + +--- + +### 6. `isAdmin()` is dead code `[medium]` + +**File:** `src/authorization/authorization.service.ts:49` + +`isAdmin()` is defined but never called. Either use it in `canInvite` (which currently re-implements the same logic inline) or delete it. + +```ts +// current canInvite re-implements admin check inline: +return ( + isUserPartOfOrganization.role === ORG_ROLE.admin || + isUserPartOfOrganization.role === ORG_ROLE.owner +); + +// isAdmin() does the same thing but only for admin role. +// Clean it up or wire it in. +``` + +--- + +### 7. `AuthorizationService` hits the DB every time — no caching `[medium]` + +**File:** `src/authorization/authorization.service.ts:61` + +Every call to `canPerformOperation` does a `findUnique` on `OrganizationUserJoinTable`. In `inviteUserToOrg` alone, you've already called `orgService.findById` (which hits cache), but then `canPerformOperation` hits DB cold. Membership data is relatively stable — it changes when someone joins, leaves, or is promoted. + +**Fix:** Cache the membership record in `CacheService` using a compound key like `membership:{userId}:{orgId}`. Invalidate it when a member joins, leaves, or their role changes. + +```ts +const key = `membership:${userId}:${orgId}`; +return this.cacheService.getOrSet(key, () => + this.prisma.organizationUserJoinTable.findUnique(...) +); +``` + +--- + +### 8. `deleteAnOrganization` has broken cascade logic `[medium]` + +**File:** `src/organization/organization.service.ts:99` + +```ts +await tx.organization.delete({ where: { id: orgId } }); + +await tx.organizationUserJoinTable.delete({ + where: { userId_orgId: { userId, orgId } }, // only deletes the OWNER's row +}); +``` + +This only deletes the owner's row from the join table. All other members' rows are left orphaned (or fail with a FK constraint — depending on your Prisma schema cascade setting). Also, `OrganizationJoinRequest` records for this org are not cleaned up. + +You should either: + +- Set `onDelete: Cascade` in the Prisma schema on those relations (so deleting the org auto-cleans everything), or +- Manually `deleteMany` all join rows and requests in the transaction before deleting the org + +Option 1 (schema cascade) is cleaner for this case. + +--- + +### 9. `console.log` / `console.warn` in production code `[medium]` + +**Files:** `src/cache/cache.service.ts:13,18`, `src/cache/cache.service.ts:62`, `src/prisma/prisma.service.ts:40` + +```ts +console.log(a); // in deleteKey — what is `a`? +console.warn('Redis disconnected'); +``` + +Use NestJS's built-in `Logger`: + +```ts +import { Logger } from '@nestjs/common'; + +private readonly logger = new Logger(CacheService.name); + +this.logger.warn('Redis disconnected'); +this.logger.error('Prisma connection failed', err.stack); +``` + +The NestJS logger respects log levels, outputs structured logs, and can be replaced with a production logger (Pino, Winston) without touching service code. + +--- + +### 10. Controller stubs are wired wrong — copy-paste bug `[medium]` + +**File:** `src/organization-membership/organization-membership.controller.ts:54` + +```ts +@Get('organization/:id/invitations') +async getOrganizationInvitations(@Param('id') orgId: string) { + return await this.orgMemService.getMemebersOfOrganization(orgId); // ❌ wrong method +} +``` + +`getOrganizationInvitations` is calling `getMemebersOfOrganization`. Both endpoints return the same data. This is a copy-paste leftover. + +--- + +### 11. `userOrganizationJoinRequestList` does manual enum validation `[low]` + +**File:** `src/organization-membership/organization-membership.service.ts:135` + +```ts +async userOrganizationJoinRequestList(userId: string, requestType: string) { + const joinReqType: ORGANIZATION_JOIN_REQUEST_TYPE | undefined = + ORGANIZATION_JOIN_REQUEST_TYPE[requestType]; + if (!joinReqType) throw new BadRequestException('Invalid request type'); +``` + +This manual enum validation in the service layer exists because `requestType` comes in as a raw `string`. The fix is to validate it in the DTO/query params using `@IsEnum(ORGANIZATION_JOIN_REQUEST_TYPE)`. Then the service never receives an invalid value — NestJS's validation pipe rejects it before it gets there. The service layer should not be doing input validation. + +--- + +### 12. `BaseException` exists but is never used `[low]` + +**File:** `common/exceptions/custom-exceptions.ts` + +You built a `BaseException` class with `code`, `message`, and `HttpStatus`. Nothing uses it. Either start using it for domain-specific errors (e.g., `OrganizationNotFoundError extends BaseException`) or delete it. Dead abstractions add noise. + +--- + +### 13. Typos in method and variable names `[low]` + +- `userOrganiaztionRequestAction` → `userOrganizationRequestAction` +- `getMemebersOfOrganization` → `getMembersOfOrganization` + +These propagate to controller method names, controller routes, and service interface. Fix them now while the surface area is small. + +--- + +## General Guidelines to Internalize + +### On DRY vs. "right abstraction" + +DRY (Don't Repeat Yourself) is often misapplied. The goal isn't zero duplication — it's avoiding duplication of _knowledge_ (the same decision made in two places). Two methods that both check `if (!org) throw NotFoundException` are DRY violations only if they're checking the same _thing_. The right fix is `assertOrganizationExists()` — a single function that owns the "org must exist" rule. The callers delegate to it. + +### On service layer responsibilities + +A service method should do one thing: execute a business operation. Input validation belongs in DTOs + pipes. Authorization belongs in guards or a dedicated authorization service (you're halfway there). Existence checks belong in helper methods on the service. What remains in the method body should read like a business narrative: "check user is allowed → do the thing → return result." + +### On the transaction client (`this.prisma.client`) + +You built the ALS-based `prisma.client` getter for shared transactions across services. It's a good idea — but it only works if services call `this.prisma.client.xxx` instead of `this.prisma.xxx`. Right now no service uses it. Either commit to the pattern (use `client` everywhere) or remove the getter to avoid confusion. Half-implemented patterns are worse than no pattern — they create false confidence. + +### On authorization: where you are vs. where to go + +**Where you are:** operation-based (`canPerformOperation` switch). This is fine for 3-5 operations. As the app grows, the switch becomes unmaintainable and you end up with logic spread across the switch, `isOwner`, `canInvite`, etc. + +**Where to go next:** a role-permission matrix. + +```ts +const PERMISSIONS: Record = { + [ORG_ROLE.owner]: [ + UPDATE_ORGANIZATION, + DELETE_ORGANIZATION, + INVITE_USERS, + REMOVE_MEMBERS, + ], + [ORG_ROLE.admin]: [INVITE_USERS, REMOVE_MEMBERS], + [ORG_ROLE.member]: [], +}; +``` + +Then `canPerformOperation` becomes: + +```ts +async canPerformOperation(userId, orgId, operation) { + const membership = await this.getMembership(userId, orgId); + if (!membership) return false; + return PERMISSIONS[membership.role].includes(operation); +} +``` + +Adding a new operation is one line in the matrix. No new DB queries, no new switch case. + +**Beyond that:** [CASL](https://casl.js.org/) is the standard Node.js authorization library for more complex scenarios (attribute-level permissions, "can user edit _this specific_ resource"). You don't need it now but it's worth knowing it exists. + +### On caching: what to cache, what to invalidate + +Cache data that is: (a) read frequently, (b) expensive to recompute, (c) stable. Organization data and membership data fit all three. The rule is simple: **every write must invalidate or update the relevant cache key**. Right now you invalidate on org delete but not on org update. When you cache membership, you must invalidate on role change, member removal, and member addition. + +Use structured cache keys: `org:{orgId}`, `membership:{userId}:{orgId}`. Bare IDs as keys (like you're doing now with just `orgId`) work but collide if you ever cache different types under the same store. + +### On the two-flow membership design (your plan) + +Your instinct to split into two separate flows is correct. Model it explicitly: + +- `POST /organization-membership/invite` — org admin invites a user (creates `INVITED` record) +- `POST /organization-membership/request` — user requests to join (creates `REQUESTED` record) +- `PATCH /organization-membership/user/respond` — **user** accepts/rejects their own `INVITED` record +- `PATCH /organization-membership/organization/:id/respond` — **admin** accepts/rejects a `REQUESTED` record + +The actor and the target record type are different in each case. Wire your service methods to match this — each method should assert `requestType` before acting. + +### On what to build next (priority order) + +1. Fix the `$transaction` bug (item 2 above) — data integrity issue +2. Split the two membership flows into two methods +3. Move JWT secret to `ConfigService` +4. Add `assertOrganizationExists()` / `assertCanPerformOperation()` helpers — this cleans up every service method +5. Fix the cascade on org delete +6. Wire up the remaining controller stubs +7. Add pagination to `getOrganizations()` +8. Replace `console.log` with `Logger` +9. Cache membership in `AuthorizationService` +10. Delete dead code (`isAdmin`, `BaseException`, commented-out `acceptInvitation`) diff --git a/common/interceptors/response.interceptor.ts b/common/interceptors/response.interceptor.ts index b2ba899..9927eab 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 { catchError, map } from 'rxjs/operators'; +import { map } from 'rxjs/operators'; @Injectable() export class ResponseInterceptor implements NestInterceptor { diff --git a/prisma/generated/prisma/enums.ts b/prisma/generated/prisma/enums.ts index 095fe01..c0353e0 100644 --- a/prisma/generated/prisma/enums.ts +++ b/prisma/generated/prisma/enums.ts @@ -12,7 +12,8 @@ export const ORGANIZATION_JOIN_REQUEST = { PENDING: 'PENDING', ACCEPTED: 'ACCEPTED', - REJECTED: 'REJECTED' + REJECTED: 'REJECTED', + CANCELLED: 'CANCELLED' } as const export type ORGANIZATION_JOIN_REQUEST = (typeof ORGANIZATION_JOIN_REQUEST)[keyof typeof ORGANIZATION_JOIN_REQUEST] diff --git a/prisma/generated/prisma/internal/class.ts b/prisma/generated/prisma/internal/class.ts index b5ee1f3..853736c 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 requestType ORGANIZATION_JOIN_REQUEST_TYPE\n requestedOn DateTime @default(now())\n role ORG_ROLE @default(member)\n updatedAt DateTime @updatedAt\n rejectReason String?\n\n organization Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\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 organization Organization @relation(fields: [orgId], references: [id], onDelete: Restrict)\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\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 members OrganizationUserJoinTable[]\n requestingMembers OrganizationJoinRequest[]\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 organizations OrganizationUserJoinTable[]\n organizationsRequested OrganizationJoinRequest[]\n\n @@map(\"user\")\n}\n\nenum USER_ROLE {\n superadmin\n user\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 role ORG_ROLE @default(member)\n updatedAt DateTime @updatedAt\n rejectReason String?\n requestMessage String?\n\n organization Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@unique([userId, orgId])\n @@map(\"organization_join_request\")\n}\n\nenum ORGANIZATION_JOIN_REQUEST {\n PENDING\n ACCEPTED\n REJECTED\n CANCELLED\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 organization Organization @relation(fields: [orgId], references: [id], onDelete: Restrict)\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\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 members OrganizationUserJoinTable[]\n requestingMembers OrganizationJoinRequest[]\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 organizations OrganizationUserJoinTable[]\n organizationsRequested OrganizationJoinRequest[]\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\":\"requestType\",\"kind\":\"enum\",\"type\":\"ORGANIZATION_JOIN_REQUEST_TYPE\"},{\"name\":\"requestedOn\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"role\",\"kind\":\"enum\",\"type\":\"ORG_ROLE\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"rejectReason\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"organization\",\"kind\":\"object\",\"type\":\"Organization\",\"relationName\":\"OrganizationToOrganizationJoinRequest\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"OrganizationJoinRequestToUser\"}],\"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\"},{\"name\":\"organization\",\"kind\":\"object\",\"type\":\"Organization\",\"relationName\":\"OrganizationToOrganizationUserJoinTable\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"OrganizationUserJoinTableToUser\"}],\"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\"},{\"name\":\"members\",\"kind\":\"object\",\"type\":\"OrganizationUserJoinTable\",\"relationName\":\"OrganizationToOrganizationUserJoinTable\"},{\"name\":\"requestingMembers\",\"kind\":\"object\",\"type\":\"OrganizationJoinRequest\",\"relationName\":\"OrganizationToOrganizationJoinRequest\"}],\"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\"},{\"name\":\"organizations\",\"kind\":\"object\",\"type\":\"OrganizationUserJoinTable\",\"relationName\":\"OrganizationUserJoinTableToUser\"},{\"name\":\"organizationsRequested\",\"kind\":\"object\",\"type\":\"OrganizationJoinRequest\",\"relationName\":\"OrganizationJoinRequestToUser\"}],\"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\":\"role\",\"kind\":\"enum\",\"type\":\"ORG_ROLE\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"rejectReason\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"requestMessage\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"organization\",\"kind\":\"object\",\"type\":\"Organization\",\"relationName\":\"OrganizationToOrganizationJoinRequest\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"OrganizationJoinRequestToUser\"}],\"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\"},{\"name\":\"organization\",\"kind\":\"object\",\"type\":\"Organization\",\"relationName\":\"OrganizationToOrganizationUserJoinTable\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"OrganizationUserJoinTableToUser\"}],\"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\"},{\"name\":\"members\",\"kind\":\"object\",\"type\":\"OrganizationUserJoinTable\",\"relationName\":\"OrganizationToOrganizationUserJoinTable\"},{\"name\":\"requestingMembers\",\"kind\":\"object\",\"type\":\"OrganizationJoinRequest\",\"relationName\":\"OrganizationToOrganizationJoinRequest\"}],\"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\"},{\"name\":\"organizations\",\"kind\":\"object\",\"type\":\"OrganizationUserJoinTable\",\"relationName\":\"OrganizationUserJoinTableToUser\"},{\"name\":\"organizationsRequested\",\"kind\":\"object\",\"type\":\"OrganizationJoinRequest\",\"relationName\":\"OrganizationJoinRequestToUser\"}],\"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 bbdde86..0df8d51 100644 --- a/prisma/generated/prisma/internal/prismaNamespace.ts +++ b/prisma/generated/prisma/internal/prismaNamespace.ts @@ -750,7 +750,8 @@ export const OrganizationJoinRequestScalarFieldEnum = { requestedOn: 'requestedOn', role: 'role', updatedAt: 'updatedAt', - rejectReason: 'rejectReason' + rejectReason: 'rejectReason', + requestMessage: 'requestMessage' } as const export type OrganizationJoinRequestScalarFieldEnum = (typeof OrganizationJoinRequestScalarFieldEnum)[keyof typeof OrganizationJoinRequestScalarFieldEnum] diff --git a/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts b/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts index 1160794..1f73dca 100644 --- a/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts +++ b/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts @@ -81,7 +81,8 @@ export const OrganizationJoinRequestScalarFieldEnum = { requestedOn: 'requestedOn', role: 'role', updatedAt: 'updatedAt', - rejectReason: 'rejectReason' + rejectReason: 'rejectReason', + requestMessage: 'requestMessage' } as const export type OrganizationJoinRequestScalarFieldEnum = (typeof OrganizationJoinRequestScalarFieldEnum)[keyof typeof OrganizationJoinRequestScalarFieldEnum] diff --git a/prisma/generated/prisma/models/OrganizationJoinRequest.ts b/prisma/generated/prisma/models/OrganizationJoinRequest.ts index f8b69aa..cc29063 100644 --- a/prisma/generated/prisma/models/OrganizationJoinRequest.ts +++ b/prisma/generated/prisma/models/OrganizationJoinRequest.ts @@ -33,6 +33,7 @@ export type OrganizationJoinRequestMinAggregateOutputType = { role: $Enums.ORG_ROLE | null updatedAt: Date | null rejectReason: string | null + requestMessage: string | null } export type OrganizationJoinRequestMaxAggregateOutputType = { @@ -44,6 +45,7 @@ export type OrganizationJoinRequestMaxAggregateOutputType = { role: $Enums.ORG_ROLE | null updatedAt: Date | null rejectReason: string | null + requestMessage: string | null } export type OrganizationJoinRequestCountAggregateOutputType = { @@ -55,6 +57,7 @@ export type OrganizationJoinRequestCountAggregateOutputType = { role: number updatedAt: number rejectReason: number + requestMessage: number _all: number } @@ -68,6 +71,7 @@ export type OrganizationJoinRequestMinAggregateInputType = { role?: true updatedAt?: true rejectReason?: true + requestMessage?: true } export type OrganizationJoinRequestMaxAggregateInputType = { @@ -79,6 +83,7 @@ export type OrganizationJoinRequestMaxAggregateInputType = { role?: true updatedAt?: true rejectReason?: true + requestMessage?: true } export type OrganizationJoinRequestCountAggregateInputType = { @@ -90,6 +95,7 @@ export type OrganizationJoinRequestCountAggregateInputType = { role?: true updatedAt?: true rejectReason?: true + requestMessage?: true _all?: true } @@ -174,6 +180,7 @@ export type OrganizationJoinRequestGroupByOutputType = { role: $Enums.ORG_ROLE updatedAt: Date rejectReason: string | null + requestMessage: string | null _count: OrganizationJoinRequestCountAggregateOutputType | null _min: OrganizationJoinRequestMinAggregateOutputType | null _max: OrganizationJoinRequestMaxAggregateOutputType | null @@ -206,6 +213,7 @@ export type OrganizationJoinRequestWhereInput = { role?: Prisma.EnumORG_ROLEFilter<"OrganizationJoinRequest"> | $Enums.ORG_ROLE updatedAt?: Prisma.DateTimeFilter<"OrganizationJoinRequest"> | Date | string rejectReason?: Prisma.StringNullableFilter<"OrganizationJoinRequest"> | string | null + requestMessage?: Prisma.StringNullableFilter<"OrganizationJoinRequest"> | string | null organization?: Prisma.XOR user?: Prisma.XOR } @@ -219,6 +227,7 @@ export type OrganizationJoinRequestOrderByWithRelationInput = { role?: Prisma.SortOrder updatedAt?: Prisma.SortOrder rejectReason?: Prisma.SortOrderInput | Prisma.SortOrder + requestMessage?: Prisma.SortOrderInput | Prisma.SortOrder organization?: Prisma.OrganizationOrderByWithRelationInput user?: Prisma.UserOrderByWithRelationInput } @@ -236,6 +245,7 @@ export type OrganizationJoinRequestWhereUniqueInput = Prisma.AtLeast<{ role?: Prisma.EnumORG_ROLEFilter<"OrganizationJoinRequest"> | $Enums.ORG_ROLE updatedAt?: Prisma.DateTimeFilter<"OrganizationJoinRequest"> | Date | string rejectReason?: Prisma.StringNullableFilter<"OrganizationJoinRequest"> | string | null + requestMessage?: Prisma.StringNullableFilter<"OrganizationJoinRequest"> | string | null organization?: Prisma.XOR user?: Prisma.XOR }, "userId_orgId"> @@ -249,6 +259,7 @@ export type OrganizationJoinRequestOrderByWithAggregationInput = { role?: Prisma.SortOrder updatedAt?: Prisma.SortOrder rejectReason?: Prisma.SortOrderInput | Prisma.SortOrder + requestMessage?: Prisma.SortOrderInput | Prisma.SortOrder _count?: Prisma.OrganizationJoinRequestCountOrderByAggregateInput _max?: Prisma.OrganizationJoinRequestMaxOrderByAggregateInput _min?: Prisma.OrganizationJoinRequestMinOrderByAggregateInput @@ -266,6 +277,7 @@ export type OrganizationJoinRequestScalarWhereWithAggregatesInput = { role?: Prisma.EnumORG_ROLEWithAggregatesFilter<"OrganizationJoinRequest"> | $Enums.ORG_ROLE updatedAt?: Prisma.DateTimeWithAggregatesFilter<"OrganizationJoinRequest"> | Date | string rejectReason?: Prisma.StringNullableWithAggregatesFilter<"OrganizationJoinRequest"> | string | null + requestMessage?: Prisma.StringNullableWithAggregatesFilter<"OrganizationJoinRequest"> | string | null } export type OrganizationJoinRequestCreateInput = { @@ -275,6 +287,7 @@ export type OrganizationJoinRequestCreateInput = { role?: $Enums.ORG_ROLE updatedAt?: Date | string rejectReason?: string | null + requestMessage?: string | null organization: Prisma.OrganizationCreateNestedOneWithoutRequestingMembersInput user: Prisma.UserCreateNestedOneWithoutOrganizationsRequestedInput } @@ -288,6 +301,7 @@ export type OrganizationJoinRequestUncheckedCreateInput = { role?: $Enums.ORG_ROLE updatedAt?: Date | string rejectReason?: string | null + requestMessage?: string | null } export type OrganizationJoinRequestUpdateInput = { @@ -297,6 +311,7 @@ export type OrganizationJoinRequestUpdateInput = { role?: Prisma.EnumORG_ROLEFieldUpdateOperationsInput | $Enums.ORG_ROLE updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string rejectReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + requestMessage?: Prisma.NullableStringFieldUpdateOperationsInput | string | null organization?: Prisma.OrganizationUpdateOneRequiredWithoutRequestingMembersNestedInput user?: Prisma.UserUpdateOneRequiredWithoutOrganizationsRequestedNestedInput } @@ -310,6 +325,7 @@ export type OrganizationJoinRequestUncheckedUpdateInput = { role?: Prisma.EnumORG_ROLEFieldUpdateOperationsInput | $Enums.ORG_ROLE updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string rejectReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + requestMessage?: Prisma.NullableStringFieldUpdateOperationsInput | string | null } export type OrganizationJoinRequestCreateManyInput = { @@ -321,6 +337,7 @@ export type OrganizationJoinRequestCreateManyInput = { role?: $Enums.ORG_ROLE updatedAt?: Date | string rejectReason?: string | null + requestMessage?: string | null } export type OrganizationJoinRequestUpdateManyMutationInput = { @@ -330,6 +347,7 @@ export type OrganizationJoinRequestUpdateManyMutationInput = { role?: Prisma.EnumORG_ROLEFieldUpdateOperationsInput | $Enums.ORG_ROLE updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string rejectReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + requestMessage?: Prisma.NullableStringFieldUpdateOperationsInput | string | null } export type OrganizationJoinRequestUncheckedUpdateManyInput = { @@ -341,6 +359,7 @@ export type OrganizationJoinRequestUncheckedUpdateManyInput = { role?: Prisma.EnumORG_ROLEFieldUpdateOperationsInput | $Enums.ORG_ROLE updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string rejectReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + requestMessage?: Prisma.NullableStringFieldUpdateOperationsInput | string | null } export type OrganizationJoinRequestUserIdOrgIdCompoundUniqueInput = { @@ -357,6 +376,7 @@ export type OrganizationJoinRequestCountOrderByAggregateInput = { role?: Prisma.SortOrder updatedAt?: Prisma.SortOrder rejectReason?: Prisma.SortOrder + requestMessage?: Prisma.SortOrder } export type OrganizationJoinRequestMaxOrderByAggregateInput = { @@ -368,6 +388,7 @@ export type OrganizationJoinRequestMaxOrderByAggregateInput = { role?: Prisma.SortOrder updatedAt?: Prisma.SortOrder rejectReason?: Prisma.SortOrder + requestMessage?: Prisma.SortOrder } export type OrganizationJoinRequestMinOrderByAggregateInput = { @@ -379,6 +400,7 @@ export type OrganizationJoinRequestMinOrderByAggregateInput = { role?: Prisma.SortOrder updatedAt?: Prisma.SortOrder rejectReason?: Prisma.SortOrder + requestMessage?: Prisma.SortOrder } export type OrganizationJoinRequestListRelationFilter = { @@ -506,6 +528,7 @@ export type OrganizationJoinRequestCreateWithoutOrganizationInput = { role?: $Enums.ORG_ROLE updatedAt?: Date | string rejectReason?: string | null + requestMessage?: string | null user: Prisma.UserCreateNestedOneWithoutOrganizationsRequestedInput } @@ -517,6 +540,7 @@ export type OrganizationJoinRequestUncheckedCreateWithoutOrganizationInput = { role?: $Enums.ORG_ROLE updatedAt?: Date | string rejectReason?: string | null + requestMessage?: string | null } export type OrganizationJoinRequestCreateOrConnectWithoutOrganizationInput = { @@ -557,6 +581,7 @@ export type OrganizationJoinRequestScalarWhereInput = { role?: Prisma.EnumORG_ROLEFilter<"OrganizationJoinRequest"> | $Enums.ORG_ROLE updatedAt?: Prisma.DateTimeFilter<"OrganizationJoinRequest"> | Date | string rejectReason?: Prisma.StringNullableFilter<"OrganizationJoinRequest"> | string | null + requestMessage?: Prisma.StringNullableFilter<"OrganizationJoinRequest"> | string | null } export type OrganizationJoinRequestCreateWithoutUserInput = { @@ -566,6 +591,7 @@ export type OrganizationJoinRequestCreateWithoutUserInput = { role?: $Enums.ORG_ROLE updatedAt?: Date | string rejectReason?: string | null + requestMessage?: string | null organization: Prisma.OrganizationCreateNestedOneWithoutRequestingMembersInput } @@ -577,6 +603,7 @@ export type OrganizationJoinRequestUncheckedCreateWithoutUserInput = { role?: $Enums.ORG_ROLE updatedAt?: Date | string rejectReason?: string | null + requestMessage?: string | null } export type OrganizationJoinRequestCreateOrConnectWithoutUserInput = { @@ -613,6 +640,7 @@ export type OrganizationJoinRequestCreateManyOrganizationInput = { role?: $Enums.ORG_ROLE updatedAt?: Date | string rejectReason?: string | null + requestMessage?: string | null } export type OrganizationJoinRequestUpdateWithoutOrganizationInput = { @@ -622,6 +650,7 @@ export type OrganizationJoinRequestUpdateWithoutOrganizationInput = { role?: Prisma.EnumORG_ROLEFieldUpdateOperationsInput | $Enums.ORG_ROLE updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string rejectReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + requestMessage?: Prisma.NullableStringFieldUpdateOperationsInput | string | null user?: Prisma.UserUpdateOneRequiredWithoutOrganizationsRequestedNestedInput } @@ -633,6 +662,7 @@ export type OrganizationJoinRequestUncheckedUpdateWithoutOrganizationInput = { role?: Prisma.EnumORG_ROLEFieldUpdateOperationsInput | $Enums.ORG_ROLE updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string rejectReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + requestMessage?: Prisma.NullableStringFieldUpdateOperationsInput | string | null } export type OrganizationJoinRequestUncheckedUpdateManyWithoutOrganizationInput = { @@ -643,6 +673,7 @@ export type OrganizationJoinRequestUncheckedUpdateManyWithoutOrganizationInput = role?: Prisma.EnumORG_ROLEFieldUpdateOperationsInput | $Enums.ORG_ROLE updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string rejectReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + requestMessage?: Prisma.NullableStringFieldUpdateOperationsInput | string | null } export type OrganizationJoinRequestCreateManyUserInput = { @@ -653,6 +684,7 @@ export type OrganizationJoinRequestCreateManyUserInput = { role?: $Enums.ORG_ROLE updatedAt?: Date | string rejectReason?: string | null + requestMessage?: string | null } export type OrganizationJoinRequestUpdateWithoutUserInput = { @@ -662,6 +694,7 @@ export type OrganizationJoinRequestUpdateWithoutUserInput = { role?: Prisma.EnumORG_ROLEFieldUpdateOperationsInput | $Enums.ORG_ROLE updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string rejectReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + requestMessage?: Prisma.NullableStringFieldUpdateOperationsInput | string | null organization?: Prisma.OrganizationUpdateOneRequiredWithoutRequestingMembersNestedInput } @@ -673,6 +706,7 @@ export type OrganizationJoinRequestUncheckedUpdateWithoutUserInput = { role?: Prisma.EnumORG_ROLEFieldUpdateOperationsInput | $Enums.ORG_ROLE updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string rejectReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + requestMessage?: Prisma.NullableStringFieldUpdateOperationsInput | string | null } export type OrganizationJoinRequestUncheckedUpdateManyWithoutUserInput = { @@ -683,6 +717,7 @@ export type OrganizationJoinRequestUncheckedUpdateManyWithoutUserInput = { role?: Prisma.EnumORG_ROLEFieldUpdateOperationsInput | $Enums.ORG_ROLE updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string rejectReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + requestMessage?: Prisma.NullableStringFieldUpdateOperationsInput | string | null } @@ -696,6 +731,7 @@ export type OrganizationJoinRequestSelect user?: boolean | Prisma.UserDefaultArgs }, ExtArgs["result"]["organizationJoinRequest"]> @@ -709,6 +745,7 @@ export type OrganizationJoinRequestSelectCreateManyAndReturn user?: boolean | Prisma.UserDefaultArgs }, ExtArgs["result"]["organizationJoinRequest"]> @@ -722,6 +759,7 @@ export type OrganizationJoinRequestSelectUpdateManyAndReturn user?: boolean | Prisma.UserDefaultArgs }, ExtArgs["result"]["organizationJoinRequest"]> @@ -735,9 +773,10 @@ export type OrganizationJoinRequestSelectScalar = { role?: boolean updatedAt?: boolean rejectReason?: boolean + requestMessage?: boolean } -export type OrganizationJoinRequestOmit = runtime.Types.Extensions.GetOmit<"userId" | "orgId" | "status" | "requestType" | "requestedOn" | "role" | "updatedAt" | "rejectReason", ExtArgs["result"]["organizationJoinRequest"]> +export type OrganizationJoinRequestOmit = runtime.Types.Extensions.GetOmit<"userId" | "orgId" | "status" | "requestType" | "requestedOn" | "role" | "updatedAt" | "rejectReason" | "requestMessage", ExtArgs["result"]["organizationJoinRequest"]> export type OrganizationJoinRequestInclude = { organization?: boolean | Prisma.OrganizationDefaultArgs user?: boolean | Prisma.UserDefaultArgs @@ -766,6 +805,7 @@ export type $OrganizationJoinRequestPayload composites: {} } @@ -1199,6 +1239,7 @@ export interface OrganizationJoinRequestFieldRefs { readonly role: Prisma.FieldRef<"OrganizationJoinRequest", 'ORG_ROLE'> readonly updatedAt: Prisma.FieldRef<"OrganizationJoinRequest", 'DateTime'> readonly rejectReason: Prisma.FieldRef<"OrganizationJoinRequest", 'String'> + readonly requestMessage: Prisma.FieldRef<"OrganizationJoinRequest", 'String'> } diff --git a/prisma/migrations/20260304063144_request_reason_in_join_request/migration.sql b/prisma/migrations/20260304063144_request_reason_in_join_request/migration.sql new file mode 100644 index 0000000..5d723f7 --- /dev/null +++ b/prisma/migrations/20260304063144_request_reason_in_join_request/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "organization_join_request" ADD COLUMN "requestMessage" TEXT; diff --git a/prisma/migrations/20260304064831_add_new_cancelled_status_in_organization_join_req/migration.sql b/prisma/migrations/20260304064831_add_new_cancelled_status_in_organization_join_req/migration.sql new file mode 100644 index 0000000..af5102c --- /dev/null +++ b/prisma/migrations/20260304064831_add_new_cancelled_status_in_organization_join_req/migration.sql @@ -0,0 +1 @@ +-- This is an empty migration. \ No newline at end of file diff --git a/prisma/migrations/20260304065334_add_cancelled_status_in_org_join_req/migration.sql b/prisma/migrations/20260304065334_add_cancelled_status_in_org_join_req/migration.sql new file mode 100644 index 0000000..c0ecc7c --- /dev/null +++ b/prisma/migrations/20260304065334_add_cancelled_status_in_org_join_req/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "ORGANIZATION_JOIN_REQUEST" ADD VALUE 'CANCELLED'; diff --git a/prisma/models/organization-join-request.prisma b/prisma/models/organization-join-request.prisma index 9d64d3c..81dc006 100644 --- a/prisma/models/organization-join-request.prisma +++ b/prisma/models/organization-join-request.prisma @@ -1,12 +1,13 @@ model OrganizationJoinRequest { - userId String - orgId String - status ORGANIZATION_JOIN_REQUEST @default(PENDING) - requestType ORGANIZATION_JOIN_REQUEST_TYPE - requestedOn DateTime @default(now()) - role ORG_ROLE @default(member) - updatedAt DateTime @updatedAt - rejectReason String? + userId String + orgId String + status ORGANIZATION_JOIN_REQUEST @default(PENDING) + requestType ORGANIZATION_JOIN_REQUEST_TYPE + requestedOn DateTime @default(now()) + role ORG_ROLE @default(member) + updatedAt DateTime @updatedAt + rejectReason String? + requestMessage String? organization Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -19,6 +20,7 @@ enum ORGANIZATION_JOIN_REQUEST { PENDING ACCEPTED REJECTED + CANCELLED } enum ORGANIZATION_JOIN_REQUEST_TYPE { diff --git a/src/cache/cache.service.ts b/src/cache/cache.service.ts index fca333c..239be5b 100644 --- a/src/cache/cache.service.ts +++ b/src/cache/cache.service.ts @@ -44,12 +44,14 @@ export class CacheService { // Fallback to DB const fresh = await factory(); - // Try setting cache only if Redis available - if (this.redisAvailable) { - try { - await this.cache.set(key, fresh, ttl); - } catch { - this.redisAvailable = false; + if (fresh) { + // Try setting cache only if Redis available + if (this.redisAvailable) { + try { + await this.cache.set(key, fresh, ttl); + } catch { + this.redisAvailable = false; + } } } diff --git a/src/main.ts b/src/main.ts index fb4cfc2..1102607 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,13 +2,14 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { ConfigService } from '@nestjs/config'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { Logger } from '@nestjs/common'; async function bootstrap() { const app = await NestFactory.create(AppModule); const swaggerConfig = new DocumentBuilder() - .setTitle('Kaa Khane') - .setDescription(`API Documentation for Kaa Khane`) + .setTitle('MultiTenant Saas') + .setDescription(`API Documentation for a simple MultiTenant Saas Application`) .setVersion('0.0.1') .addGlobalResponse( { @@ -46,5 +47,7 @@ async function bootstrap() { const port = config.get('PORT') ?? 3000; await app.listen(port); + + Logger.log(`Listning on port ${port}`) } bootstrap(); diff --git a/src/organization-membership/constants/index.ts b/src/organization-membership/constants/index.ts new file mode 100644 index 0000000..9b911d9 --- /dev/null +++ b/src/organization-membership/constants/index.ts @@ -0,0 +1 @@ +export * from "./type" diff --git a/src/organization-membership/constants/type.ts b/src/organization-membership/constants/type.ts new file mode 100644 index 0000000..46cf9f5 --- /dev/null +++ b/src/organization-membership/constants/type.ts @@ -0,0 +1,4 @@ +export enum USER_ORG_ACCEPT_REJECT_ACTION { + ACCEPT = 'ACCEPT', + REJECT = 'REJECT' +} diff --git a/src/organization-membership/dto/index.ts b/src/organization-membership/dto/index.ts index 8d635fe..0231791 100644 --- a/src/organization-membership/dto/index.ts +++ b/src/organization-membership/dto/index.ts @@ -1 +1,3 @@ export * from './invite-to-org.dto'; +export * from './join-request.dto' +export * from "./user-invitation-action.dto" diff --git a/src/organization-membership/dto/invite-to-org.dto.ts b/src/organization-membership/dto/invite-to-org.dto.ts index aaf805b..818bd22 100644 --- a/src/organization-membership/dto/invite-to-org.dto.ts +++ b/src/organization-membership/dto/invite-to-org.dto.ts @@ -12,15 +12,6 @@ export class InviteUserToOrganizationRequestDTO { @IsNotEmpty() invitedUserEmail: string; - @ApiProperty({ - description: 'Organization id', - example: 'eeec2c79-766a-4174-8004-2e57642095fe', - type: 'string', - }) - @IsUUID() - @IsNotEmpty() - orgId: string; - @ApiProperty({ description: 'Role to assign', example: ORG_ROLE.member, diff --git a/src/organization-membership/dto/join-request.dto.ts b/src/organization-membership/dto/join-request.dto.ts new file mode 100644 index 0000000..c727e11 --- /dev/null +++ b/src/organization-membership/dto/join-request.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsNotEmpty, IsOptional, IsString, IsUUID } from "class-validator"; + +export class JoinRequestToOrganizationRequestDTO { + @ApiPropertyOptional({ + description: 'Message along with the request', + example: 'I would like to join', + type: 'string', + }) + @IsString() + @IsOptional() + requestMessage?: string; +} diff --git a/src/organization-membership/dto/user-invitation-action.dto.ts b/src/organization-membership/dto/user-invitation-action.dto.ts new file mode 100644 index 0000000..92c49ab --- /dev/null +++ b/src/organization-membership/dto/user-invitation-action.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID } from "class-validator"; +import { USER_ORG_ACCEPT_REJECT_ACTION } from "../constants"; + + +export class UserOrganizationInvitationActionRequestDTO { + @ApiProperty({ + description: 'Action', + example: USER_ORG_ACCEPT_REJECT_ACTION.ACCEPT, + type: 'string', + }) + @IsEnum(USER_ORG_ACCEPT_REJECT_ACTION) + @IsNotEmpty() + action: string + + @ApiPropertyOptional({ + description: 'Message(reject reason)', + example: 'Bad sry or smth', + type: 'string', + }) + @IsString() + @IsOptional() + message?: string +} diff --git a/src/organization-membership/organization-membership.controller.ts b/src/organization-membership/organization-membership.controller.ts index ea59ae9..2f307a5 100644 --- a/src/organization-membership/organization-membership.controller.ts +++ b/src/organization-membership/organization-membership.controller.ts @@ -1,62 +1,145 @@ -import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, ParseEnumPipe, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; import { OrganizationMembershipService } from './organization-membership.service'; import { RequestContextService } from 'core/als/request-context.service'; -import { ApiBearerAuth } from '@nestjs/swagger'; -import { InviteUserToOrganizationRequestDTO } from './dto'; +import { ApiBearerAuth, ApiOperation, ApiParam, ApiQuery } from '@nestjs/swagger'; +import { InviteUserToOrganizationRequestDTO, JoinRequestToOrganizationRequestDTO, UserOrganizationInvitationActionRequestDTO } from './dto'; +import { ORGANIZATION_JOIN_REQUEST_TYPE } from 'prisma/generated/prisma/enums'; -@Controller('organization-membership') +/* NOTE: Regarding endpoint path naming + * - Since we follow REST style, endpoint are resource based. + * - So insted of /organization/:orgId/invitation-action, we user ..../invitation/:invitationId + * (invitationid points to a resource) + * */ + +@Controller('membership') @ApiBearerAuth('access-token') export class OrganizationMembershipController { constructor( private readonly orgMemService: OrganizationMembershipService, private readonly requestContext: RequestContextService, - ) {} + ) { } - @Post('/invite') - async inviteUserToOrg(@Body() body: InviteUserToOrganizationRequestDTO) { - const user = this.requestContext.user; - return await this.orgMemService.inviteUserToOrg(user.userId, body); - } - - @Post('/request') - async requestToJoinOrg() {} - - // TODO: Move invite to org. section and join to user. Also option to cancel invitation and join req. /* * * USER OPERATIONS * */ - @Patch('user/accept-invitation') - acceptInvitation() {} - - @Patch('user/reject-invitation') - rejectInvitation() {} - - @Get('user/invitations') - async getUserInvitations() { + @ApiOperation({ summary: 'Send request to join an organization' }) + @ApiParam({ + name: 'orgId', + type: String, + }) + @Post('organization/:orgId/join-request') + async requestToJoinOrg( + @Param('orgId', new ParseUUIDPipe()) orgId: string, + @Body() body: JoinRequestToOrganizationRequestDTO + ) { const user = this.requestContext.user; - return await this.orgMemService.getUserInvitations(user.userId); + return await this.orgMemService.usersRequestToJoin(user.userId, orgId, body) + } + + @ApiOperation({ summary: 'Cancel a sent invitation to join an organization' }) + @ApiParam({ + name: 'orgId', + type: String, + }) + @Delete('organization/:orgId/join-request') + async cancelRequestToJoinOrg(@Param('orgId', new ParseUUIDPipe()) orgId: string) { + const user = this.requestContext.user; + return await this.orgMemService.userCancelOrgJoinRequest(user.userId, orgId) + } + + @ApiOperation({ summary: 'Accept or reject an invitation from an organization' }) + @ApiParam({ + name: 'orgId', + type: String, + }) + @Patch('organization/:orgId/invitation') + async acceptOrRejectInvitation( + @Param('orgId', new ParseUUIDPipe()) orgId: string, + @Body() body: UserOrganizationInvitationActionRequestDTO + ) { + const user = this.requestContext.user; + return await this.orgMemService.userOrganiaztionRequestAction( + user.userId, + orgId, + body + ) + } + + @ApiOperation({ summary: 'List invitations recieved or join requests sent' }) + @ApiQuery({ + name: 'requestType', + enum: ORGANIZATION_JOIN_REQUEST_TYPE, + required: false, + }) + @Get('me/invitations') + async getUserInvitations( + @Query( + 'requestType', + new ParseEnumPipe( + ORGANIZATION_JOIN_REQUEST_TYPE, { optional: true } + ) + ) requestType?: ORGANIZATION_JOIN_REQUEST_TYPE + ) { + const user = this.requestContext.user; + return await this.orgMemService.getUserInvitations(user.userId, requestType); + } + + @ApiOperation({ summary: 'List organizations user is part of' }) + @Get('me/organizations') + async getUserOrganizations() { + const user = this.requestContext.user; + return await this.orgMemService.getOrganizationsOfUser(user.userId) + } + + @ApiOperation({ summary: 'Leave an organization' }) + @ApiParam({ + name: 'orgId', + type: String, + }) + @Delete('organization/:orgId/member/me') + async leaveOrganization( + @Param('orgId', new ParseUUIDPipe()) orgId: string, + ) { + const user = this.requestContext.user; + return await this.orgMemService.userLeaveAnOrganization(user.userId, orgId) } - @Get('user/organizations') - async getUserOrganizations() {} /* * * ORGANIZATION OPERATIONS * */ - @Get('organization/:id/members') - async getOrganizationMemebers(@Param('id') orgId: string) { - return await this.orgMemService.getMemebersOfOrganization(orgId); - } - @Get('organization/:id/invitations') - async getOrganizationInvitations(@Param('id') orgId: string) { - return await this.orgMemService.getMemebersOfOrganization(orgId); - } - - @Patch('organization/:id/accept-request') - acceptJoinRequest() {} - - @Patch('organization/:id/reject-request') - rejectJoinRequest() {} + // @ApiOperation({ summary: 'Invite user to organization' }) + // @ApiParam({ + // name: 'orgId', + // type: String, + // }) + // @Post('organization/:orgId/invitation') + // async inviteUserToOrg( + // @Param('orgId', new ParseUUIDPipe()) orgId: string, + // @Body() body: InviteUserToOrganizationRequestDTO + // ) { + // const user = this.requestContext.user; + // return await this.orgMemService.inviteUserToOrg( + // user.userId, + // orgId, + // body + // ); + // } + // @Get('organization/:id/members') + // async getOrganizationMemebers(@Param('id') orgId: string) { + // return await this.orgMemService.getMemebersOfOrganization(orgId); + // } + // + // @Get('organization/:id/invitations') + // async getOrganizationInvitations(@Param('id') orgId: string) { + // return await this.orgMemService.getOrganizationRequestList(orgId); + // } + // + // @Patch('organization/:id/accept-request') + // acceptJoinRequest() { } + // + // @Patch('organization/:id/reject-request') + // rejectJoinRequest() { } } diff --git a/src/organization-membership/organization-membership.service.ts b/src/organization-membership/organization-membership.service.ts index 9f098a2..c70bd58 100644 --- a/src/organization-membership/organization-membership.service.ts +++ b/src/organization-membership/organization-membership.service.ts @@ -6,7 +6,7 @@ import { } from '@nestjs/common'; import { OrganizationService } from 'src/organization/organization.service'; import { UserService } from 'src/user/user.service'; -import { InviteUserToOrganizationRequestDTO } from './dto'; +import { InviteUserToOrganizationRequestDTO, UserOrganizationInvitationActionRequestDTO } from './dto'; import { PrismaService } from 'src/prisma/prisma.service'; import { ORGANIZATION_JOIN_REQUEST, @@ -15,6 +15,8 @@ import { import { AuthorizationService } from 'src/authorization/authorization.service'; import { USER_ORGANIZATION_OPERATIONS } from 'src/authorization/operations'; import { Prisma } from 'prisma/generated/prisma/client'; +import { JoinRequestToOrganizationRequestDTO } from './dto/join-request.dto'; +import { USER_ORG_ACCEPT_REJECT_ACTION } from './constants'; @Injectable() export class OrganizationMembershipService { @@ -23,14 +25,15 @@ export class OrganizationMembershipService { private readonly orgService: OrganizationService, private readonly prisma: PrismaService, private readonly authorization: AuthorizationService, - ) {} + ) { } async inviteUserToOrg( userId: string, + orgId: string, dto: InviteUserToOrganizationRequestDTO, ) { const { invitedUserEmail, ...rest } = dto; const [orgExists, invitedUser] = await Promise.all([ - this.orgService.organizationExists(dto.orgId), + this.orgService.findById(orgId), this.userService.findByEmail(invitedUserEmail), ]); @@ -41,7 +44,7 @@ export class OrganizationMembershipService { await this.prisma.organizationUserJoinTable.findUnique({ where: { userId_orgId: { - orgId: dto.orgId, + orgId: orgId, userId: invitedUser.id, }, }, @@ -52,7 +55,7 @@ export class OrganizationMembershipService { const canInviteUser = await this.authorization.canPerformOperation( userId, - dto.orgId, + orgId, USER_ORGANIZATION_OPERATIONS.INVITE_USERS, ); if (!canInviteUser) throw new ForbiddenException('Insufficient Permission'); @@ -62,6 +65,7 @@ export class OrganizationMembershipService { data: { ...rest, userId: invitedUser.id, + orgId, requestType: ORGANIZATION_JOIN_REQUEST_TYPE.INVITED, }, }); @@ -77,63 +81,227 @@ export class OrganizationMembershipService { } } - requestToJoin() {} - - // TODO: reject, rejectReason - async acceptInvitation(userId: string, orgId: string) { - const orgExists = await this.orgService.organizationExists(orgId); - if (!orgExists) throw new NotFoundException('Organization'); - - const [userAlreadyPart, isUserInvited] = await Promise.all([ - this.prisma.organizationUserJoinTable.findUnique({ - where: { - userId_orgId: { - orgId, - userId, - }, - }, - }), - + /* * + * USER OPERATIONS + * */ + async usersRequestToJoin( + userId: string, + orgId: string, + dto: JoinRequestToOrganizationRequestDTO + ) { + const [ + orgExists, + invitationAlreadySent, + userAlreadyPartOf + ] = await Promise.all([ + this.orgService.findById(orgId), this.prisma.organizationJoinRequest.findUnique({ where: { userId_orgId: { - orgId, userId, + orgId: orgId }, - status: ORGANIZATION_JOIN_REQUEST.PENDING, + status: ORGANIZATION_JOIN_REQUEST.PENDING }, + select: { orgId: true } }), - ]); + this.prisma.organizationUserJoinTable.findUnique({ + where: { + userId_orgId: { + orgId: orgId, + userId + } + }, + select: { userId: true } + }) + ]) - if (userAlreadyPart) - throw new BadRequestException('User already part of this organization'); - if (!isUserInvited) - throw new BadRequestException( - 'User has no invitations from this organization', - ); + if (!orgExists) + throw new NotFoundException("Organization") + if (invitationAlreadySent) + throw new BadRequestException("Invitation to join this organization already sent") + if (userAlreadyPartOf) + throw new BadRequestException("User already part of the organization") - return await this.prisma.$transaction(async (tx) => { - await tx.organizationJoinRequest.update({ + return await this.prisma.organizationJoinRequest.create({ + data: { + orgId: orgId, + userId, + requestType: ORGANIZATION_JOIN_REQUEST_TYPE.REQUESTED, + requestMessage: dto.requestMessage, + } + }) + } + + + async userCancelOrgJoinRequest( + userId: string, + orgId: string + ) { + try { + await this.prisma.organizationJoinRequest.update({ where: { userId_orgId: { userId, - orgId, + orgId }, }, data: { - status: ORGANIZATION_JOIN_REQUEST.ACCEPTED, - }, - }); - - return await tx.organizationUserJoinTable.create({ - data: { - orgId, - userId, - }, - }); - }); + status: ORGANIZATION_JOIN_REQUEST.CANCELLED, + } + }) + } + catch (err) { + throw new NotFoundException("Join request") + } } + /* + * List of organizations that: + * - user have requested to join + * - have send invitations to user + * + * filtered by requestType + * */ + async userOrganizationJoinRequestList( + userId: string, + requestType: string + ) { + const joinReqType: ORGANIZATION_JOIN_REQUEST_TYPE | undefined = ORGANIZATION_JOIN_REQUEST_TYPE[requestType]; + if (!joinReqType) + throw new BadRequestException("Invalid request type") + + return await this.prisma.organizationJoinRequest.findMany({ + where: { + userId, + requestType: joinReqType + } + }) + } + + + async userOrganiaztionRequestAction( + userId: string, + orgId: string, + dto: UserOrganizationInvitationActionRequestDTO + ) { + const [orgExists, hasUserSendRequest] = await Promise.all([ + this.orgService.findById(orgId), + this.prisma.organizationJoinRequest.findUnique({ + where: { + userId_orgId: { + userId, + orgId: orgId + }, + status: ORGANIZATION_JOIN_REQUEST.PENDING, + requestType: ORGANIZATION_JOIN_REQUEST_TYPE.REQUESTED + } + }) + ]) + + if (!orgExists) + throw new NotFoundException("Organization") + if (!hasUserSendRequest) + throw new BadRequestException("No pending join request") + + try { + return await this.prisma.$transaction(async (tx) => { + const userAction = dto.action === USER_ORG_ACCEPT_REJECT_ACTION.ACCEPT + ? ORGANIZATION_JOIN_REQUEST.ACCEPTED + : ORGANIZATION_JOIN_REQUEST.REJECTED; + + await tx.organizationJoinRequest.update({ + where: { + userId_orgId: { + userId, + orgId: orgId, + } + }, + data: { + status: userAction, + ...( + userAction === ORGANIZATION_JOIN_REQUEST.REJECTED && dto.message + ? { rejectReason: dto.message } + : {} + ) + } + }); + + if (userAction === ORGANIZATION_JOIN_REQUEST.ACCEPTED) + await tx.organizationUserJoinTable.create({ + data: { + userId: userId, + orgId: orgId, + } + }) + }); + } + catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError) { + if (err.code === 'P2002') + throw new BadRequestException('User already part of this organization.'); + } else { + throw err; + } + } + } + + // TODO: reject, rejectReason + // + // async acceptInvitation(userId: string, orgId: string) { + // const orgExists = await this.orgService.organizationExists(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 = ORGANIZATION_JOIN_REQUEST_TYPE.INVITED, @@ -153,8 +321,58 @@ export class OrganizationMembershipService { }); } + async getOrganizationsOfUser(userId: string) { + return await this.prisma.organizationJoinRequest.findMany({ + where: { + userId + }, + include: { + organization: { + select: { + name: true, + description: true + } + } + }, + }) + } + + async userLeaveAnOrganization(userId: string, orgId: string) { + try { + await this.prisma.organizationUserJoinTable.delete({ + where: { + userId_orgId: { userId, orgId } + } + }); + } catch (e) { + throw new NotFoundException("Membership not found"); + } + + return true; + } + + async getOrganizationRequestList( + orgId: string, + requestType: ORGANIZATION_JOIN_REQUEST_TYPE = ORGANIZATION_JOIN_REQUEST_TYPE.REQUESTED, + status: ORGANIZATION_JOIN_REQUEST = ORGANIZATION_JOIN_REQUEST.PENDING, + ) { + // TODO: Check can perform + return await this.prisma.organizationJoinRequest.findMany({ + where: { + orgId: orgId, + status: status, + requestType: requestType, + }, + include: { + user: { + select: { email: true }, + }, + }, + }); + } + async getMemebersOfOrganization(orgId: string) { - const orgExists = await this.orgService.organizationExists(orgId); + const orgExists = await this.orgService.findById(orgId); if (!orgExists) throw new NotFoundException('Organization'); return await this.prisma.organizationUserJoinTable.findMany({ diff --git a/src/organization/organization.service.ts b/src/organization/organization.service.ts index d57d191..e1c13c6 100644 --- a/src/organization/organization.service.ts +++ b/src/organization/organization.service.ts @@ -21,7 +21,7 @@ export class OrganizationService { // private readonly reqContext: RequestContextService, private readonly authorization: AuthorizationService, private readonly cacheService: CacheService, - ) {} + ) { } async createNewOrganization( userId: string, dto: CreateNewOrganizationRequestDTO, @@ -60,7 +60,7 @@ export class OrganizationService { orgId: string, dto: UpdateOrganizationRequestDTO, ) { - const orgExists = await this.organizationExists(orgId); + const orgExists = await this.findById(orgId); if (!orgExists) throw new NotFoundException('Organization'); const canUserUpdateOrganization = @@ -83,7 +83,7 @@ export class OrganizationService { // TODO: Either empty or choose someone to be owner async deleteAnOrganization(userId: string, orgId: string) { - const orgExists = await this.organizationExists(orgId); + const orgExists = await this.findById(orgId); if (!orgExists) throw new NotFoundException('Organization'); const canUserDeleteOrganization = @@ -118,13 +118,16 @@ export class OrganizationService { }); } - async organizationExists(orgId: string) { - return await this.prisma.organization.findUnique({ - where: { id: orgId }, - select: { id: true }, - }); - } + // async organizationExists(orgId: string) { + // return await this.prisma.organization.findUnique({ + // where: { id: orgId }, + // select: { id: true }, + // }); + // } + /* + * Its neat, it caches info plus only selects id so faster DB search as well + * */ async findById(orgId: string) { const organization = await this.cacheService.getOrSet( orgId,