এক্সপ্রেসে মাল্টি টেন্যান্ট এপিআই তৈরি

এর আগে আমরা প্রথমে সিম্পল CRUD এপিআই তৈরি করেছি। তারপর ঐ এপিআই গুলোকে সিকিউর করেছি। এবার আমরা মাল্টি টেন্যান্ট এপিআই তৈরি করব। মূল গোল হচ্ছে রোল বেইজড একটা মাল্টি টেন্যান্ট সিস্টেম কিভাবে ডেভেলপ করা যায়, তা শেখা। এখানেও আমরা নোট অ্যাপ তৈরি করব। তবে একটু ভিন্ন ভাবে। আগের দুইটা লেখাঃ

এই দুইটা লেখার উপর ভিত্তি করেই এবারের লেখাটা লিখছি। তাই পড়া না থাকলে দুইটাই পড়ে আসতে হবে।

আমাদের প্রজেক্টে তিন ধরণের ইউজার থাকবেঃ

RoleDescription
Adminএডমিন অনেকটা সুপার এডমিনের মত। যে সবার নোট দেখতে পারবে। নতুন টেন্যান্ট তৈরি করতে পারবে।
Tenantটেন্যান্ট মূলত একটা ওয়ার্কস্পেস বা টিমের মত। সুপার-এডমিন টেন্যান্ট তৈরি করার পর এই টেন্যান্ট-এডমিন ঐ টিমের আন্ডারে ইউজার তৈরি করতে পারবে। ঐ টিমের যে কোন ইউজারের নোট দেখতে পারবে।
Userটেন্যান্ট এডমিন ইউজার তৈরি করতে পারবে। এরপর ঐ ইউজার শুধু মাত্র নিজের নোট তৈরি, আপডেট, ডিলেট করতে পারবে।

এর জন্য আমাদের ডেটাবেজ স্ট্র্যাকচার পরিবর্তন করতে হবে। db.js:

import sqlite3 from "sqlite3";
import { open } from "sqlite";

const dbPromise = open({
  filename: "./notes.db",
  driver: sqlite3.Database
});

(async () => {
  const db = await dbPromise;

  await db.run(`
    CREATE TABLE IF NOT EXISTS tenants (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      name TEXT UNIQUE NOT NULL,
      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
    )
  `);

  await db.run(`
    CREATE TABLE IF NOT EXISTS users (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      username TEXT UNIQUE NOT NULL,
      password TEXT NOT NULL,
      role TEXT CHECK(role IN ('admin', 'tenant', 'user')) DEFAULT 'user',
      tenant_id INTEGER,
      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
      FOREIGN KEY (tenant_id) REFERENCES tenants(id)
    )
  `);

  await db.run(`
    CREATE TABLE IF NOT EXISTS notes (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      user_id INTEGER NOT NULL,
      tenant_id INTEGER NOT NULL,
      title TEXT NOT NULL,
      content TEXT NOT NULL,
      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
      FOREIGN KEY (user_id) REFERENCES users(id),
      FOREIGN KEY (tenant_id) REFERENCES tenants(id)
    )
  `);
})();

export default dbPromise;

এটা হচ্ছে আমাদের ডেটাবেজ স্কিমা ডায়াগ্রাম / Relationship Diagram (ERD):

DBeaver অ্যাপে ডেটাবেজ লোড করার পর রাইট ক্লিক করে View Diagram এ ক্লিক করলে এই স্কিমা দেখা যাবে। আরো অনেক অপশন রয়েছে। ERD app লিখে সার্চ করলেই পাওয়া যাবে।

আমরা আগের ডেটাবেজ ডিলেট করে দিব। তা না হলে এরর দেখানে। কারণ আমরা মাইগ্রেশন নিয়ে এখন কাজ করছি না। যখন মাইগ্রেশন শিখব, তখন আমরা জানতে পারব কিভাবে ডেটাবেজ ডিলিট না করেও নতুন টেবিল ও নতুন ফিল্ড যোগ করা যায়।

