Compare commits

...

18 Commits

Author SHA1 Message Date
SauravDhakal
4686e0abbc feat: ws simple setup only 2026-04-12 21:50:46 +05:45
SauravDhakal
2f01adeade fix: Prisma issue fix + Auth done 2026-04-12 08:06:05 +05:45
SauravDhakal
aa8deadf1f feat: Change auth flow 2026-04-11 07:57:28 +05:45
SauravDhakal
ab8b2ef353 fix: auth otp flow + remove generated 2026-04-05 16:19:19 +05:45
SauravDhakal
4905c6f1d1 fix: Welcome mail send using worker 2026-04-05 12:43:35 +05:45
SauravDhakal
2f30be8c82 fix: Added bullmq 2026-04-04 22:38:09 +05:45
SauravDhakal
f21ee1d131 feat: Event architecture implemented 2026-04-04 20:53:07 +05:45
SauravDhakal
9d931e0d96 fix: Move mail to src/ 2026-04-04 08:27:34 +05:45
SauravDhakal
68135ae022 fix: Org slight 2026-04-02 21:51:46 +05:45
SauravDhakal
349196b801 feat: Added methods for organization 2026-03-11 21:47:35 +05:45
SauravDhakal
6fc494687a feat: Simple nodemailer integration 2026-03-07 07:38:50 +05:45
SauravDhakal
496d689ec1 feat: User operations on join org 2026-03-04 22:26:20 +05:45
sauravdhakal12
024702dd26 wip: added cache 2026-02-27 21:26:36 +05:45
sauravdhakal12
90b0192cd2 feat: Organization operations like invite and accept 2026-02-22 17:27:37 +05:45
sauravdhakal12
afed1731d2 feat: Organization services 2026-02-22 15:47:45 +05:45
sauravdhakal12
f4c9174752 feat: Basic setup with auth 2026-02-21 17:21:48 +05:45
sauravdhakal12
f6bce78aee feat: User services 2026-02-20 15:53:45 +05:45
sauravdhakal12
9561693cb4 feat: als and prisma 2026-02-10 17:19:53 +05:45
120 changed files with 5999 additions and 150 deletions

5
.gitignore vendored
View File

@@ -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
View File

@@ -0,0 +1 @@
service:

389
CODE_REVIEW.md Normal file
View 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
View File

@@ -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>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](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
View 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">
&copy; 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
View 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

View 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();
// }
// }

View 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
}
}

View 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,
// });
}
}

View File

@@ -0,0 +1 @@
export * from './custom-exceptions';

View 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
View File

@@ -0,0 +1 @@
export * from './response';

25
common/http/response.ts Normal file
View 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;
}

View File

View 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
View 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__';

View 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';
},
},
});
};
}

View File

@@ -0,0 +1 @@
export * from './at-least-one-field';

View File

@@ -0,0 +1,6 @@
import { IsUUID } from 'class-validator';
export class UUIDQueryDTO {
@IsUUID()
id: string;
}

View 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);
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { RequestContextService } from './request-context.service';
@Module({
providers: [RequestContextService],
exports: [RequestContextService],
})
export class RequestContextModule {}

View 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)
}
}

View 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;
}

View 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:

View File

@@ -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

File diff suppressed because it is too large Load Diff

14
prisma.config.ts Normal file
View 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'],
},
});

View File

@@ -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");

View File

@@ -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';

View File

@@ -0,0 +1 @@
-- This is an empty migration.

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "organization_join_request" ADD COLUMN "role" "ORG_ROLE" NOT NULL DEFAULT 'member';

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "organization_join_request" ADD COLUMN "requestMessage" TEXT;

View File

@@ -0,0 +1 @@
-- This is an empty migration.

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "ORGANIZATION_JOIN_REQUEST" ADD VALUE 'CANCELLED';

View File

@@ -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");

View File

@@ -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");

View File

@@ -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;

View File

@@ -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;

View 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"

View 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
}

View 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
}

View 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")
}

View 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
View 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
View 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 Architects 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.

View File

@@ -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) {}

View File

@@ -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)
}
}
}

View 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();
});
});

View 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
View 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 { }

View 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
View 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;
}
}

View 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)

View File

@@ -0,0 +1,4 @@
export * from './public.decorator';
export * from './role.decorator';
export * from './authorization.decorator';
export * from './isTemp.decorator'

View 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)

View 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);

View 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);

View 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
View 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";

View 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;
}
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}
}

View 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
View File

@@ -0,0 +1 @@
export * from "./authorization.guard"

View 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
View File

@@ -0,0 +1,3 @@
export * from './jwt';
export * from './role';
export * from './token';

13
src/auth/types/jwt.ts Normal file
View 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
View File

@@ -0,0 +1 @@
export type UserRoleType = 'user' | 'admin';

20
src/auth/types/token.ts Normal file
View 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;
}

View 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 {}

View 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();
});
});

View 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,
},
},
});
}
}

View 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
View 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
View 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
View 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);
}
}

View 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
View 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
}
}

View 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
View 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 { }

View 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
View 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
}
)
}
}

View File

@@ -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();

View 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;
}

View 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();
});
});

View 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")
}
}

View 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 {}

View 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();
});
});

View File

@@ -0,0 +1,4 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class NotificationService {}

View File

@@ -0,0 +1 @@
export * from "./type"

View File

@@ -0,0 +1,4 @@
export enum USER_ORG_ACCEPT_REJECT_ACTION {
ACCEPT = 'ACCEPT',
REJECT = 'REJECT'
}

View 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"

View 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;
}

View 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;
}

View 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
}

View File

@@ -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