Compare commits
18 Commits
65480c4f8c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4686e0abbc | ||
|
|
2f01adeade | ||
|
|
aa8deadf1f | ||
|
|
ab8b2ef353 | ||
|
|
4905c6f1d1 | ||
|
|
2f30be8c82 | ||
|
|
f21ee1d131 | ||
|
|
9d931e0d96 | ||
|
|
68135ae022 | ||
|
|
349196b801 | ||
|
|
6fc494687a | ||
|
|
496d689ec1 | ||
|
|
024702dd26 | ||
|
|
90b0192cd2 | ||
|
|
afed1731d2 | ||
|
|
f4c9174752 | ||
|
|
f6bce78aee | ||
|
|
9561693cb4 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -54,3 +54,8 @@ pids
|
|||||||
|
|
||||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
prisma/generated
|
||||||
|
|
||||||
|
dump.rdb
|
||||||
|
|
||||||
|
|||||||
1
.woodpecker.yml
Normal file
1
.woodpecker.yml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
service:
|
||||||
389
CODE_REVIEW.md
Normal file
389
CODE_REVIEW.md
Normal file
@@ -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<string>('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<void> {
|
||||||
|
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, USER_ORGANIZATION_OPERATIONS[]> = {
|
||||||
|
[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`)
|
||||||
104
README.md
104
README.md
@@ -1,98 +1,8 @@
|
|||||||
<p align="center">
|
## Folder Structure
|
||||||
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
/common
|
||||||
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
|-> Common stuff thats shared across
|
||||||
|
/core
|
||||||
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
|-> Infra/Setup for core functionality thats also shared
|
||||||
<p align="center">
|
/src
|
||||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
|
|-> Modules, that are service specific
|
||||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
|
|
||||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
|
|
||||||
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
|
|
||||||
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
|
|
||||||
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
|
|
||||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
|
|
||||||
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
|
|
||||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
|
|
||||||
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
|
|
||||||
</p>
|
|
||||||
<!--[](https://opencollective.com/nest#backer)
|
|
||||||
[](https://opencollective.com/nest#sponsor)-->
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
|
||||||
|
|
||||||
## Project setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
$ pnpm install
|
|
||||||
```
|
|
||||||
|
|
||||||
## Compile and run the project
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# development
|
|
||||||
$ pnpm run start
|
|
||||||
|
|
||||||
# watch mode
|
|
||||||
$ pnpm run start:dev
|
|
||||||
|
|
||||||
# production mode
|
|
||||||
$ pnpm run start:prod
|
|
||||||
```
|
|
||||||
|
|
||||||
## Run tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# unit tests
|
|
||||||
$ pnpm run test
|
|
||||||
|
|
||||||
# e2e tests
|
|
||||||
$ pnpm run test:e2e
|
|
||||||
|
|
||||||
# test coverage
|
|
||||||
$ pnpm run test:cov
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deployment
|
|
||||||
|
|
||||||
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
|
|
||||||
|
|
||||||
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
$ pnpm install -g mau
|
|
||||||
$ mau deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
|
|
||||||
|
|
||||||
## Resources
|
|
||||||
|
|
||||||
Check out a few resources that may come in handy when working with NestJS:
|
|
||||||
|
|
||||||
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
|
|
||||||
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
|
|
||||||
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
|
|
||||||
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
|
|
||||||
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
|
|
||||||
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
|
|
||||||
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
|
|
||||||
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
|
||||||
|
|
||||||
## Stay in touch
|
|
||||||
|
|
||||||
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
|
|
||||||
- Website - [https://nestjs.com](https://nestjs.com/)
|
|
||||||
- Twitter - [@nestframework](https://twitter.com/nestframework)
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
|
|
||||||
|
|||||||
183
common/emails/auth.ts
Normal file
183
common/emails/auth.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
export const authOTP = (otp: number) => (
|
||||||
|
`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Your Verification Code</title>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body{
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
background:#f4f7fb;
|
||||||
|
margin:0;
|
||||||
|
padding:0;
|
||||||
|
color:#374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container{
|
||||||
|
max-width:600px;
|
||||||
|
margin:40px auto;
|
||||||
|
background:#ffffff;
|
||||||
|
border-radius:10px;
|
||||||
|
padding:40px;
|
||||||
|
box-shadow:0 8px 25px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo{
|
||||||
|
text-align:center;
|
||||||
|
font-size:24px;
|
||||||
|
font-weight:700;
|
||||||
|
color:#2563eb;
|
||||||
|
margin-bottom:30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1{
|
||||||
|
font-size:22px;
|
||||||
|
margin-bottom:10px;
|
||||||
|
color:#111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
p{
|
||||||
|
font-size:15px;
|
||||||
|
color:#4b5563;
|
||||||
|
line-height:1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.otp-box{
|
||||||
|
margin:35px 0;
|
||||||
|
text-align:center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.otp{
|
||||||
|
display:inline-block;
|
||||||
|
font-size:32px;
|
||||||
|
letter-spacing:8px;
|
||||||
|
font-weight:700;
|
||||||
|
background:#f1f5ff;
|
||||||
|
color:#1d4ed8;
|
||||||
|
padding:18px 30px;
|
||||||
|
border-radius:8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expiry{
|
||||||
|
margin-top:12px;
|
||||||
|
font-size:13px;
|
||||||
|
color:#6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning{
|
||||||
|
margin-top:20px;
|
||||||
|
font-size:13px;
|
||||||
|
color:#6b7280;
|
||||||
|
background:#f9fafb;
|
||||||
|
padding:12px;
|
||||||
|
border-radius:6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer{
|
||||||
|
text-align:center;
|
||||||
|
font-size:12px;
|
||||||
|
color:#9ca3af;
|
||||||
|
margin-top:35px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider{
|
||||||
|
height:1px;
|
||||||
|
background:#e5e7eb;
|
||||||
|
margin:30px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
|
||||||
|
<div class="logo">MultiTenant SaaS</div>
|
||||||
|
|
||||||
|
<h1>Your Verification Code</h1>
|
||||||
|
|
||||||
|
<p>Hello,</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Use the following One-Time Password (OTP) to continue signing in to your
|
||||||
|
<strong>MultiTenant SaaS</strong> account.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="otp-box">
|
||||||
|
<div class="otp">${otp}</div>
|
||||||
|
<div class="expiry">This code is valid for <strong>5 minutes</strong> only.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Enter this code in the verification screen to complete your login or signup.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="warning">
|
||||||
|
For security reasons, never share this code with anyone. Our team will never ask you for your OTP.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
If you did not request this code, you can safely ignore this email.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
© 2026 MultiTenant SaaS. All rights reserved.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
)
|
||||||
|
|
||||||
|
export const welcomeToApp =
|
||||||
|
`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Welcome to Research Shock</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; background-color: #f4f7f9; }
|
||||||
|
.container { max-width: 600px; margin: 20px auto; background: #ffffff; padding: 40px; border-radius: 8px; box-shadow: 0 4px 10px rgba(0,0,0,0.05); }
|
||||||
|
.logo { font-size: 24px; font-weight: bold; color: #2563eb; margin-bottom: 20px; text-align: center; }
|
||||||
|
h1 { font-size: 22px; color: #111827; }
|
||||||
|
p { margin-bottom: 20px; color: #4b5563; }
|
||||||
|
.button { display: inline-block; padding: 12px 24px; background-color: #2563eb; color: #ffffff; text-decoration: none; border-radius: 5px; font-weight: 600; transition: background 0.3s ease; }
|
||||||
|
.footer { margin-top: 30px; font-size: 12px; color: #9ca3af; text-align: center; }
|
||||||
|
.divider { height: 1px; background: #e5e7eb; margin: 30px 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="logo">MultiTenant SaaS</div>
|
||||||
|
<h1>Welcome to the team!</h1>
|
||||||
|
<p>Hi there,</p>
|
||||||
|
<p>Thanks for signing up for <strong>MultiTenant Saas</strong>. We're excited to help you around.</p>
|
||||||
|
<p>To get started, we recommend setting up your organization profile and inviting your first team member.</p>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin: 35px 0;">
|
||||||
|
<a href="http://localhost:3000" class="button">Go to Dashboard</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>If you have any questions, just reply to this email. We're here to help!</p>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<p>Cheers,<br>Team</p>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
© 2026 App. All rights reserved.<br>
|
||||||
|
If you didn't create an account, you can safely ignore this email.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
16
common/emails/index.ts
Normal file
16
common/emails/index.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { welcomeToApp, authOTP } from "./auth"
|
||||||
|
|
||||||
|
const EmailTemplates = {
|
||||||
|
signup_otp: (otp: number) => ({
|
||||||
|
subject: "Your MultiTenant SaaS Verification Code",
|
||||||
|
body: authOTP(otp)
|
||||||
|
}),
|
||||||
|
signup_completed: {
|
||||||
|
subject: "Welcome to app",
|
||||||
|
body: welcomeToApp
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EmailTemplates
|
||||||
|
|
||||||
|
|
||||||
24
common/exceptions/custom-exceptions.ts
Normal file
24
common/exceptions/custom-exceptions.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||||
|
|
||||||
|
// Base exception
|
||||||
|
export class BaseException extends HttpException {
|
||||||
|
protected constructor(
|
||||||
|
errorCode: string,
|
||||||
|
errorMessage: string,
|
||||||
|
status: HttpStatus
|
||||||
|
) {
|
||||||
|
super({ code: errorCode, message: errorMessage }, status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Organization Exceptions
|
||||||
|
* */
|
||||||
|
export class OrganizationNotFoundException extends BaseException {
|
||||||
|
|
||||||
|
}
|
||||||
|
// export class NotPartOfOrganizationException extends BaseException {
|
||||||
|
// constructor(code: string, message: string, status: HttpStatus) {
|
||||||
|
// super();
|
||||||
|
// }
|
||||||
|
// }
|
||||||
17
common/exceptions/error_codes.ts
Normal file
17
common/exceptions/error_codes.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { HttpStatus } from "@nestjs/common";
|
||||||
|
|
||||||
|
interface AppError {
|
||||||
|
errorCode: string,
|
||||||
|
message: string,
|
||||||
|
status: HttpStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrorsType = Record<string, AppError>
|
||||||
|
|
||||||
|
export const ORGANIZATION_ERRORS: ErrorsType = {
|
||||||
|
NOT_FOUND: {
|
||||||
|
errorCode: 'ORG_001',
|
||||||
|
message: 'Organization not found',
|
||||||
|
status: HttpStatus.NOT_FOUND
|
||||||
|
}
|
||||||
|
}
|
||||||
39
common/exceptions/exception-filter.ts
Normal file
39
common/exceptions/exception-filter.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import {
|
||||||
|
ExceptionFilter,
|
||||||
|
Catch,
|
||||||
|
ArgumentsHost,
|
||||||
|
HttpException,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
|
||||||
|
@Catch(HttpException) // What exception to catch
|
||||||
|
export class HttpExceptionFilter implements ExceptionFilter<HttpException> {
|
||||||
|
constructor(private readonly logger: Logger) { }
|
||||||
|
catch(exception: HttpException, host: ArgumentsHost) {
|
||||||
|
const ctx = host.switchToHttp();
|
||||||
|
const request: Request = ctx.getRequest();
|
||||||
|
const response: Response = ctx.getResponse();
|
||||||
|
const status = exception.getStatus();
|
||||||
|
|
||||||
|
|
||||||
|
if (status >= 500) {
|
||||||
|
this.logger.warn({
|
||||||
|
method: request.method,
|
||||||
|
url: request.url,
|
||||||
|
message: exception.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 404) {
|
||||||
|
exception.message = `${exception.message} not found`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// response.status(status).json({
|
||||||
|
// success: false,
|
||||||
|
// message: exception.message,
|
||||||
|
// statusCode: status,
|
||||||
|
// });
|
||||||
|
}
|
||||||
|
}
|
||||||
1
common/exceptions/index.ts
Normal file
1
common/exceptions/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './custom-exceptions';
|
||||||
29
common/exceptions/websocket.ts
Normal file
29
common/exceptions/websocket.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Catch, ArgumentsHost } from '@nestjs/common';
|
||||||
|
import { BaseWsExceptionFilter, WsException } from '@nestjs/websockets';
|
||||||
|
|
||||||
|
@Catch()
|
||||||
|
export class WsValidationExceptionFilter extends BaseWsExceptionFilter {
|
||||||
|
catch(exception: unknown, host: ArgumentsHost) {
|
||||||
|
const client = host.switchToWs().getClient();
|
||||||
|
if (exception instanceof WsException) {
|
||||||
|
client.emit('exception', {
|
||||||
|
status: 'error',
|
||||||
|
message: exception.getError(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle ValidationPipe errors (they come as plain objects, not WsException)
|
||||||
|
if (
|
||||||
|
Array.isArray((exception as any)?.response?.message)
|
||||||
|
) {
|
||||||
|
client.emit('exception', {
|
||||||
|
status: 'error',
|
||||||
|
message: (exception as any).response.message,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
super.catch(exception, host);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
common/http/index.ts
Normal file
1
common/http/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './response';
|
||||||
25
common/http/response.ts
Normal file
25
common/http/response.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export class MessageResponse {
|
||||||
|
readonly success: boolean;
|
||||||
|
readonly message: string;
|
||||||
|
|
||||||
|
constructor(message?: string) {
|
||||||
|
this.success = true;
|
||||||
|
this.message = message ?? 'Success';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DataResponse<T> extends MessageResponse {
|
||||||
|
readonly data: T;
|
||||||
|
|
||||||
|
constructor(data: T, message?: string) {
|
||||||
|
super(message);
|
||||||
|
this.data = data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skipped
|
||||||
|
export class GlobalErrorResponseDTO {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
statusCode: number;
|
||||||
|
}
|
||||||
0
common/interceptors/index.ts
Normal file
0
common/interceptors/index.ts
Normal file
72
common/interceptors/response.interceptor.ts
Normal file
72
common/interceptors/response.interceptor.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NestInterceptor,
|
||||||
|
ExecutionContext,
|
||||||
|
CallHandler,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { DataResponse, MessageResponse } from 'common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { map } from 'rxjs/operators';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ResponseInterceptor<T> implements NestInterceptor<T, any> {
|
||||||
|
intercept(_: ExecutionContext, next: CallHandler): Observable<any> {
|
||||||
|
return next.handle().pipe(
|
||||||
|
map((data) => {
|
||||||
|
if (data instanceof MessageResponse) return data;
|
||||||
|
else if (data instanceof DataResponse) return data;
|
||||||
|
else if (typeof data === 'string') return new MessageResponse(data);
|
||||||
|
return new DataResponse(data);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* NOTE: How to access request
|
||||||
|
*
|
||||||
|
@Injectable()
|
||||||
|
export class ResponseInterceptor<T> implements NestInterceptor<T, any> {
|
||||||
|
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||||
|
const req = context.switchToHttp().getRequest();
|
||||||
|
req.userId = 'ram';
|
||||||
|
return next.handle();
|
||||||
|
// return next.handle().pipe(
|
||||||
|
// map((data) => {
|
||||||
|
// if (data instanceof MessageResponse) return data;
|
||||||
|
// else if (data instanceof DataResponse) return data;
|
||||||
|
// else if (typeof data === 'string') return new MessageResponse(data);
|
||||||
|
// return new DataResponse(data);
|
||||||
|
// }),
|
||||||
|
// );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
REQUEST
|
||||||
|
↓
|
||||||
|
Guards
|
||||||
|
↓
|
||||||
|
Interceptors (before)
|
||||||
|
↓
|
||||||
|
Pipes
|
||||||
|
↓
|
||||||
|
Controller method
|
||||||
|
↓
|
||||||
|
Interceptors (after) ← YOU ARE HERE
|
||||||
|
↓
|
||||||
|
Response
|
||||||
|
|
||||||
|
|
||||||
|
3️⃣ What is next.handle() really?
|
||||||
|
next.handle(): Observable<T>
|
||||||
|
|
||||||
|
|
||||||
|
This is:
|
||||||
|
|
||||||
|
An RxJS stream of whatever the controller returns
|
||||||
|
|
||||||
|
Not the request
|
||||||
|
|
||||||
|
Not the response object
|
||||||
|
|
||||||
|
Just the return value
|
||||||
|
* */
|
||||||
6
common/keys.ts
Normal file
6
common/keys.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export const PUBLIC_KEY = '__PUBLIC_KEY__';
|
||||||
|
export const ROLE_KEY = '__ROLE_KEY__';
|
||||||
|
export const ORG_ROLE_KEY = '__ORG_ROLE_KEY__'
|
||||||
|
export const ORG_ROLES_ALL_KEY = '__ORG_ROLE_ALL_KEY__';
|
||||||
|
export const CAN_PERFORM_KEY = '__CAN_PERFORM_KEY__';
|
||||||
|
export const TEMP_TOKEN_KEY = '__TEMP_TOKEN_KEY__';
|
||||||
26
common/validators/at-least-one-field.ts
Normal file
26
common/validators/at-least-one-field.ts
Normal file
@@ -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<string, any>;
|
||||||
|
|
||||||
|
return Object.values(object).some((value) => value !== undefined);
|
||||||
|
},
|
||||||
|
defaultMessage() {
|
||||||
|
return 'At least one field must be provided';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
1
common/validators/index.ts
Normal file
1
common/validators/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './at-least-one-field';
|
||||||
6
common/validators/query-uuid.dto.ts
Normal file
6
common/validators/query-uuid.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class UUIDQueryDTO {
|
||||||
|
@IsUUID()
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
19
core/als/request-context.middleware.ts
Normal file
19
core/als/request-context.middleware.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { RequestContextService } from './request-context.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RequestContextMiddleware implements NestMiddleware {
|
||||||
|
constructor(private readonly ctx: RequestContextService) {}
|
||||||
|
|
||||||
|
use(req: Request, _: Response, next: NextFunction) {
|
||||||
|
const context = {
|
||||||
|
requestId: randomUUID(),
|
||||||
|
correlationId: (req.headers['x-correlation-id'] as string) ?? undefined,
|
||||||
|
headers: req.headers as Record<string, string>,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ctx.run(context, next);
|
||||||
|
}
|
||||||
|
}
|
||||||
8
core/als/request-context.module.ts
Normal file
8
core/als/request-context.module.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { RequestContextService } from './request-context.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [RequestContextService],
|
||||||
|
exports: [RequestContextService],
|
||||||
|
})
|
||||||
|
export class RequestContextModule {}
|
||||||
58
core/als/request-context.service.ts
Normal file
58
core/als/request-context.service.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { AsyncLocalStorage } from 'async_hooks';
|
||||||
|
import { RequestContext } from './request-context.type';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
/**
|
||||||
|
* RequestContext holds per-request metadata including:
|
||||||
|
* - HTTP request
|
||||||
|
* - Auth payload (decoded JWT, optional)
|
||||||
|
* - Prisma transaction client (optional)
|
||||||
|
* - Correlation ID and other future cross-cutting concerns
|
||||||
|
*/
|
||||||
|
export class RequestContextService {
|
||||||
|
private readonly als = new AsyncLocalStorage<RequestContext>();
|
||||||
|
|
||||||
|
run(context: RequestContext, fn: () => void) {
|
||||||
|
this.als.run(context, fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
get(): RequestContext {
|
||||||
|
const store = this.als.getStore();
|
||||||
|
if (!store) {
|
||||||
|
throw new Error('RequestContext not initialized');
|
||||||
|
}
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
set<K extends keyof RequestContext>(key: K, value: RequestContext[K]) {
|
||||||
|
this.get()[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
get user() {
|
||||||
|
const user = this.get().user;
|
||||||
|
if (!user) throw new UnauthorizedException();
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
get tx() {
|
||||||
|
return this.get().tx;
|
||||||
|
}
|
||||||
|
|
||||||
|
set tx(tx) {
|
||||||
|
this.set('tx', tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isTransaction(): boolean {
|
||||||
|
return !!this.get().tx;
|
||||||
|
}
|
||||||
|
|
||||||
|
get orgId(): string {
|
||||||
|
return this.orgId
|
||||||
|
}
|
||||||
|
|
||||||
|
set orgId(id: string) {
|
||||||
|
this.set('orgId', id)
|
||||||
|
}
|
||||||
|
}
|
||||||
11
core/als/request-context.type.ts
Normal file
11
core/als/request-context.type.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Prisma } from 'prisma/generated/prisma/client';
|
||||||
|
import { JwtPayload } from 'src/auth/types';
|
||||||
|
|
||||||
|
export interface RequestContext {
|
||||||
|
requestId: string;
|
||||||
|
correlationId?: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
user?: JwtPayload;
|
||||||
|
orgId?: string;
|
||||||
|
tx?: Prisma.TransactionClient;
|
||||||
|
}
|
||||||
17
infra/db/docker-compose.yml
Normal file
17
infra/db/docker-compose.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:18
|
||||||
|
container_name: multi-tenant
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: admin
|
||||||
|
POSTGRES_DB: multi_tenant
|
||||||
|
ports:
|
||||||
|
- '5454:5432'
|
||||||
|
volumes:
|
||||||
|
- multiTenant:/var/lib/postgresql
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
multiTenant:
|
||||||
32
package.json
32
package.json
@@ -10,6 +10,7 @@
|
|||||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
"start": "nest start",
|
"start": "nest start",
|
||||||
"start:dev": "nest start --watch",
|
"start:dev": "nest start --watch",
|
||||||
|
"dev": "nest start --watch",
|
||||||
"start:debug": "nest start --debug --watch",
|
"start:debug": "nest start --debug --watch",
|
||||||
"start:prod": "node dist/main",
|
"start:prod": "node dist/main",
|
||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
@@ -17,26 +18,54 @@
|
|||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:cov": "jest --coverage",
|
"test:cov": "jest --coverage",
|
||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
|
"prisma:migrate": "prisma migrate dev --create-only",
|
||||||
|
"prisma:apply": "prisma migrate dev && prisma generate",
|
||||||
|
"prisma:deploy": "prisma migrate deploy",
|
||||||
|
"prisma:generate": "prisma generate"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@keyv/redis": "^5.1.6",
|
||||||
|
"@nestjs/bullmq": "^11.0.4",
|
||||||
|
"@nestjs/cache-manager": "^3.1.0",
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
|
"@nestjs/event-emitter": "^3.0.1",
|
||||||
|
"@nestjs/jwt": "^11.0.2",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
|
"@nestjs/platform-socket.io": "^11.1.18",
|
||||||
|
"@nestjs/swagger": "^11.2.6",
|
||||||
|
"@nestjs/websockets": "^11.1.18",
|
||||||
|
"@prisma/adapter-pg": "^7.3.0",
|
||||||
|
"@prisma/client": "^7.3.0",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"bullmq": "^5.73.0",
|
||||||
|
"cache-manager": "^7.2.8",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.3",
|
||||||
|
"nodemailer": "^8.0.1",
|
||||||
|
"pg": "^8.18.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@bull-board/api": "^6.20.6",
|
||||||
|
"@bull-board/express": "^6.20.6",
|
||||||
|
"@bull-board/nestjs": "^6.20.6",
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
"@eslint/js": "^9.18.0",
|
"@eslint/js": "^9.18.0",
|
||||||
"@nestjs/cli": "^11.0.0",
|
"@nestjs/cli": "^11.0.0",
|
||||||
|
"@nestjs/config": "^4.0.3",
|
||||||
"@nestjs/schematics": "^11.0.0",
|
"@nestjs/schematics": "^11.0.0",
|
||||||
"@nestjs/testing": "^11.0.1",
|
"@nestjs/testing": "^11.0.1",
|
||||||
"@swc/cli": "^0.6.0",
|
"@swc/cli": "^0.6.0",
|
||||||
"@swc/core": "^1.10.7",
|
"@swc/core": "^1.10.7",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
|
"@types/nodemailer": "^7.0.11",
|
||||||
|
"@types/pg": "^8.16.0",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
@@ -44,6 +73,7 @@
|
|||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
|
"prisma": "^7.3.0",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"ts-jest": "^29.2.5",
|
"ts-jest": "^29.2.5",
|
||||||
|
|||||||
1778
pnpm-lock.yaml
generated
1778
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
14
prisma.config.ts
Normal file
14
prisma.config.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// This file was generated by Prisma, and assumes you have installed the following:
|
||||||
|
// npm install --save-dev prisma dotenv
|
||||||
|
import 'dotenv/config';
|
||||||
|
import { defineConfig } from 'prisma/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: 'prisma/models',
|
||||||
|
migrations: {
|
||||||
|
path: 'prisma/migrations',
|
||||||
|
},
|
||||||
|
datasource: {
|
||||||
|
url: process.env['DATABASE_URL'],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ORGANIZATION_JOIN_REQUEST" AS ENUM ('PENDING', 'ACCEPTED', 'REJECTED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ORG_ROLE" AS ENUM ('admin', 'user');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "USER_ROLE" AS ENUM ('superadmin', 'ordinary');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "organization_join_request" (
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"orgId" TEXT NOT NULL,
|
||||||
|
"status" "ORGANIZATION_JOIN_REQUEST" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"requestedOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"rejectReason" TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "organization_user_join" (
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"orgId" TEXT NOT NULL,
|
||||||
|
"role" "ORG_ROLE" NOT NULL DEFAULT 'user',
|
||||||
|
"joinedDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "organization" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "organization_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "user" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"firstName" TEXT NOT NULL,
|
||||||
|
"middleName" TEXT,
|
||||||
|
"lastName" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"password" TEXT NOT NULL,
|
||||||
|
"role" "USER_ROLE" NOT NULL DEFAULT 'ordinary',
|
||||||
|
"isVerified" BOOLEAN DEFAULT false,
|
||||||
|
"refreshToken" TEXT,
|
||||||
|
"profilePicture" TEXT,
|
||||||
|
"isDeleted" BOOLEAN DEFAULT false,
|
||||||
|
"deletedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "user_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "organization_join_request_userId_orgId_key" ON "organization_join_request"("userId", "orgId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "organization_user_join_userId_orgId_key" ON "organization_user_join"("userId", "orgId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "user_email_key" ON "user"("email");
|
||||||
@@ -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';
|
||||||
1
prisma/migrations/20260221131903_new_enum/migration.sql
Normal file
1
prisma/migrations/20260221131903_new_enum/migration.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
-- This is an empty migration.
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- Added the required column `updatedAt` to the `organization_join_request` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "organization_join_request" ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "organization_join_request" ADD CONSTRAINT "organization_join_request_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "organization_join_request" ADD CONSTRAINT "organization_join_request_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "organization_user_join" ADD CONSTRAINT "organization_user_join_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "organization_user_join" ADD CONSTRAINT "organization_user_join_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "organization_join_request" ADD COLUMN "role" "ORG_ROLE" NOT NULL DEFAULT 'member';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "organization_join_request" ADD COLUMN "requestMessage" TEXT;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
-- This is an empty migration.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "ORGANIZATION_JOIN_REQUEST" ADD VALUE 'CANCELLED';
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- The required column `id` was added to the `organization_join_request` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "organization_join_request_userId_orgId_key";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "organization_join_request" ADD COLUMN "id" TEXT NOT NULL,
|
||||||
|
ADD CONSTRAINT "organization_join_request_pkey" PRIMARY KEY ("id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "organization_join_request_userId_orgId_idx" ON "organization_join_request"("userId", "orgId");
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "user_otp" (
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"otp" INTEGER NOT NULL,
|
||||||
|
"generatedOn" TIMESTAMP(3) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "user_otp_email_key" ON "user_otp"("email");
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `firstName` on the `user` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `lastName` on the `user` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `middleName` on the `user` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `profilePicture` on the `user` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "user" DROP COLUMN "firstName",
|
||||||
|
DROP COLUMN "lastName",
|
||||||
|
DROP COLUMN "middleName",
|
||||||
|
DROP COLUMN "profilePicture";
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "UserAdditionalInformation" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"firstName" TEXT NOT NULL,
|
||||||
|
"middleName" TEXT,
|
||||||
|
"lastName" TEXT NOT NULL,
|
||||||
|
"profilePicture" TEXT,
|
||||||
|
"address" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "UserAdditionalInformation_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "UserAdditionalInformation_userId_key" ON "UserAdditionalInformation"("userId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "UserAdditionalInformation" ADD CONSTRAINT "UserAdditionalInformation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `isVerified` on the `user` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `generatedOn` on the `user_otp` table. All the data in the column will be lost.
|
||||||
|
- Added the required column `expiresAt` to the `user_otp` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "USER_ACCOUNT_STATUS" AS ENUM ('pending', 'active', 'suspended', 'deleted');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "user" DROP COLUMN "isVerified",
|
||||||
|
ADD COLUMN "status" "USER_ACCOUNT_STATUS" NOT NULL DEFAULT 'pending',
|
||||||
|
ALTER COLUMN "password" DROP NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "user_otp" DROP COLUMN "generatedOn",
|
||||||
|
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
ADD COLUMN "expiresAt" TIMESTAMP(3) NOT NULL;
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
31
prisma/models/organization-join-request.prisma
Normal file
31
prisma/models/organization-join-request.prisma
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
model OrganizationJoinRequest {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
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)
|
||||||
|
|
||||||
|
// @@unique([userId, orgId])
|
||||||
|
@@index([userId, orgId])
|
||||||
|
@@map("organization_join_request")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ORGANIZATION_JOIN_REQUEST {
|
||||||
|
PENDING
|
||||||
|
ACCEPTED
|
||||||
|
REJECTED
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ORGANIZATION_JOIN_REQUEST_TYPE {
|
||||||
|
INVITED
|
||||||
|
REQUESTED
|
||||||
|
}
|
||||||
18
prisma/models/organization-user-join.prisma
Normal file
18
prisma/models/organization-user-join.prisma
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
model OrganizationUserJoinTable {
|
||||||
|
userId String
|
||||||
|
orgId String
|
||||||
|
role ORG_ROLE @default(member)
|
||||||
|
joinedDate DateTime @default(now())
|
||||||
|
|
||||||
|
organization Organization @relation(fields: [orgId], references: [id], onDelete: Restrict)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, orgId])
|
||||||
|
@@map("organization_user_join")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ORG_ROLE {
|
||||||
|
owner
|
||||||
|
admin
|
||||||
|
member
|
||||||
|
}
|
||||||
13
prisma/models/organization.prisma
Normal file
13
prisma/models/organization.prisma
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
model Organization {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
members OrganizationUserJoinTable[]
|
||||||
|
requestingMembers OrganizationJoinRequest[]
|
||||||
|
|
||||||
|
@@map("organization")
|
||||||
|
}
|
||||||
14
prisma/models/schema.prisma
Normal file
14
prisma/models/schema.prisma
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
|
||||||
|
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client"
|
||||||
|
output = "../generated/prisma"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
}
|
||||||
57
prisma/models/user.prisma
Normal file
57
prisma/models/user.prisma
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
model User {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
email String @unique
|
||||||
|
password String?
|
||||||
|
role USER_ROLE @default(user)
|
||||||
|
refreshToken String?
|
||||||
|
status USER_ACCOUNT_STATUS @default(pending)
|
||||||
|
isDeleted Boolean? @default(false)
|
||||||
|
deletedAt DateTime?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
organizations OrganizationUserJoinTable[]
|
||||||
|
organizationsRequested OrganizationJoinRequest[]
|
||||||
|
userAdditionalInformation UserAdditionalInformation?
|
||||||
|
|
||||||
|
@@map("user")
|
||||||
|
}
|
||||||
|
|
||||||
|
model UserAdditionalInformation {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String @unique
|
||||||
|
firstName String
|
||||||
|
middleName String?
|
||||||
|
lastName String
|
||||||
|
profilePicture String?
|
||||||
|
address String?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model UserOTP {
|
||||||
|
email String @unique
|
||||||
|
otp Int
|
||||||
|
// ExipresAt is also saved so its easier to check and also
|
||||||
|
// run cron job to remove expired OTPs
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
expiresAt DateTime
|
||||||
|
|
||||||
|
@@map("user_otp")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum USER_ROLE {
|
||||||
|
superadmin
|
||||||
|
user
|
||||||
|
}
|
||||||
|
|
||||||
|
enum USER_ACCOUNT_STATUS {
|
||||||
|
pending
|
||||||
|
active
|
||||||
|
suspended
|
||||||
|
deleted
|
||||||
|
}
|
||||||
108
roadmap.md
Normal file
108
roadmap.md
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
```
|
||||||
|
|
||||||
|
``` md
|
||||||
|
## Immediate
|
||||||
|
|
||||||
|
User registers: AuthService saves the user and emits a UserSignedUpEvent.
|
||||||
|
|
||||||
|
Listener catches it: The WelcomeEmailListener hears the event.
|
||||||
|
|
||||||
|
Queue handles it: The listener adds a job to BullMQ called send-welcome-email.
|
||||||
|
|
||||||
|
Worker processes it: A separate background worker picks up the job, tries to send the email, and automatically handles retries if it fails.
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Also an API of my own
|
||||||
|
3. Production and testing env diff
|
||||||
|
4. Testing
|
||||||
|
|
||||||
|
# 🏗️ SaaS Architect’s Roadmap: NestJS & DevOps
|
||||||
|
|
||||||
|
This document serves as a strategic guide for evolving from a developer to an architect. It balances the "What" (the technology) with the "How" (the engineering mindset).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Phase 1: The "What" — Advanced Backend Features
|
||||||
|
*Focus: Scaling complexity and ensuring production-grade reliability.*
|
||||||
|
|
||||||
|
### 1. Advanced Multi-Tenancy Architecture
|
||||||
|
* **The Goal:** Move beyond simple `organization_id` filters to true data isolation.
|
||||||
|
* **Concepts:** Shared Database/Separate Schema or **Row Level Security (RLS)**.
|
||||||
|
* **Challenge:** Implement a NestJS `Interceptor` or `Provider` that extracts a `tenant_id` from the request and automatically switches the database context/schema.
|
||||||
|
|
||||||
|
|
||||||
|
### 2. Background Tasks & Reliability
|
||||||
|
* **The Goal:** Decouple time-consuming tasks from the request/response cycle.
|
||||||
|
* **Tech Stack:** **BullMQ** + **Redis**.
|
||||||
|
* **Implementation:** Offload email invitations, image processing, and report generation to a separate "Worker" instance.
|
||||||
|
|
||||||
|
|
||||||
|
### 3. Real-Time Interactions
|
||||||
|
* **The Goal:** Create a reactive, living dashboard.
|
||||||
|
* **Tech Stack:** **Socket.io** (WebSockets).
|
||||||
|
* **Implementation:** Sync team membership changes or notification counts in real-time without requiring a page refresh.
|
||||||
|
|
||||||
|
### 4. Billing & Webhooks (The SaaS "S")
|
||||||
|
* **The Goal:** Turn a project into a business.
|
||||||
|
* **Tech Stack:** **Stripe API**.
|
||||||
|
* **Learning Point:** Handle **Idempotency**. Ensure that if Stripe sends a "Payment Success" webhook twice, you only upgrade the user's account once.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧠 Phase 2: The "How" — Architectural Thinking
|
||||||
|
*Focus: Designing systems that don't break under edge cases.*
|
||||||
|
|
||||||
|
### 1. State Machine Logic
|
||||||
|
Stop thinking in "CRUD" (Create, Read, Update, Delete) and start thinking in **Transitions**.
|
||||||
|
* **Entity Lifecycle:** An Invitation isn't just a row; it's a flow: `Pending` → `Accepted` | `Declined` | `Expired`.
|
||||||
|
* **Action:** Explicitly define what happens during every status change (e.g., "On Accept: Create User, Join Team, Clear Invite Cache").
|
||||||
|
|
||||||
|
|
||||||
|
### 2. Atomic Transactions
|
||||||
|
Never perform "partial" updates.
|
||||||
|
* **Strategy:** Use Database Transactions (`BEGIN...COMMIT`) for any operation touching multiple tables. If the user joins the team but the invitation update fails, the whole thing must roll back.
|
||||||
|
|
||||||
|
### 3. Event-Driven Side Effects
|
||||||
|
Don't bloat your services with 10 different tasks.
|
||||||
|
* **Pattern:** Use an **Event Emitter**.
|
||||||
|
* **Logic:** `UserService.create()` emits a `USER_CREATED` event. Separate listeners then handle the email, the analytics ping, and the Slack notification independently.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ☁️ Phase 3: The DevOps Pillar
|
||||||
|
*Focus: Automation, Observability, and "Infrastructure as Code".*
|
||||||
|
|
||||||
|
### 1. Observability (Stop Guessing)
|
||||||
|
* **Structured Logging:** Use **Pino** to output JSON logs that include `trace_id` and `tenant_id`.
|
||||||
|
* **Tracing:** Implement **OpenTelemetry** to visualize the lifecycle of a request across different services.
|
||||||
|
|
||||||
|
|
||||||
|
### 2. Infrastructure as Code (IaC)
|
||||||
|
* **The Goal:** Destroy and rebuild your entire cloud environment in minutes.
|
||||||
|
* **Tech Stack:** **Terraform**.
|
||||||
|
* **Action:** Codify your GCP/Azure VMs, VPC Firewalls, and Managed Databases so you never have to click buttons in a portal manually.
|
||||||
|
|
||||||
|
### 3. Hardened CI/CD
|
||||||
|
* **The Goal:** Catch errors before they hit production.
|
||||||
|
* **Action:** Add "Structure Verification" steps in your GitHub Actions to ensure the build folder matches what the server expects (e.g., checking for Next.js standalone chunks).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤖 The "Anti-GIGO" AI Workflow
|
||||||
|
*How to use AI as an architect, not a typist.*
|
||||||
|
|
||||||
|
1. **Sketch First:** Define your DB schema and logic flow on a whiteboard before opening the chat.
|
||||||
|
2. **Hunt Edge Cases:** Ask yourself: "What if the user is already in another org? What if the database is locked?"
|
||||||
|
3. **Prompt as an Architect:**
|
||||||
|
* *Bad:* "Make an invite system."
|
||||||
|
* *Good:* "Design a NestJS service for an Invitation State Machine. It must use a database transaction, handle BullMQ for email side-effects, and include a unique constraint on (email, organization_id)."
|
||||||
|
4. **Audit the Output:** Check for N+1 query problems, lack of input validation, and missing error handling.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Next Steps for You:**
|
||||||
|
* Start with the **Invitation State Machine**.
|
||||||
|
* Implement **BullMQ** for the invitation email.
|
||||||
|
* Write your first **Terraform** script to manage your GCP firewall.
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
|
import { Public } from './auth/decorators';
|
||||||
|
|
||||||
@Controller()
|
@Controller('')
|
||||||
|
@Public(true)
|
||||||
export class AppController {
|
export class AppController {
|
||||||
constructor(private readonly appService: AppService) {}
|
constructor(private readonly appService: AppService) {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,103 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Logger, MiddlewareConsumer, Module, NestModule, ValidationPipe } from '@nestjs/common';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
|
import { UserModule } from './user/user.module';
|
||||||
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { RequestContextMiddleware } from 'core/als/request-context.middleware';
|
||||||
|
import { RequestContextModule } from 'core/als/request-context.module';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
|
import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
|
||||||
|
import { ResponseInterceptor } from 'common/interceptors/response.interceptor';
|
||||||
|
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';
|
||||||
|
import { CacheModule } from './cache/cache.module';
|
||||||
|
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||||
|
import { MailModule } from './mail/mail.module';
|
||||||
|
import { BullModule } from '@nestjs/bullmq';
|
||||||
|
import { BullBoardModule } from '@bull-board/nestjs';
|
||||||
|
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
|
||||||
|
import { ExpressAdapter } from '@bull-board/express';
|
||||||
|
import { NotificationModule } from './notification/notification.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [],
|
imports: [
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
}),
|
||||||
|
EventEmitterModule.forRoot(),
|
||||||
|
BullModule.forRoot({
|
||||||
|
connection: {
|
||||||
|
host: 'localhost',
|
||||||
|
port: 6379,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
BullBoardModule.forRoot({
|
||||||
|
route: '/queues', // Dashboard URL
|
||||||
|
adapter: ExpressAdapter,
|
||||||
|
}),
|
||||||
|
|
||||||
|
BullBoardModule.forFeature({
|
||||||
|
name: 'mail', // Register each queue you want visible
|
||||||
|
adapter: BullMQAdapter,
|
||||||
|
}),
|
||||||
|
UserModule,
|
||||||
|
AuthModule,
|
||||||
|
RequestContextModule,
|
||||||
|
PrismaModule,
|
||||||
|
OrganizationModule,
|
||||||
|
OrganizationMembershipModule,
|
||||||
|
AuthorizationModule,
|
||||||
|
CacheModule,
|
||||||
|
MailModule,
|
||||||
|
NotificationModule,
|
||||||
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [
|
||||||
|
AppService,
|
||||||
|
Logger,
|
||||||
|
{
|
||||||
|
provide: APP_INTERCEPTOR,
|
||||||
|
useClass: ResponseInterceptor,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: APP_PIPE,
|
||||||
|
useClass: ValidationPipe,
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// provide: APP_FILTER,
|
||||||
|
// useClass: HttpExceptionFilter,
|
||||||
|
// },
|
||||||
|
// NOTE: Auto cache controller response
|
||||||
|
// {
|
||||||
|
// provide: APP_INTERCEPTOR,
|
||||||
|
// useClass: CacheInterceptor,
|
||||||
|
// },
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule implements NestModule {
|
||||||
|
constructor(private readonly configService: ConfigService) { };
|
||||||
|
|
||||||
|
configure(consumer: MiddlewareConsumer) {
|
||||||
|
consumer.apply(RequestContextMiddleware).forRoutes('*paths');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure all required env vars are present
|
||||||
|
onModuleInit() {
|
||||||
|
const requiredEnvVars = [
|
||||||
|
"TOKEN_SECRET",
|
||||||
|
"DATABASE_URL",
|
||||||
|
"BULL_MQ_REDIS_HOST",
|
||||||
|
"BULL_MQ_REDIS_PORT"
|
||||||
|
]
|
||||||
|
|
||||||
|
const missingEnvVars = requiredEnvVars.filter((envVar) => !(this.configService.get<string | number>(envVar)))
|
||||||
|
|
||||||
|
if (missingEnvVars.length > 0) {
|
||||||
|
Logger.error(`One or more env variables are missing. Add: ${missingEnvVars.join(', ')} to env file.`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
18
src/auth/auth.controller.spec.ts
Normal file
18
src/auth/auth.controller.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
|
||||||
|
describe('AuthController', () => {
|
||||||
|
let controller: AuthController;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [AuthController],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
controller = module.get<AuthController>(AuthController);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
97
src/auth/auth.controller.ts
Normal file
97
src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Post,
|
||||||
|
Res,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
CompleteProfileSetupRequestDTO,
|
||||||
|
LoginUserRequestDTO,
|
||||||
|
LoginUserResponseDTO,
|
||||||
|
RegisterUserRequestDTO,
|
||||||
|
ValidateUserRegisterOTPRequestDTO,
|
||||||
|
} from './dto';
|
||||||
|
import { Response } from 'express';
|
||||||
|
import { DataResponse } from 'common/http';
|
||||||
|
import { IsTempToken, Public } from './decorators';
|
||||||
|
import { AuthGuard } from './guards/auth.guard';
|
||||||
|
|
||||||
|
@Controller('auth')
|
||||||
|
@Public()
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private readonly authService: AuthService) { }
|
||||||
|
|
||||||
|
@ApiOperation({ summary: 'User login' })
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('/login')
|
||||||
|
async login(
|
||||||
|
@Body() body: LoginUserRequestDTO,
|
||||||
|
@Res({ passthrough: true }) response: Response,
|
||||||
|
): Promise<DataResponse<LoginUserResponseDTO>> {
|
||||||
|
const { accessToken, refreshToken, user } =
|
||||||
|
await this.authService.login(body);
|
||||||
|
|
||||||
|
response.cookie('accessToken', accessToken);
|
||||||
|
|
||||||
|
return new DataResponse(
|
||||||
|
new LoginUserResponseDTO(user, accessToken, refreshToken),
|
||||||
|
'Login successfull',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ summary: 'User register' })
|
||||||
|
@Post('/register')
|
||||||
|
async register(@Body() body: RegisterUserRequestDTO): Promise<string> {
|
||||||
|
await this.authService.register(body);
|
||||||
|
|
||||||
|
return 'Check your email for OTP';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// TODO: Assign Temp Token
|
||||||
|
@ApiOperation({ summary: 'Validate OTP' })
|
||||||
|
@Post('/validate-otp')
|
||||||
|
async validateOTP(@Body() body: ValidateUserRegisterOTPRequestDTO) {
|
||||||
|
const { accessToken, refreshToken } = await this.authService.validateOtp(body);
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'Continue with the rest of profile setup',
|
||||||
|
data: {
|
||||||
|
accessToken,
|
||||||
|
refreshToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Assign Temp Token
|
||||||
|
@ApiOperation({ summary: 'Complete Profile' })
|
||||||
|
@ApiBearerAuth("access-token")
|
||||||
|
@Public(false)
|
||||||
|
@IsTempToken()
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
@Post('/complete-profile')
|
||||||
|
async completeUserProfile(@Body() body: CompleteProfileSetupRequestDTO) {
|
||||||
|
const { accessToken, refreshToken, user } = await this.authService.completeProfileSetup(body);
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: "Welcome to our app",
|
||||||
|
data: {
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logout() { }
|
||||||
|
|
||||||
|
forgotPassword() { }
|
||||||
|
|
||||||
|
regenTokens() { }
|
||||||
|
}
|
||||||
41
src/auth/auth.module.ts
Normal file
41
src/auth/auth.module.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
|
import { AuthGuard } from './guards/auth.guard';
|
||||||
|
import { UserModule } from 'src/user/user.module';
|
||||||
|
import { RequestContextModule } from 'core/als/request-context.module';
|
||||||
|
import { BullModule } from '@nestjs/bullmq';
|
||||||
|
import { PrismaModule } from 'src/prisma/prisma.module';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [
|
||||||
|
AuthService,
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: AuthGuard,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
controllers: [AuthController],
|
||||||
|
imports: [
|
||||||
|
BullModule.registerQueue({
|
||||||
|
name: "mail"
|
||||||
|
}),
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
global: true,
|
||||||
|
imports: [ConfigModule],
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService) => ({
|
||||||
|
secret: config.get<string>("TOKEN_SECRET"),
|
||||||
|
signOptions: { expiresIn: '7d' }
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
UserModule,
|
||||||
|
RequestContextModule,
|
||||||
|
PrismaModule
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AuthModule { }
|
||||||
18
src/auth/auth.service.spec.ts
Normal file
18
src/auth/auth.service.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
|
describe('AuthService', () => {
|
||||||
|
let service: AuthService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [AuthService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<AuthService>(AuthService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
252
src/auth/auth.service.ts
Normal file
252
src/auth/auth.service.ts
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
InternalServerErrorException,
|
||||||
|
UnauthorizedException
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Public } from './decorators';
|
||||||
|
import {
|
||||||
|
CompleteProfileSetupRequestDTO,
|
||||||
|
LoginUserRequestDTO,
|
||||||
|
RegisterUserRequestDTO,
|
||||||
|
ValidateUserRegisterOTPRequestDTO
|
||||||
|
} from './dto';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { UserService } from 'src/user/user.service';
|
||||||
|
import { OTPTokenInputType, TokenInputType } from './types';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { Queue } from 'bullmq';
|
||||||
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
|
import { PrismaService } from 'src/prisma/prisma.service';
|
||||||
|
import { RequestContextService } from 'core/als/request-context.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
@Public()
|
||||||
|
export class AuthService {
|
||||||
|
constructor(
|
||||||
|
private readonly userService: UserService,
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly prismaService: PrismaService,
|
||||||
|
private readonly requestContext: RequestContextService,
|
||||||
|
@InjectQueue('mail') private readonly mailQueue: Queue
|
||||||
|
) { }
|
||||||
|
|
||||||
|
// Generate OTP
|
||||||
|
async register(dto: RegisterUserRequestDTO) {
|
||||||
|
const [userExists, otpExists] = await Promise.all([
|
||||||
|
this.userService.findByEmail(dto.email),
|
||||||
|
this.userService.findByEmailInOTP(dto.email),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (userExists)
|
||||||
|
throw new ConflictException("User with this email already exists");
|
||||||
|
else if (otpExists) {
|
||||||
|
/* *
|
||||||
|
* If OTP was last generated more than 2 minutes ago, regen.
|
||||||
|
* Else, do nothing
|
||||||
|
* */
|
||||||
|
const now = Number(new Date()) / 1000;
|
||||||
|
const generatedPlusTwoMin = (Number(otpExists.createdAt) / 1000) + 60 * 2;
|
||||||
|
|
||||||
|
if (generatedPlusTwoMin > now) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const otp = this.genOtp()
|
||||||
|
|
||||||
|
await this.prismaService.$transaction(async (tx) => {
|
||||||
|
this.requestContext.tx = tx;
|
||||||
|
await this.userService.updateOTPByEmail(dto.email, otp);
|
||||||
|
})
|
||||||
|
|
||||||
|
this.mailQueue.add('send-register-otp-email', {
|
||||||
|
email: dto.email,
|
||||||
|
otp: otp
|
||||||
|
}, {
|
||||||
|
attempts: 3,
|
||||||
|
backoff: {
|
||||||
|
type: "exponential",
|
||||||
|
delay: 3000
|
||||||
|
},
|
||||||
|
removeOnComplete: true, // clean up Redis after success
|
||||||
|
removeOnFail: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate OTP
|
||||||
|
async validateOtp(dto: ValidateUserRegisterOTPRequestDTO) {
|
||||||
|
const otpExists = await this.userService.findByEmailInOTP(dto.email)
|
||||||
|
const now = Number(new Date()) / 1000;
|
||||||
|
|
||||||
|
if (!otpExists)
|
||||||
|
throw new BadRequestException("No OTP request found")
|
||||||
|
else if (otpExists.otp !== dto.otp)
|
||||||
|
throw new BadRequestException("Invalid OTP")
|
||||||
|
else if ((Number(otpExists.expiresAt) / 1000 < now)) {
|
||||||
|
await this.userService.removeByEmailInOTP(dto.email);
|
||||||
|
throw new BadRequestException("OTP has expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.prismaService.$transaction(async (tx) => {
|
||||||
|
this.requestContext.tx = tx;
|
||||||
|
await this.userService.removeByEmailInOTP(dto.email);
|
||||||
|
return await this.userService.initializeUserWithEmail(dto.email)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!user)
|
||||||
|
throw new InternalServerErrorException()
|
||||||
|
|
||||||
|
const token = {
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
status: user.status
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = this.genSignedTempToken(token)
|
||||||
|
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete rest of singup process
|
||||||
|
async completeProfileSetup(dto: CompleteProfileSetupRequestDTO) {
|
||||||
|
const user = this.requestContext.user;
|
||||||
|
if (!user)
|
||||||
|
throw new UnauthorizedException("User")
|
||||||
|
|
||||||
|
const hashedPassword = await bcrypt.hash(dto.password, 10);
|
||||||
|
|
||||||
|
const {
|
||||||
|
newUser,
|
||||||
|
userAdditionalInfo: _
|
||||||
|
} = await this.prismaService.$transaction(async (tx) => {
|
||||||
|
this.requestContext.tx = tx;
|
||||||
|
|
||||||
|
const newUser = await this.userService.createUserWithPassword(
|
||||||
|
user.email,
|
||||||
|
hashedPassword,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!newUser)
|
||||||
|
throw new UnauthorizedException()
|
||||||
|
|
||||||
|
const userAdditionalInfo = await this.userService.createUserAdditionalInformation(
|
||||||
|
newUser?.id,
|
||||||
|
dto
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
newUser,
|
||||||
|
userAdditionalInfo,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.mailQueue.add('send-welcome-email', {
|
||||||
|
email: user.email,
|
||||||
|
}, {
|
||||||
|
attempts: 3,
|
||||||
|
backoff: {
|
||||||
|
type: "exponential",
|
||||||
|
delay: 3000,
|
||||||
|
},
|
||||||
|
removeOnComplete: true, // clean up Redis after success
|
||||||
|
removeOnFail: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const token = {
|
||||||
|
userId: newUser.id,
|
||||||
|
email: newUser.email,
|
||||||
|
role: newUser.role,
|
||||||
|
status: newUser.status
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
accessToken,
|
||||||
|
refreshToken
|
||||||
|
} = await this.genSignedTokens(token)
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
user: newUser
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(dto: LoginUserRequestDTO) {
|
||||||
|
const user = await this.userService.findUserForAuth(dto.email);
|
||||||
|
if (!user) throw new UnauthorizedException('Invalid credentials.');
|
||||||
|
else if (!user.password) {
|
||||||
|
const token = {
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
status: user.status
|
||||||
|
}
|
||||||
|
|
||||||
|
const { accessToken, refreshToken } = await this.genSignedTempToken(token)
|
||||||
|
|
||||||
|
return { accessToken, refreshToken, user };
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordMatch = await bcrypt.compare(dto.password, user.password);
|
||||||
|
if (!passwordMatch) throw new UnauthorizedException('Invalid credentials.');
|
||||||
|
|
||||||
|
const token = {
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
status: user.status
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO: Store more info: orgId, orgRole, etc
|
||||||
|
const { accessToken, refreshToken } = await this.genSignedTokens(token);
|
||||||
|
const hashedRefreshToken = await bcrypt.hash(refreshToken, 10);
|
||||||
|
|
||||||
|
await this.userService.updateRefreshToken(user.id, hashedRefreshToken);
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
user,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
logout() { }
|
||||||
|
|
||||||
|
resetPassword() { }
|
||||||
|
|
||||||
|
// TODO: If remember me is there, sign for like 30d maybe
|
||||||
|
private async genSignedTokens(token: TokenInputType) {
|
||||||
|
const accessToken = await this.jwtService.signAsync(token);
|
||||||
|
|
||||||
|
const refreshToken = await this.jwtService.signAsync(
|
||||||
|
{
|
||||||
|
userId: token.userId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return { accessToken, refreshToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async genSignedTempToken(token: OTPTokenInputType) {
|
||||||
|
const accessToken = await this.jwtService.signAsync(token);
|
||||||
|
|
||||||
|
const refreshToken = await this.jwtService.signAsync(
|
||||||
|
{
|
||||||
|
userId: token.userId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return { accessToken, refreshToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
genOtp(): number {
|
||||||
|
const array = new Uint32Array(1);
|
||||||
|
crypto.getRandomValues(array);
|
||||||
|
const otp = array[0] % 900000 + 100000;
|
||||||
|
return otp;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
16
src/auth/decorators/authorization.decorator.ts
Normal file
16
src/auth/decorators/authorization.decorator.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { applyDecorators, SetMetadata, UseGuards } from "@nestjs/common";
|
||||||
|
import { CAN_PERFORM_KEY } from "common/keys";
|
||||||
|
import { ORG_ROLE } from "prisma/generated/prisma/enums";
|
||||||
|
import { AuthorizationGuard } from "../guards";
|
||||||
|
|
||||||
|
/*
|
||||||
|
*Is this user part of the organization (And optionally, has required role)
|
||||||
|
* */
|
||||||
|
|
||||||
|
export function Authorization(role?: ORG_ROLE[]) {
|
||||||
|
return applyDecorators(
|
||||||
|
SetMetadata(CAN_PERFORM_KEY, role),
|
||||||
|
UseGuards(AuthorizationGuard)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
//export const Authorization = (role?: ORG_ROLE[]) => SetMetadata(CAN_PERFORM_KEY, role)
|
||||||
4
src/auth/decorators/index.ts
Normal file
4
src/auth/decorators/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './public.decorator';
|
||||||
|
export * from './role.decorator';
|
||||||
|
export * from './authorization.decorator';
|
||||||
|
export * from './isTemp.decorator'
|
||||||
4
src/auth/decorators/isTemp.decorator.ts
Normal file
4
src/auth/decorators/isTemp.decorator.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { SetMetadata } from "@nestjs/common";
|
||||||
|
import { TEMP_TOKEN_KEY } from "common/keys";
|
||||||
|
|
||||||
|
export const IsTempToken = () => SetMetadata(TEMP_TOKEN_KEY, true)
|
||||||
5
src/auth/decorators/public.decorator.ts
Normal file
5
src/auth/decorators/public.decorator.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
import { PUBLIC_KEY } from 'common/keys';
|
||||||
|
|
||||||
|
export const Public = (isPublic?: boolean) =>
|
||||||
|
SetMetadata(PUBLIC_KEY, isPublic ?? true);
|
||||||
6
src/auth/decorators/role.decorator.ts
Normal file
6
src/auth/decorators/role.decorator.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
import { ORG_ROLE_KEY, ROLE_KEY } from 'common/keys';
|
||||||
|
|
||||||
|
export const Roles = (role: string) => SetMetadata(ROLE_KEY, role);
|
||||||
|
|
||||||
|
export const OrgRole = (role: string) => SetMetadata(ORG_ROLE_KEY, role);
|
||||||
42
src/auth/dto/complete-setup.dto.ts
Normal file
42
src/auth/dto/complete-setup.dto.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { IsNotEmpty, IsOptional, IsString, MinLength } from "class-validator";
|
||||||
|
|
||||||
|
export class CompleteProfileSetupRequestDTO {
|
||||||
|
@ApiProperty({
|
||||||
|
description: "User's firstName",
|
||||||
|
example: 'John',
|
||||||
|
type: 'string',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
firstName: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: "User's middleName",
|
||||||
|
example: 'Kumar',
|
||||||
|
type: 'string',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
middleName?: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "User's lastName",
|
||||||
|
example: 'Doe',
|
||||||
|
type: 'string',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
lastName: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "User's password",
|
||||||
|
example: '123456',
|
||||||
|
type: 'string',
|
||||||
|
minLength: 6,
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MinLength(6)
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
5
src/auth/dto/index.ts
Normal file
5
src/auth/dto/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export * from './register-user.dto';
|
||||||
|
export * from './login-user.dto';
|
||||||
|
export * from './login-response.dto';
|
||||||
|
export * from "./validate-otp.dto";
|
||||||
|
export * from "./complete-setup.dto";
|
||||||
14
src/auth/dto/login-response.dto.ts
Normal file
14
src/auth/dto/login-response.dto.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { User } from 'prisma/generated/prisma/client';
|
||||||
|
import { UserDTO } from 'src/user/dtos';
|
||||||
|
|
||||||
|
export class LoginUserResponseDTO {
|
||||||
|
readonly accessToken: string;
|
||||||
|
readonly refreshToken: string;
|
||||||
|
readonly user: UserDTO;
|
||||||
|
|
||||||
|
constructor(user: User, accessToken: string, refreshToken: string) {
|
||||||
|
this.user = new UserDTO(user);
|
||||||
|
this.accessToken = accessToken;
|
||||||
|
this.refreshToken = refreshToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/auth/dto/login-user.dto.ts
Normal file
24
src/auth/dto/login-user.dto.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class LoginUserRequestDTO {
|
||||||
|
@ApiProperty({
|
||||||
|
description: "User's email",
|
||||||
|
example: 'user@example.com',
|
||||||
|
type: 'string',
|
||||||
|
})
|
||||||
|
@IsEmail()
|
||||||
|
@IsNotEmpty()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "User's password",
|
||||||
|
example: '123456',
|
||||||
|
type: 'string',
|
||||||
|
minLength: 6,
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MinLength(6)
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
16
src/auth/dto/register-user.dto.ts
Normal file
16
src/auth/dto/register-user.dto.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
IsEmail,
|
||||||
|
IsNotEmpty,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class RegisterUserRequestDTO {
|
||||||
|
@ApiProperty({
|
||||||
|
description: "User's email",
|
||||||
|
example: 'user@example.com',
|
||||||
|
type: 'string',
|
||||||
|
})
|
||||||
|
@IsEmail()
|
||||||
|
@IsNotEmpty()
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
22
src/auth/dto/validate-otp.dto.ts
Normal file
22
src/auth/dto/validate-otp.dto.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsEmail, IsNotEmpty, IsNumber } from "class-validator";
|
||||||
|
|
||||||
|
export class ValidateUserRegisterOTPRequestDTO {
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Register OTP",
|
||||||
|
example: 123456,
|
||||||
|
type: 'number',
|
||||||
|
})
|
||||||
|
@IsNumber()
|
||||||
|
@IsNotEmpty()
|
||||||
|
otp: number
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "User's email",
|
||||||
|
example: 'user@example.com',
|
||||||
|
type: 'string',
|
||||||
|
})
|
||||||
|
@IsEmail()
|
||||||
|
@IsNotEmpty()
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
76
src/auth/guards/auth.guard.ts
Normal file
76
src/auth/guards/auth.guard.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { RequestContextService } from 'core/als/request-context.service';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { JwtPayload } from '../types';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { PUBLIC_KEY, TEMP_TOKEN_KEY } from 'common/keys';
|
||||||
|
import { UserService } from 'src/user/user.service';
|
||||||
|
import { USER_ACCOUNT_STATUS } from 'prisma/generated/prisma/enums';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private readonly reflector: Reflector,
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly requestContext: RequestContextService,
|
||||||
|
private readonly userService: UserService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext) {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
|
||||||
|
const isPublicRoute = this.reflector.getAllAndOverride<boolean>(
|
||||||
|
PUBLIC_KEY,
|
||||||
|
[context.getHandler(), context.getClass()],
|
||||||
|
);
|
||||||
|
if (isPublicRoute) return true;
|
||||||
|
|
||||||
|
const isTempToken = this.reflector.getAllAndOverride<boolean>(
|
||||||
|
TEMP_TOKEN_KEY,
|
||||||
|
[context.getHandler(), context.getClass()],
|
||||||
|
)
|
||||||
|
|
||||||
|
const token = this.extractTokenFromHeader(request);
|
||||||
|
if (!token) throw new UnauthorizedException();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload: JwtPayload = await this.jwtService.verifyAsync(token, {
|
||||||
|
secret: this.configService.get<string>("TOKEN_SECRET"),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isTempToken && payload.status !== USER_ACCOUNT_STATUS.pending)
|
||||||
|
throw new UnauthorizedException()
|
||||||
|
|
||||||
|
|
||||||
|
// TODO: Redis + Org too, blacklist token
|
||||||
|
const userExists = await this.userService.findById(payload.userId);
|
||||||
|
if (!userExists) throw new UnauthorizedException();
|
||||||
|
|
||||||
|
// NOTE: Add more checks here (other account status)
|
||||||
|
if (userExists.status !== USER_ACCOUNT_STATUS.active) {
|
||||||
|
if (userExists.status === USER_ACCOUNT_STATUS.pending && isTempToken === undefined)
|
||||||
|
throw new ForbiddenException()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.requestContext.set('user', payload);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractTokenFromHeader(request: Request): string | undefined {
|
||||||
|
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||||
|
return type === 'Bearer' ? token : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
57
src/auth/guards/authorization.guard.ts
Normal file
57
src/auth/guards/authorization.guard.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { Reflector } from "@nestjs/core";
|
||||||
|
import { CAN_PERFORM_KEY } from "common/keys";
|
||||||
|
import { RequestContextService } from "core/als/request-context.service";
|
||||||
|
import { ORG_ROLE } from "prisma/generated/prisma/enums";
|
||||||
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthorizationGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private readonly reqeustContext: RequestContextService,
|
||||||
|
private readonly reflector: Reflector,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) { };
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const requiredRole = this.reflector.getAllAndOverride<ORG_ROLE[] | undefined>(
|
||||||
|
CAN_PERFORM_KEY,
|
||||||
|
[context.getHandler(), context.getClass()]
|
||||||
|
)
|
||||||
|
|
||||||
|
const userId = this.reqeustContext.user.userId;
|
||||||
|
if (!userId)
|
||||||
|
throw new UnauthorizedException()
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest()
|
||||||
|
const orgId = request.params.orgId;
|
||||||
|
|
||||||
|
if (!orgId)
|
||||||
|
throw new BadRequestException()
|
||||||
|
|
||||||
|
const userIsPartOfOrg = await this.prisma.organizationUserJoinTable.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_orgId: {
|
||||||
|
userId,
|
||||||
|
orgId
|
||||||
|
},
|
||||||
|
...(requiredRole ? { role: { in: requiredRole } } : {})
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
userId: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!userIsPartOfOrg)
|
||||||
|
throw new ForbiddenException()
|
||||||
|
|
||||||
|
this.reqeustContext.orgId = orgId;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/auth/guards/index.ts
Normal file
1
src/auth/guards/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./authorization.guard"
|
||||||
26
src/auth/guards/rbac.guard.ts
Normal file
26
src/auth/guards/rbac.guard.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { ROLE_KEY } from 'common/keys';
|
||||||
|
import { RequestContextService } from 'core/als/request-context.service';
|
||||||
|
|
||||||
|
export class RbacGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private readonly reflector: Reflector,
|
||||||
|
private readonly requestContext: RequestContextService,
|
||||||
|
) { }
|
||||||
|
canActivate(context: ExecutionContext) {
|
||||||
|
const requiredRole = this.reflector.getAllAndOverride<string>(ROLE_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const user = this.requestContext.user;
|
||||||
|
if (!user) throw new UnauthorizedException();
|
||||||
|
|
||||||
|
return user.role === requiredRole;
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/auth/types/index.ts
Normal file
3
src/auth/types/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './jwt';
|
||||||
|
export * from './role';
|
||||||
|
export * from './token';
|
||||||
13
src/auth/types/jwt.ts
Normal file
13
src/auth/types/jwt.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { ORG_ROLE, USER_ACCOUNT_STATUS, USER_ROLE } from 'prisma/generated/prisma/enums';
|
||||||
|
|
||||||
|
export interface JwtPayload {
|
||||||
|
iat?: number;
|
||||||
|
exp?: number;
|
||||||
|
orgId?: string;
|
||||||
|
orgRole?: ORG_ROLE;
|
||||||
|
status: USER_ACCOUNT_STATUS;
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
role: USER_ROLE;
|
||||||
|
permissions?: string[];
|
||||||
|
}
|
||||||
1
src/auth/types/role.ts
Normal file
1
src/auth/types/role.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export type UserRoleType = 'user' | 'admin';
|
||||||
20
src/auth/types/token.ts
Normal file
20
src/auth/types/token.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { USER_ACCOUNT_STATUS } from "prisma/generated/prisma/enums";
|
||||||
|
|
||||||
|
export interface TokenInputType {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
status: USER_ACCOUNT_STATUS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OTPTokenInputType {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
status: USER_ACCOUNT_STATUS
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccessTokenPayloadType extends TokenInputType { }
|
||||||
|
|
||||||
|
export interface RefreshTokenPayloadType {
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
10
src/authorization/authorization.module.ts
Normal file
10
src/authorization/authorization.module.ts
Normal file
@@ -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 {}
|
||||||
18
src/authorization/authorization.service.spec.ts
Normal file
18
src/authorization/authorization.service.spec.ts
Normal file
@@ -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>(AuthorizationService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
71
src/authorization/authorization.service.ts
Normal file
71
src/authorization/authorization.service.ts
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/authorization/operations.ts
Normal file
5
src/authorization/operations.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export enum USER_ORGANIZATION_OPERATIONS {
|
||||||
|
UPDATE_ORGANIZATION = 'update_organization',
|
||||||
|
DELETE_ORGANIZATION = 'delete_organization',
|
||||||
|
INVITE_USERS = 'invite_users',
|
||||||
|
}
|
||||||
32
src/cache/cache.module.ts
vendored
Normal file
32
src/cache/cache.module.ts
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CacheModule as NestCacheManager } from '@nestjs/cache-manager';
|
||||||
|
import KeyvRedis from '@keyv/redis';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { CacheService } from './cache.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
NestCacheManager.registerAsync({
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: async (configService: ConfigService) => {
|
||||||
|
const redisUrl = configService.get<string>('REDIS_URL');
|
||||||
|
const redisStore = new KeyvRedis(redisUrl, {
|
||||||
|
connectionTimeout: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
redisStore.on('error', (err) => {
|
||||||
|
console.error('Redis error:', err.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
stores: [redisStore],
|
||||||
|
ttl: 120 * 1000,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
isGlobal: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
providers: [CacheService],
|
||||||
|
exports: [CacheService],
|
||||||
|
})
|
||||||
|
export class CacheModule {}
|
||||||
18
src/cache/cache.service.spec.ts
vendored
Normal file
18
src/cache/cache.service.spec.ts
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { CacheService } from './cache.service';
|
||||||
|
|
||||||
|
describe('CacheService', () => {
|
||||||
|
let service: CacheService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [CacheService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<CacheService>(CacheService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
65
src/cache/cache.service.ts
vendored
Normal file
65
src/cache/cache.service.ts
vendored
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { CACHE_MANAGER, Cache } from '@nestjs/cache-manager';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CacheService {
|
||||||
|
private redisAvailable = true;
|
||||||
|
|
||||||
|
constructor(@Inject(CACHE_MANAGER) private cache: Cache) {
|
||||||
|
const store = this.cache.stores[0];
|
||||||
|
|
||||||
|
if (store?.on) {
|
||||||
|
store.on('end', () => {
|
||||||
|
this.redisAvailable = false;
|
||||||
|
console.warn('Redis disconnected');
|
||||||
|
});
|
||||||
|
|
||||||
|
store.on('ready', () => {
|
||||||
|
this.redisAvailable = true;
|
||||||
|
console.log('Redis ready');
|
||||||
|
});
|
||||||
|
|
||||||
|
store.on('error', () => {
|
||||||
|
this.redisAvailable = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOrSet<T>(
|
||||||
|
key: string,
|
||||||
|
factory: () => Promise<T>,
|
||||||
|
ttl?: number,
|
||||||
|
): Promise<T> {
|
||||||
|
if (this.redisAvailable) {
|
||||||
|
try {
|
||||||
|
const cached = await this.cache.get<T>(key);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.redisAvailable = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to DB
|
||||||
|
const fresh = await factory();
|
||||||
|
|
||||||
|
if (fresh) {
|
||||||
|
// Try setting cache only if Redis available
|
||||||
|
if (this.redisAvailable) {
|
||||||
|
try {
|
||||||
|
await this.cache.set(key, fresh, ttl);
|
||||||
|
} catch {
|
||||||
|
this.redisAvailable = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteKey(key: string) {
|
||||||
|
const a = await this.cache.del(key);
|
||||||
|
console.log(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/mail/mail-job-names.ts
Normal file
4
src/mail/mail-job-names.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export const MAIL_JOBS_NAME = {
|
||||||
|
WELCOME: 'send-welcome-email',
|
||||||
|
REGISTER_OTP: 'send-register-otp-email'
|
||||||
|
}
|
||||||
44
src/mail/mail.consumer.ts
Normal file
44
src/mail/mail.consumer.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Processor, WorkerHost } from "@nestjs/bullmq";
|
||||||
|
import { Job } from "bullmq";
|
||||||
|
import { MailService } from "./mail.service";
|
||||||
|
import { MAIL_JOBS_NAME } from "./mail-job-names";
|
||||||
|
import { RegisterOtpEmailJob, WelcomeEmailJob } from "./mail.interface";
|
||||||
|
|
||||||
|
@Processor('mail')
|
||||||
|
export class MailConsumer extends WorkerHost {
|
||||||
|
constructor(private readonly mailService: MailService) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
// This runs, so we define handlers here
|
||||||
|
async process(job: Job) {
|
||||||
|
const handlers: Record<string, (job: Job) => Promise<void>> = {
|
||||||
|
[MAIL_JOBS_NAME.REGISTER_OTP]: (j: Job<RegisterOtpEmailJob>) =>
|
||||||
|
this.handleSendOTPMail(j),
|
||||||
|
|
||||||
|
[MAIL_JOBS_NAME.WELCOME]: (j: Job<WelcomeEmailJob>) =>
|
||||||
|
this.handleSendWelcomeMail(j),
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler = handlers[job.name];
|
||||||
|
if (!handler) throw new Error(`No handler for job: ${job.name}`);
|
||||||
|
await handler(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* These are seperated. Using switch-case is not scalable, couldn't define types
|
||||||
|
* when there were multiple types of emails to be sent
|
||||||
|
* */
|
||||||
|
async handleSendOTPMail(job: Job<RegisterOtpEmailJob>) {
|
||||||
|
await this.mailService.sendOTPMail({
|
||||||
|
to: job.data.email,
|
||||||
|
otp: job.data.otp
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleSendWelcomeMail(job: Job<WelcomeEmailJob>) {
|
||||||
|
await this.mailService.sendWelcomeMail({ to: job.data.email })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/mail/mail.interface.ts
Normal file
8
src/mail/mail.interface.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export interface WelcomeEmailJob {
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterOtpEmailJob {
|
||||||
|
email: string;
|
||||||
|
otp: number;
|
||||||
|
}
|
||||||
15
src/mail/mail.module.ts
Normal file
15
src/mail/mail.module.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MailService } from './mail.service';
|
||||||
|
import { BullModule } from '@nestjs/bullmq';
|
||||||
|
import { MailConsumer } from './mail.consumer';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
BullModule.registerQueue({
|
||||||
|
name: "mail"
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
providers: [MailService, MailConsumer],
|
||||||
|
exports: [MailService]
|
||||||
|
})
|
||||||
|
export class MailModule { }
|
||||||
18
src/mail/mail.service.spec.ts
Normal file
18
src/mail/mail.service.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { MailService } from './mail.service';
|
||||||
|
|
||||||
|
describe('MailService', () => {
|
||||||
|
let service: MailService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [MailService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<MailService>(MailService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
77
src/mail/mail.service.ts
Normal file
77
src/mail/mail.service.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import EmailTemplates from 'common/emails';
|
||||||
|
import * as nodemailer from "nodemailer";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MailService {
|
||||||
|
private transporter: nodemailer.Transporter;
|
||||||
|
private mailServiceAvailable = false;
|
||||||
|
|
||||||
|
constructor(private readonly configService: ConfigService) {
|
||||||
|
const mailId = this.configService.get<string | undefined>("MAIL_ID");
|
||||||
|
const mailPass = this.configService.get<string | undefined>("MAIL_PASS");
|
||||||
|
|
||||||
|
if (!mailId || !mailPass)
|
||||||
|
throw new Error("Make sure MAIL_ID and MAIL_PASS environment variables are set")
|
||||||
|
|
||||||
|
this.transporter = nodemailer.createTransport({
|
||||||
|
host: "smtp.gmail.com",
|
||||||
|
port: 587,
|
||||||
|
secure: false, // Use true for port 465, false for port 587
|
||||||
|
auth: {
|
||||||
|
user: mailId,
|
||||||
|
pass: mailPass,
|
||||||
|
},
|
||||||
|
from: mailId
|
||||||
|
});
|
||||||
|
|
||||||
|
this.mailServiceAvailable = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* SIGN-UP
|
||||||
|
* */
|
||||||
|
async sendWelcomeMail({ to }: { to: string }) {
|
||||||
|
if (!this.mailServiceAvailable)
|
||||||
|
throw new Error("Mail service not available")
|
||||||
|
|
||||||
|
const email = EmailTemplates.signup_completed;
|
||||||
|
|
||||||
|
await this.transporter.sendMail(
|
||||||
|
{
|
||||||
|
to,
|
||||||
|
subject: email.subject,
|
||||||
|
html: email.body
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendOTPMail({ to, otp }: { to: string, otp: number }) {
|
||||||
|
if (!this.mailServiceAvailable)
|
||||||
|
throw new Error("Mail service not available")
|
||||||
|
|
||||||
|
const email = EmailTemplates.signup_otp(otp);
|
||||||
|
|
||||||
|
await this.transporter.sendMail(
|
||||||
|
{
|
||||||
|
to,
|
||||||
|
subject: email.subject,
|
||||||
|
html: email.body
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
sendMail({ to, subject, body }: { to: string, subject: string, body: string }) {
|
||||||
|
if (!this.mailServiceAvailable)
|
||||||
|
throw new Error("Mail service not available")
|
||||||
|
|
||||||
|
this.transporter.sendMail(
|
||||||
|
{
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
html: body
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/main.ts
47
src/main.ts
@@ -1,8 +1,53 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
|
import { Logger, ValidationPipe } from '@nestjs/common';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
await app.listen(process.env.PORT ?? 3000);
|
|
||||||
|
const swaggerConfig = new DocumentBuilder()
|
||||||
|
.setTitle('MultiTenant Saas')
|
||||||
|
.setDescription(`API Documentation for a simple MultiTenant Saas Application`)
|
||||||
|
.setVersion('0.0.1')
|
||||||
|
.addGlobalResponse(
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
description: 'Internal Server Error',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
description: 'Unauthorized',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
description: 'Forbidden',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.addBearerAuth(
|
||||||
|
{
|
||||||
|
type: 'http',
|
||||||
|
scheme: 'bearer',
|
||||||
|
bearerFormat: 'JWT',
|
||||||
|
},
|
||||||
|
'access-token',
|
||||||
|
)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const documentFactory = () =>
|
||||||
|
SwaggerModule.createDocument(app, swaggerConfig);
|
||||||
|
SwaggerModule.setup('/docs', app, documentFactory, {
|
||||||
|
swaggerOptions: {
|
||||||
|
persistAuthorization: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = app.get(ConfigService);
|
||||||
|
const port = config.get<number>('PORT') ?? 3000;
|
||||||
|
|
||||||
|
await app.listen(port);
|
||||||
|
|
||||||
|
Logger.log(`Listning on port ${port}`)
|
||||||
}
|
}
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
19
src/notification/notification.dto.ts
Normal file
19
src/notification/notification.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import {
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString
|
||||||
|
} from "class-validator"
|
||||||
|
|
||||||
|
export class NotificationDTO {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
18
src/notification/notification.gateway.spec.ts
Normal file
18
src/notification/notification.gateway.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { NotificationGateway } from './notification.gateway';
|
||||||
|
|
||||||
|
describe('NotificationGateway', () => {
|
||||||
|
let gateway: NotificationGateway;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [NotificationGateway],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
gateway = module.get<NotificationGateway>(NotificationGateway);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(gateway).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
29
src/notification/notification.gateway.ts
Normal file
29
src/notification/notification.gateway.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import {
|
||||||
|
ConnectedSocket,
|
||||||
|
MessageBody,
|
||||||
|
SubscribeMessage,
|
||||||
|
WebSocketGateway
|
||||||
|
} from "@nestjs/websockets";
|
||||||
|
import { Socket } from "net";
|
||||||
|
import { NotificationDTO } from "./notification.dto";
|
||||||
|
import { UseFilters, UsePipes, ValidationPipe } from "@nestjs/common";
|
||||||
|
import { WsValidationExceptionFilter } from "../../common/exceptions/websocket";
|
||||||
|
|
||||||
|
@WebSocketGateway()
|
||||||
|
@UsePipes(new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
}))
|
||||||
|
@UseFilters(new WsValidationExceptionFilter())
|
||||||
|
export class NotificationGateway {
|
||||||
|
|
||||||
|
@SubscribeMessage('hello')
|
||||||
|
handleHello(
|
||||||
|
@MessageBody() body: NotificationDTO,
|
||||||
|
@ConnectedSocket() client: Socket
|
||||||
|
) {
|
||||||
|
console.log(body);
|
||||||
|
client.emit("hello", "Hi")
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/notification/notification.module.ts
Normal file
8
src/notification/notification.module.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { NotificationService } from './notification.service';
|
||||||
|
import { NotificationGateway } from './notification.gateway';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [NotificationService, NotificationGateway]
|
||||||
|
})
|
||||||
|
export class NotificationModule {}
|
||||||
18
src/notification/notification.service.spec.ts
Normal file
18
src/notification/notification.service.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { NotificationService } from './notification.service';
|
||||||
|
|
||||||
|
describe('NotificationService', () => {
|
||||||
|
let service: NotificationService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [NotificationService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<NotificationService>(NotificationService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
4
src/notification/notification.service.ts
Normal file
4
src/notification/notification.service.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NotificationService {}
|
||||||
1
src/organization-membership/constants/index.ts
Normal file
1
src/organization-membership/constants/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./type"
|
||||||
4
src/organization-membership/constants/type.ts
Normal file
4
src/organization-membership/constants/type.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export enum USER_ORG_ACCEPT_REJECT_ACTION {
|
||||||
|
ACCEPT = 'ACCEPT',
|
||||||
|
REJECT = 'REJECT'
|
||||||
|
}
|
||||||
4
src/organization-membership/dto/index.ts
Normal file
4
src/organization-membership/dto/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './invite-to-org.dto';
|
||||||
|
export * from './join-request.dto'
|
||||||
|
export * from "./user-invitation-action.dto"
|
||||||
|
export * from "./org-request-action.dto"
|
||||||
34
src/organization-membership/dto/invite-to-org.dto.ts
Normal file
34
src/organization-membership/dto/invite-to-org.dto.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsEmail, IsEnum, IsNotEmpty, IsUUID } from 'class-validator';
|
||||||
|
import { ORG_ROLE } from 'prisma/generated/prisma/enums';
|
||||||
|
|
||||||
|
export class InviteUserToOrganizationRequestDTO {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Who to invite',
|
||||||
|
example: 'user1@example.com',
|
||||||
|
type: 'string',
|
||||||
|
})
|
||||||
|
@IsEmail()
|
||||||
|
@IsNotEmpty()
|
||||||
|
invitedUserEmail: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Role to assign',
|
||||||
|
example: ORG_ROLE.member,
|
||||||
|
type: 'string',
|
||||||
|
})
|
||||||
|
@IsEnum(ORG_ROLE)
|
||||||
|
@IsNotEmpty()
|
||||||
|
role: ORG_ROLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CancelInviteUserToOrganizationRequestDTO {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Who to cancel',
|
||||||
|
example: 'bb1c81da-ce8f-4231-aee8-2b976124a589',
|
||||||
|
type: 'string',
|
||||||
|
})
|
||||||
|
@IsUUID()
|
||||||
|
@IsNotEmpty()
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
13
src/organization-membership/dto/join-request.dto.ts
Normal file
13
src/organization-membership/dto/join-request.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
23
src/organization-membership/dto/org-request-action.dto.ts
Normal file
23
src/organization-membership/dto/org-request-action.dto.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"
|
||||||
|
import { IsEnum, IsNotEmpty, IsOptional, IsString } from "class-validator"
|
||||||
|
import { USER_ORG_ACCEPT_REJECT_ACTION } from "../constants"
|
||||||
|
|
||||||
|
export class UserOrganizationRequestActionRequestDTO {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Action',
|
||||||
|
example: USER_ORG_ACCEPT_REJECT_ACTION.ACCEPT,
|
||||||
|
type: 'string',
|
||||||
|
})
|
||||||
|
@IsEnum(USER_ORG_ACCEPT_REJECT_ACTION)
|
||||||
|
@IsNotEmpty()
|
||||||
|
action: USER_ORG_ACCEPT_REJECT_ACTION
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Message(reject reason)',
|
||||||
|
example: 'Bad sry or smth',
|
||||||
|
type: 'string',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { IsEnum, IsNotEmpty, IsOptional, IsString } 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
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user