যেহেতু এখন আমাদের প্রজেক্টে একাধিক রোল রয়েছে, রোল অনুযায়ী অথেনটিকেশন করতে হবে, তাই অথেনটিকেশন রাউট আপডেট করতে হবে। routes/auth.js:

import express from "express";
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import dbPromise from "../db.js";
import { authenticate, authorizeRoles } from "../middleware/auth.js";

const router = express.Router();
const JWT_SECRET = "s9d8f7s9df7s9dadsfdf7s9df7s9df"; // use env in prod

// 🧱 Register Tenant (only admin can do this)
router.post("/register-tenant", authenticate, authorizeRoles("admin"), async (req, res) => {
  const { tenantName, username, password } = req.body;
  const db = await dbPromise;

  const hashedPassword = await bcrypt.hash(password, 10);

  try {
    const tenantResult = await db.run("INSERT INTO tenants (name) VALUES (?)", [tenantName]);
    const tenantId = tenantResult.lastID;

    await db.run(
      "INSERT INTO users (username, password, role, tenant_id) VALUES (?, ?, 'tenant', ?)",
      [username, hashedPassword, tenantId]
    );

    res.json({ message: "Tenant created", tenantId });
  } catch (err) {
    res.status(400).json({ error: "Tenant or username already exists" });
  }
});

// 👤 Register Regular User (only tenant admin can do this)
router.post("/register", authenticate, authorizeRoles("tenant"), async (req, res) => {
  const { username, password } = req.body;
  const db = await dbPromise;

  const hashedPassword = await bcrypt.hash(password, 10);

  try {
    const result = await db.run(
      "INSERT INTO users (username, password, role, tenant_id) VALUES (?, ?, 'user', ?)",
      [username, hashedPassword, req.user.tenant_id]
    );
    res.json({ message: "User created", id: result.lastID });
  } catch (err) {
    res.status(400).json({ error: "Username already exists" });
  }
});


// 🔑 Login (works for all roles)
router.post("/login", async (req, res) => {
  const { username, password } = req.body;
  const db = await dbPromise;
  const user = await db.get("SELECT * FROM users WHERE username = ?", [username]);
  if (!user) return res.status(400).json({ error: "Invalid credentials" });

  const valid = await bcrypt.compare(password, user.password);
  if (!valid) return res.status(400).json({ error: "Invalid credentials" });

  const token = jwt.sign(
    { id: user.id, role: user.role, tenant_id: user.tenant_id },
    JWT_SECRET,
    { expiresIn: "1h" }
  );

  res.json({ token, role: user.role });
});

export default router;

এরপর আমাদের middleware/auth.js ও আপডেট করতে হবেঃ

import jwt from "jsonwebtoken";

const JWT_SECRET = "s9d8f7s9df7s9dadsfdf7s9df7s9df"; // ⚠️ use process.env.JWT_SECRET in production

// ✅ Verify JWT and attach user info to request
export const authenticate = (req, res, next) => {
  const authHeader = req.headers.authorization;

  if (!authHeader || !authHeader.startsWith("Bearer ")) {
    return res.status(401).json({ error: "No or invalid token provided" });
  }

  const token = authHeader.split(" ")[1];

  try {
    const decoded = jwt.verify(token, JWT_SECRET);
    req.user = decoded; // contains { id, role, tenant_id }
    next();
  } catch (err) {
    res.status(401).json({ error: "Invalid or expired token" });
  }
};

// ✅ Restrict access by role(s)
export const authorizeRoles = (...allowedRoles) => {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({ error: "User not authenticated" });
    }

    if (!allowedRoles.includes(req.user.role)) {
      return res.status(403).json({ error: "Forbidden: insufficient permissions" });
    }

    next();
  };
};

নোট রাউটও আপডেট করতে হবে। যেখানে টেন্যান্ট তার অধিনস্ত সবার নোট দেখতে পাবে। একজন ইউজার শুধু মাত্র তার নোট দেখতে পাবে। একজন সুপার এডমিন সব নোট দেখতে পাবে। তার জন্য routes/notes.js এভাবে লিখতে পারিঃ

import express from "express";
import dbPromise from "../db.js";
import { authenticate } from "../middleware/auth.js";

const router = express.Router();

