diff --git a/common/emails/auth.ts b/common/emails/auth.ts
new file mode 100644
index 0000000..b5bf18a
--- /dev/null
+++ b/common/emails/auth.ts
@@ -0,0 +1,45 @@
+export const welcomeToApp =
+ `
+
+
+
+
+
+ Welcome to Research Shock
+
+
+
+
+
MultiTenant SaaS
+
Welcome to the team!
+
Hi there,
+
Thanks for signing up for MultiTenant Saas. We're excited to help you around.
+
To get started, we recommend setting up your organization profile and inviting your first team member.
+
+
+
+
If you have any questions, just reply to this email. We're here to help!
+
+
+
+
Cheers,
Team
+
+
+
+
+
+`
diff --git a/common/emails/index.ts b/common/emails/index.ts
new file mode 100644
index 0000000..80560e5
--- /dev/null
+++ b/common/emails/index.ts
@@ -0,0 +1,9 @@
+import { welcomeToApp } from "./auth"
+
+const EmailTemplates = {
+ welcomeToApp,
+}
+
+export default EmailTemplates
+
+
diff --git a/core/mail/mail.module.ts b/core/mail/mail.module.ts
new file mode 100644
index 0000000..286f471
--- /dev/null
+++ b/core/mail/mail.module.ts
@@ -0,0 +1,8 @@
+import { Module } from "@nestjs/common";
+import { MailService } from "./mail.service";
+
+@Module({
+ providers: [MailService],
+ exports: [MailService]
+})
+export class MailModule { }
diff --git a/core/mail/mail.service.ts b/core/mail/mail.service.ts
new file mode 100644
index 0000000..d823f3d
--- /dev/null
+++ b/core/mail/mail.service.ts
@@ -0,0 +1,45 @@
+import { Injectable } from "@nestjs/common";
+import { ConfigService } from "@nestjs/config";
+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("MAIL_ID");
+ const mailPass = this.configService.get("MAIL_PASS");
+
+ if (!mailId || !mailPass)
+ throw new Error("Make sure MAIL_ID and MAIL_PASS environment variables are set")
+
+ // Use secure in prod
+ // TODO: A table for failed emails to retry later(actually bullmq)
+ 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;
+ }
+
+ 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
+ }
+ )
+ }
+}
diff --git a/package.json b/package.json
index cc5c20c..0ad92f9 100644
--- a/package.json
+++ b/package.json
@@ -37,6 +37,7 @@
"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",
"rxjs": "^7.8.1"
@@ -54,6 +55,7 @@
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/node": "^22.10.7",
+ "@types/nodemailer": "^7.0.11",
"@types/pg": "^8.16.0",
"@types/supertest": "^6.0.2",
"eslint": "^9.18.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b6d4c89..61f295d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -47,6 +47,9 @@ importers:
class-validator:
specifier: ^0.14.3
version: 0.14.3
+ nodemailer:
+ specifier: ^8.0.1
+ version: 8.0.1
pg:
specifier: ^8.18.0
version: 8.18.0
@@ -93,6 +96,9 @@ importers:
'@types/node':
specifier: ^22.10.7
version: 22.19.10
+ '@types/nodemailer':
+ specifier: ^7.0.11
+ version: 7.0.11
'@types/pg':
specifier: ^8.16.0
version: 8.16.0
@@ -751,42 +757,49 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@napi-rs/nice-linux-arm64-musl@1.1.1':
resolution: {integrity: sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@napi-rs/nice-linux-ppc64-gnu@1.1.1':
resolution: {integrity: sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==}
engines: {node: '>= 10'}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@napi-rs/nice-linux-riscv64-gnu@1.1.1':
resolution: {integrity: sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@napi-rs/nice-linux-s390x-gnu@1.1.1':
resolution: {integrity: sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==}
engines: {node: '>= 10'}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@napi-rs/nice-linux-x64-gnu@1.1.1':
resolution: {integrity: sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@napi-rs/nice-linux-x64-musl@1.1.1':
resolution: {integrity: sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@napi-rs/nice-openharmony-arm64@1.1.1':
resolution: {integrity: sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==}
@@ -1082,24 +1095,28 @@ packages:
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@swc/core-linux-arm64-musl@1.15.11':
resolution: {integrity: sha512-PYftgsTaGnfDK4m6/dty9ryK1FbLk+LosDJ/RJR2nkXGc8rd+WenXIlvHjWULiBVnS1RsjHHOXmTS4nDhe0v0w==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@swc/core-linux-x64-gnu@1.15.11':
resolution: {integrity: sha512-DKtnJKIHiZdARyTKiX7zdRjiDS1KihkQWatQiCHMv+zc2sfwb4Glrodx2VLOX4rsa92NLR0Sw8WLcPEMFY1szQ==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@swc/core-linux-x64-musl@1.15.11':
resolution: {integrity: sha512-mUjjntHj4+8WBaiDe5UwRNHuEzLjIWBTSGTw0JT9+C9/Yyuh4KQqlcEQ3ro6GkHmBGXBFpGIj/o5VMyRWfVfWw==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@swc/core-win32-arm64-msvc@1.15.11':
resolution: {integrity: sha512-ZkNNG5zL49YpaFzfl6fskNOSxtcZ5uOYmWBkY4wVAvgbSAQzLRVBp+xArGWh2oXlY/WgL99zQSGTv7RI5E6nzA==}
@@ -1236,6 +1253,9 @@ packages:
'@types/node@22.19.10':
resolution: {integrity: sha512-tF5VOugLS/EuDlTBijk0MqABfP8UxgYazTLo3uIn3b4yJgg26QRbVYJYsDtHrjdDUIRfP70+VfhTTc+CE1yskw==}
+ '@types/nodemailer@7.0.11':
+ resolution: {integrity: sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==}
+
'@types/pg@8.16.0':
resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==}
@@ -2984,6 +3004,10 @@ packages:
node-releases@2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
+ nodemailer@8.0.1:
+ resolution: {integrity: sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==}
+ engines: {node: '>=6.0.0'}
+
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
@@ -5141,6 +5165,10 @@ snapshots:
dependencies:
undici-types: 6.21.0
+ '@types/nodemailer@7.0.11':
+ dependencies:
+ '@types/node': 22.19.10
+
'@types/pg@8.16.0':
dependencies:
'@types/node': 22.19.10
@@ -7179,6 +7207,8 @@ snapshots:
node-releases@2.0.27: {}
+ nodemailer@8.0.1: {}
+
normalize-path@3.0.0: {}
normalize-url@8.1.1: {}
diff --git a/roadmap.md b/roadmap.md
new file mode 100644
index 0000000..4782c6e
--- /dev/null
+++ b/roadmap.md
@@ -0,0 +1,104 @@
+```
+
+``` 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.
+```
+```
+
+
+# 🏗️ SaaS Architect’s Roadmap: NestJS & DevOps
+
+This document serves as a strategic guide for evolving from a developer to an architect. It balances the "What" (the technology) with the "How" (the engineering mindset).
+
+---
+
+## 🛠️ Phase 1: The "What" — Advanced Backend Features
+*Focus: Scaling complexity and ensuring production-grade reliability.*
+
+### 1. Advanced Multi-Tenancy Architecture
+* **The Goal:** Move beyond simple `organization_id` filters to true data isolation.
+* **Concepts:** Shared Database/Separate Schema or **Row Level Security (RLS)**.
+* **Challenge:** Implement a NestJS `Interceptor` or `Provider` that extracts a `tenant_id` from the request and automatically switches the database context/schema.
+
+
+### 2. Background Tasks & Reliability
+* **The Goal:** Decouple time-consuming tasks from the request/response cycle.
+* **Tech Stack:** **BullMQ** + **Redis**.
+* **Implementation:** Offload email invitations, image processing, and report generation to a separate "Worker" instance.
+
+
+### 3. Real-Time Interactions
+* **The Goal:** Create a reactive, living dashboard.
+* **Tech Stack:** **Socket.io** (WebSockets).
+* **Implementation:** Sync team membership changes or notification counts in real-time without requiring a page refresh.
+
+### 4. Billing & Webhooks (The SaaS "S")
+* **The Goal:** Turn a project into a business.
+* **Tech Stack:** **Stripe API**.
+* **Learning Point:** Handle **Idempotency**. Ensure that if Stripe sends a "Payment Success" webhook twice, you only upgrade the user's account once.
+
+---
+
+## 🧠 Phase 2: The "How" — Architectural Thinking
+*Focus: Designing systems that don't break under edge cases.*
+
+### 1. State Machine Logic
+Stop thinking in "CRUD" (Create, Read, Update, Delete) and start thinking in **Transitions**.
+* **Entity Lifecycle:** An Invitation isn't just a row; it's a flow: `Pending` → `Accepted` | `Declined` | `Expired`.
+* **Action:** Explicitly define what happens during every status change (e.g., "On Accept: Create User, Join Team, Clear Invite Cache").
+
+
+### 2. Atomic Transactions
+Never perform "partial" updates.
+* **Strategy:** Use Database Transactions (`BEGIN...COMMIT`) for any operation touching multiple tables. If the user joins the team but the invitation update fails, the whole thing must roll back.
+
+### 3. Event-Driven Side Effects
+Don't bloat your services with 10 different tasks.
+* **Pattern:** Use an **Event Emitter**.
+* **Logic:** `UserService.create()` emits a `USER_CREATED` event. Separate listeners then handle the email, the analytics ping, and the Slack notification independently.
+
+---
+
+## ☁️ Phase 3: The DevOps Pillar
+*Focus: Automation, Observability, and "Infrastructure as Code".*
+
+### 1. Observability (Stop Guessing)
+* **Structured Logging:** Use **Pino** to output JSON logs that include `trace_id` and `tenant_id`.
+* **Tracing:** Implement **OpenTelemetry** to visualize the lifecycle of a request across different services.
+
+
+### 2. Infrastructure as Code (IaC)
+* **The Goal:** Destroy and rebuild your entire cloud environment in minutes.
+* **Tech Stack:** **Terraform**.
+* **Action:** Codify your GCP/Azure VMs, VPC Firewalls, and Managed Databases so you never have to click buttons in a portal manually.
+
+### 3. Hardened CI/CD
+* **The Goal:** Catch errors before they hit production.
+* **Action:** Add "Structure Verification" steps in your GitHub Actions to ensure the build folder matches what the server expects (e.g., checking for Next.js standalone chunks).
+
+---
+
+## 🤖 The "Anti-GIGO" AI Workflow
+*How to use AI as an architect, not a typist.*
+
+1. **Sketch First:** Define your DB schema and logic flow on a whiteboard before opening the chat.
+2. **Hunt Edge Cases:** Ask yourself: "What if the user is already in another org? What if the database is locked?"
+3. **Prompt as an Architect:**
+ * *Bad:* "Make an invite system."
+ * *Good:* "Design a NestJS service for an Invitation State Machine. It must use a database transaction, handle BullMQ for email side-effects, and include a unique constraint on (email, organization_id)."
+4. **Audit the Output:** Check for N+1 query problems, lack of input validation, and missing error handling.
+
+---
+
+**Next Steps for You:**
+* Start with the **Invitation State Machine**.
+* Implement **BullMQ** for the invitation email.
+* Write your first **Terraform** script to manage your GCP firewall.
diff --git a/src/app.module.ts b/src/app.module.ts
index 92b6c47..dd7812c 100644
--- a/src/app.module.ts
+++ b/src/app.module.ts
@@ -14,6 +14,7 @@ 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 { MailModule } from 'core/mail/mail.module';
@Module({
imports: [
@@ -28,6 +29,7 @@ import { CacheModule } from './cache/cache.module';
OrganizationMembershipModule,
AuthorizationModule,
CacheModule,
+ MailModule
],
controllers: [AppController],
providers: [
diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts
index f303cdf..49b8483 100644
--- a/src/auth/auth.module.ts
+++ b/src/auth/auth.module.ts
@@ -6,6 +6,7 @@ import { AuthGuard } from './guards/auth.guard';
import { UserModule } from 'src/user/user.module';
import { JwtModule } from '@nestjs/jwt';
import { RequestContextModule } from 'core/als/request-context.module';
+import { MailModule } from 'core/mail/mail.module';
@Global()
@Module({
@@ -17,6 +18,11 @@ import { RequestContextModule } from 'core/als/request-context.module';
},
],
controllers: [AuthController],
- imports: [UserModule, JwtModule, RequestContextModule],
+ imports: [
+ UserModule,
+ JwtModule,
+ RequestContextModule,
+ MailModule
+ ],
})
-export class AuthModule {}
+export class AuthModule { }
diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts
index 88ed60a..94ce549 100644
--- a/src/auth/auth.service.ts
+++ b/src/auth/auth.service.ts
@@ -5,6 +5,8 @@ import * as bcrypt from 'bcrypt';
import { UserService } from 'src/user/user.service';
import { TokenInputType } from './types';
import { JwtService } from '@nestjs/jwt';
+import { MailService } from 'core/mail/mail.service';
+import EmailTemplates from 'common/emails';
@Injectable()
@Public()
@@ -12,7 +14,8 @@ export class AuthService {
constructor(
private readonly userService: UserService,
private readonly jwtService: JwtService,
- ) {}
+ private readonly mailService: MailService,
+ ) { }
async register(dto: RegisterUserRequestDTO) {
const hashedPassword = await bcrypt.hash(dto.password, 10);
@@ -21,6 +24,11 @@ export class AuthService {
password: hashedPassword,
});
+ this.mailService.sendMail({
+ to: dto.email,
+ subject: "Welcome onboard",
+ body: EmailTemplates.welcomeToApp
+ })
return true;
}
@@ -50,9 +58,9 @@ export class AuthService {
};
}
- logout() {}
+ logout() { }
- resetPassword() {}
+ resetPassword() { }
// TODO: Use nest jwt
private async genSignedTokens(token: TokenInputType) {
diff --git a/src/organization-membership/organization-membership.controller.ts b/src/organization-membership/organization-membership.controller.ts
index 2f307a5..949b2b9 100644
--- a/src/organization-membership/organization-membership.controller.ts
+++ b/src/organization-membership/organization-membership.controller.ts
@@ -2,7 +2,7 @@ import { Body, Controller, Delete, Get, Param, ParseEnumPipe, ParseUUIDPipe, Pat
import { OrganizationMembershipService } from './organization-membership.service';
import { RequestContextService } from 'core/als/request-context.service';
import { ApiBearerAuth, ApiOperation, ApiParam, ApiQuery } from '@nestjs/swagger';
-import { InviteUserToOrganizationRequestDTO, JoinRequestToOrganizationRequestDTO, UserOrganizationInvitationActionRequestDTO } from './dto';
+import { JoinRequestToOrganizationRequestDTO, UserOrganizationInvitationActionRequestDTO } from './dto';
import { ORGANIZATION_JOIN_REQUEST_TYPE } from 'prisma/generated/prisma/enums';
/* NOTE: Regarding endpoint path naming
diff --git a/src/organization-membership/organization-membership.service.ts b/src/organization-membership/organization-membership.service.ts
index c70bd58..d3d93d2 100644
--- a/src/organization-membership/organization-membership.service.ts
+++ b/src/organization-membership/organization-membership.service.ts
@@ -152,7 +152,7 @@ export class OrganizationMembershipService {
})
}
catch (err) {
- throw new NotFoundException("Join request")
+ throw new NotFoundException("Request")
}
}