// ✅ All routes require authentication
router.use(authenticate);

/**
 * 📘 Create Note
 * - Regular users can create notes under their tenant
 * - Tenants and Admins can also create (optional)
 */
router.post("/", async (req, res) => {
  const { title, content } = req.body;
  const { id, tenant_id } = req.user;
  const db = await dbPromise;

  try {
    const result = await db.run(
      "INSERT INTO notes (user_id, tenant_id, title, content) VALUES (?, ?, ?, ?)",
      [id, tenant_id, title, content]
    );

    res.json({
      id: result.lastID,
      user_id: id,
      tenant_id,
      title,
      content,
    });
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: "Failed to create note" });
  }
});

/**
 * 📖 Get All Notes
 * - Admin: see all notes
 * - Tenant: see all notes under their tenant
 * - User: see only their own notes
 */
router.get("/", async (req, res) => {
  const db = await dbPromise;
  const { role, id, tenant_id } = req.user;

  let notes;

  try {
    if (role === "admin") {
      notes = await db.all("SELECT * FROM notes ORDER BY created_at DESC");
    } else if (role === "tenant") {
      notes = await db.all(
        "SELECT * FROM notes WHERE tenant_id = ? ORDER BY created_at DESC",
        [tenant_id]
      );
    } else {
      notes = await db.all(
        "SELECT * FROM notes WHERE user_id = ? ORDER BY created_at DESC",
        [id]
      );
    }

    res.json(notes);
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: "Failed to fetch notes" });
  }
});

/**
 * 🔍 Get Single Note
 * - Admin: can access any note
 * - Tenant: can access notes within their tenant
 * - User: only their own notes
 */
router.get("/:id", async (req, res) => {
  const db = await dbPromise;
  const { role, id, tenant_id } = req.user;

  try {
    let note;

    if (role === "admin") {
      note = await db.get("SELECT * FROM notes WHERE id = ?", [req.params.id]);
    } else if (role === "tenant") {
      note = await db.get(
        "SELECT * FROM notes WHERE id = ? AND tenant_id = ?",
        [req.params.id, tenant_id]
      );
    } else {
      note = await db.get(
        "SELECT * FROM notes WHERE id = ? AND user_id = ?",
        [req.params.id, id]
      );
    }

    if (!note) return res.status(404).json({ error: "Note not found" });
    res.json(note);
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: "Failed to fetch note" });
  }
});

/**
 * ✏️ Update Note
 */
router.put("/:id", async (req, res) => {
  const { title, content } = req.body;
  const db = await dbPromise;
  const { role, id, tenant_id } = req.user;

  try {
    let result;

    if (role === "admin") {
      result = await db.run(
        "UPDATE notes SET title = ?, content = ? WHERE id = ?",
        [title, content, req.params.id]
      );
    } else if (role === "tenant") {
      result = await db.run(
        "UPDATE notes SET title = ?, content = ? WHERE id = ? AND tenant_id = ?",
        [title, content, req.params.id, tenant_id]
      );
    } else {
      result = await db.run(
        "UPDATE notes SET title = ?, content = ? WHERE id = ? AND user_id = ?",
        [title, content, req.params.id, id]
      );
    }

    if (result.changes === 0) return res.status(404).json({ error: "Note not found or access denied" });
    res.json({ message: "Note updated successfully" });
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: "Failed to update note" });
  }
});

/**
 * ❌ Delete Note
 */
router.delete("/:id", async (req, res) => {
  const db = await dbPromise;
  const { role, id, tenant_id } = req.user;

  try {
    let result;

    if (role === "admin") {
      result = await db.run("DELETE FROM notes WHERE id = ?", [req.params.id]);
    } else if (role === "tenant") {
      result = await db.run(
        "DELETE FROM notes WHERE id = ? AND tenant_id = ?",
        [req.params.id, tenant_id]
      );
    } else {
      result = await db.run(
        "DELETE FROM notes WHERE id = ? AND user_id = ?",
        [req.params.id, id]
      );
    }

    if (result.changes === 0) return res.status(404).json({ error: "Note not found or access denied" });
    res.json({ message: "Note deleted successfully" });
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: "Failed to delete note" });
  }
});

export default router;

টেন্যান্ট তৈরি করার জন্য আমাদের একজন সুপারএডমিন লাগবে। তার জন্য এটা ম্যানুয়ালি ডেটাবেজে এই সুপারএডমিন যোগ করে দিব। টার্মিনালে নিচের কমান্ড রান করলে আমরা নোট ডাটাবেজে SQL কমান্ড রান করতে পারব। বাংলায় ডেটাবেজ টিউটোরিয়াল – SQL এ SQL সম্পর্কে জানা যাবে।

sqlite3 notes.db

এরপর নিচের কমান্ড রান করবঃ

INSERT INTO users (username, password, role)
VALUES ('superadmin', '$2a$12$UozpQlgo0bnkyIStzH8THegMJgKt0J70a1FtX9k0a2NMtD/5E7fLK', 'admin');

মনে আছে কিনা আমরা BCrypt ব্যবহার করছি? ডেটাবেজে যে কোন পাসওয়ার্ড BCrypt করে রাখতে হবে। উপরের $2a$12$UozpQlgo0bnkyIStzH8THegMJgKt0J70a1FtX9k0a2NMtD/5E7fLK এই হ্যাস ভ্যালু হচচ্ছে ‘password’ এর এনক্রিপশন ভ্যালু। অনলাইনে এখান থেকে একটা টেক্সটের bcrypt ভ্যালু জেনারেট করে নেওয়া যাবে। এখন যদি ডেটাবেজ চেক করি, তাহলে দেখব সুপার এডমিন যোগ হয়েছে।

এবার আমরা লগিন করতে পারবঃ

এই টোকেন ব্যবহার করে আমরা নতুন টেন্যান্ট যোগ করতে পারব। টেন্যান্ট রেজিস্ট্রেশনের রাউট হচ্ছে http://localhost:3000/auth/register-tenant। auth.js একটু ভালো করে লক্ষ্য করলে কোন রাউটের কি কাজ, কি কি প্যারামিটার লাগবে রিকোয়েস্ট করতে, এগুলো জানা যাবে।

{
  "tenantName": "TenantOne",
  "username": "tenantadmin1",
  "password": "tenant123"
}

টেন্যান্ট রেজিস্ট্রেশন করার ক্ষেত্রে সুপারএডমিন টোকেন বিয়ারার টোকেন হিসেবে পাস করতে হবে। আর এটা হবে POST মেথড।

টেন্যান্ট রেজিস্ট্রেশন শেষে আমরা এই টেন্যান্টের ইউজারনেম এবং পাসওয়ার্ড দিয়ে লগিন করতে পারব। সব ধরণের ইউজারের লগিন রাউট একই। /auth/login

{
  "username": "tenantadmin1",
  "password": "tenant123"
}

লগিন করার পর যে টোকেন পাবো, ঐ টোকেন ব্যবহার করে নির্দিষ্ট টেন্যান্টের আন্ডারে ইউজার রেজিস্ট্রেশন করতে পারব। রেজিস্ট্রেশন রাউট হচ্ছে /auth/register।

{
  "username": "jack",
  "password": "123123",
  "tenantName" : "tenant1"
}

ইউজার রেজিস্ট্রেশনের ক্ষেত্রে টেন্যান্টের নাম, প্যারামিটার হিসেবে পাস করতে হবে। এবং বিয়ায়ারার টোকেন হিসেবে নির্দিষ্ট টেন্যান্টের টোকেন দিতে হবে।

এবার ঐ ইউজার লগিন করে নোট তৈরি করতে পারবে। টেন্যান্ট যদি লগিন করে, তাহলে নিজের নোট তৈরি করে দেখার পাশাপাশি তার আন্ডারের সব গুলো ইউজারের নোট দেখতে পাবে।

সুপারএডমিন লগিন করার পর সবার নোট দেখতে পাবে। নোটের রাউট গুলো একই রকম রয়েছে। শুধু মাত্র নির্দিষ্ট ইউজার লগিন করে টোকেন ব্যবহার করলেই হবে।

Leave a Comment