├── .dockerignore
├── packages
├── prismaclient
│ ├── src
│ │ └── index.ts
│ ├── package.json
│ ├── schema.prisma
│ └── tsconfig.json
└── session-opts
│ ├── package.json
│ ├── src
│ └── index.ts
│ └── tsconfig.json
├── apps
├── frontend
│ ├── src
│ │ ├── lib
│ │ │ ├── types.ts
│ │ │ ├── components
│ │ │ │ ├── patients-table
│ │ │ │ │ ├── header-cell.tsx
│ │ │ │ │ ├── columns.tsx
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── layout.tsx
│ │ │ │ └── login-form.tsx
│ │ │ ├── zustand
│ │ │ │ └── app-store.ts
│ │ │ ├── hooks
│ │ │ │ └── use-data.ts
│ │ │ └── ofetch-instance.ts
│ │ ├── pages
│ │ │ ├── login.tsx
│ │ │ ├── _document.tsx
│ │ │ ├── index.tsx
│ │ │ └── _app.tsx
│ │ ├── middleware.ts
│ │ └── styles
│ │ │ ├── globals.css
│ │ │ └── Home.module.css
│ ├── public
│ │ ├── favicon.ico
│ │ ├── vercel.svg
│ │ └── next.svg
│ ├── next.config.js
│ ├── .env
│ ├── next-env.d.ts
│ ├── playwright.config.ts
│ ├── tsconfig.json
│ ├── tests
│ │ ├── tenancy.spec.ts
│ │ └── lib
│ │ │ ├── get-user.ts
│ │ │ ├── fixtures.ts
│ │ │ └── data.ts
│ ├── package.json
│ └── README.md
└── backend
│ ├── src
│ ├── prisma-tenancy
│ │ ├── client-extensions
│ │ │ ├── index.ts
│ │ │ ├── bypass.ts
│ │ │ └── tenant.ts
│ │ ├── prisma-tenancy.module.ts
│ │ └── prisma-tenancy.service.ts
│ ├── auth
│ │ ├── index.ts
│ │ ├── public.decorator.ts
│ │ ├── auth.middleware.ts
│ │ ├── auth.module.ts
│ │ ├── auth.controller.ts
│ │ ├── auth.guard.ts
│ │ └── auth.service.ts
│ ├── models
│ │ ├── users
│ │ │ ├── users.module.ts
│ │ │ ├── users.controller.ts
│ │ │ └── users.service.ts
│ │ ├── tenants
│ │ │ ├── tenants.module.ts
│ │ │ ├── tenants.controller.ts
│ │ │ └── tenants.service.ts
│ │ └── patients
│ │ │ ├── patients.module.ts
│ │ │ ├── patients.controller.ts
│ │ │ └── patients.service.ts
│ ├── app.controller.ts
│ ├── main.ts
│ └── app.module.ts
│ ├── tsconfig.build.json
│ ├── .env
│ ├── nest-cli.json
│ ├── tsconfig.json
│ ├── package.json
│ └── README.md
├── dockerfiles
├── Dockerfile.node
└── Dockerfile.dev
├── reset-db.sh
├── turbo.json
├── nginx
├── proxy.conf
├── nginx.conf
└── conf.d
│ └── default.conf
├── test.sh
├── .gitignore
├── AUTH.md
├── setup.sh
├── package.json
├── NGINX.md
├── docker-compose.yml
├── db
├── 1_schema.sql
└── 2_data.sql
├── POSTGRES.md
└── README.md
/.dockerignore:
--------------------------------------------------------------------------------
1 | **/node_modules
2 | **/.next
3 | **/dist
--------------------------------------------------------------------------------
/packages/prismaclient/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from '@prisma/client';
--------------------------------------------------------------------------------
/apps/frontend/src/lib/types.ts:
--------------------------------------------------------------------------------
1 | export interface StoreUser {
2 | userName: string;
3 | tenantName: string;
4 | }
--------------------------------------------------------------------------------
/apps/backend/src/prisma-tenancy/client-extensions/index.ts:
--------------------------------------------------------------------------------
1 | export * from './bypass';
2 | export * from './tenant';
3 |
4 |
--------------------------------------------------------------------------------
/apps/frontend/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/moofoo/nestjs-prisma-postgres-tenancy/HEAD/apps/frontend/public/favicon.ico
--------------------------------------------------------------------------------
/apps/backend/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
4 | }
5 |
--------------------------------------------------------------------------------
/apps/backend/src/auth/index.ts:
--------------------------------------------------------------------------------
1 | export * from './auth.guard';
2 | export * from './auth.middleware';
3 | export * from './public.decorator';
4 | export * from './auth.module';
--------------------------------------------------------------------------------
/apps/frontend/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | reactStrictMode: true,
4 | }
5 |
6 | module.exports = nextConfig
7 |
--------------------------------------------------------------------------------
/apps/frontend/src/pages/login.tsx:
--------------------------------------------------------------------------------
1 | import { LoginForm } from "@/lib/components/login-form";
2 |
3 | export default function LoginPage() {
4 | return ;
5 | }
--------------------------------------------------------------------------------
/apps/backend/src/auth/public.decorator.ts:
--------------------------------------------------------------------------------
1 | import { SetMetadata } from '@nestjs/common';
2 |
3 | export const IS_PUBLIC_KEY = 'isPublic';
4 | export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
5 |
--------------------------------------------------------------------------------
/apps/frontend/.env:
--------------------------------------------------------------------------------
1 | COOKIE_SECRET=bffe28bdfda47e29dc45d718db0a3901d6a7a2c48dbc8bc2d3d9c517ed696940
2 | COOKIE_NAME=tenancy_app
3 | DATABASE_URL=postgresql://tenant:c7b38884e5c959ac151e4f24320c7a34@db:5432/app_db?schema=public
--------------------------------------------------------------------------------
/apps/backend/.env:
--------------------------------------------------------------------------------
1 | COOKIE_SECRET=bffe28bdfda47e29dc45d718db0a3901d6a7a2c48dbc8bc2d3d9c517ed696940
2 | COOKIE_NAME=tenancy_app
3 | DATABASE_URL=postgresql://tenant:c7b38884e5c959ac151e4f24320c7a34@db:5432/app_db?schema=public
4 |
--------------------------------------------------------------------------------
/apps/backend/nest-cli.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/nest-cli",
3 | "collection": "@nestjs/schematics",
4 | "sourceRoot": "src",
5 | "compilerOptions": {
6 | "deleteOutDir": true
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/apps/frontend/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/basic-features/typescript for more information.
6 |
--------------------------------------------------------------------------------
/dockerfiles/Dockerfile.node:
--------------------------------------------------------------------------------
1 | # syntax=docker/dockerfile:1.5.2
2 |
3 | FROM node:20.2.0-alpine3.17
4 |
5 | RUN apk add --no-cache --virtual .gyp nano bash libc6-compat python3 make g++ \
6 | && yarn global add turbo \
7 | && apk del .gyp
--------------------------------------------------------------------------------
/reset-db.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | docker compose kill db
4 | docker compose rm -f db
5 | docker volume rm tenancy_example_db_data
6 | docker volume create tenancy_example_db_data
7 | docker compose up -d db
8 | sleep 5
9 | docker compose restart
--------------------------------------------------------------------------------
/apps/frontend/src/lib/components/patients-table/header-cell.tsx:
--------------------------------------------------------------------------------
1 | export function HeaderCell(props: { header: string, color?: string; }) {
2 | let { header, color } = props;
3 | color = color || 'black';
4 | return {header};
5 | };
--------------------------------------------------------------------------------
/apps/backend/src/models/users/users.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { UsersService } from './users.service';
3 | import { UsersController } from './users.controller';
4 | @Module({
5 | providers: [UsersService],
6 | exports: [UsersService],
7 | controllers: [UsersController]
8 | })
9 | export class UsersModule { }
10 |
--------------------------------------------------------------------------------
/apps/frontend/playwright.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from '@playwright/test';
2 |
3 | export default defineConfig({
4 | testDir: './tests',
5 | fullyParallel: true,
6 | workers: 50,
7 | repeatEach: 50,
8 | reporter: 'html',
9 | use: {
10 | trace: 'on-first-retry',
11 | bypassCSP: true
12 | }
13 | });
--------------------------------------------------------------------------------
/apps/backend/src/models/tenants/tenants.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { TenantsService } from './tenants.service';
3 | import { TenantsController } from './tenants.controller';
4 |
5 | @Module({
6 | providers: [TenantsService],
7 | exports: [TenantsService],
8 | controllers: [TenantsController]
9 | })
10 | export class TenantsModule { }
11 |
--------------------------------------------------------------------------------
/apps/backend/src/models/patients/patients.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { PatientsService } from './patients.service';
3 | import { PatientsController } from './patients.controller';
4 |
5 | @Module({
6 | controllers: [PatientsController],
7 | providers: [PatientsService],
8 | exports: [PatientsService]
9 | })
10 | export class PatientsModule { }
11 |
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turbo.build/schema.json",
3 | "pipeline": {
4 | "build": {
5 | "dependsOn": [
6 | "^build"
7 | ],
8 | "outputs": [
9 | "dist/**",
10 | ".next/**",
11 | "!.next/cache/**"
12 | ]
13 | },
14 | "lint": {},
15 | "dev": {
16 | "cache": false,
17 | "persistent": true
18 | }
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/apps/backend/src/auth/auth.middleware.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response, NextFunction } from 'express';
2 | import { getIronSession } from 'iron-session';
3 | import { SessionData, getSessionOpts } from 'session-opts';
4 |
5 | export async function AuthMiddleware(req: Request, res: Response, next: NextFunction) {
6 | const session: SessionData = await getIronSession(req, res, getSessionOpts());
7 | (req as any).session = session;
8 | next();
9 | };
10 |
--------------------------------------------------------------------------------
/apps/backend/src/auth/auth.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from "@nestjs/common";
2 | import { AuthController } from './auth.controller';
3 | import { UsersModule } from "@/models/users/users.module";
4 | import { TenantsModule } from "@/models/tenants/tenants.module";
5 | import { AuthService } from "./auth.service";
6 |
7 | @Module({
8 | imports: [UsersModule, TenantsModule],
9 | providers: [AuthService],
10 | controllers: [AuthController]
11 | })
12 | export class AuthModule { }
--------------------------------------------------------------------------------
/nginx/proxy.conf:
--------------------------------------------------------------------------------
1 | # proxy config used in default.conf location blocks
2 |
3 | proxy_redirect off;
4 | proxy_set_header Forwarded $proxy_add_forwarded;
5 | proxy_cache_bypass $http_upgrade;
6 |
7 | proxy_http_version 1.1;
8 | proxy_set_header Upgrade $http_upgrade;
9 | proxy_set_header Connection $connection_upgrade;
10 |
11 | proxy_set_header Host $host;
12 | proxy_set_header X-Real-IP $remote_addr;
13 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
14 | proxy_set_header X-Forwarded-Proto $scheme;
--------------------------------------------------------------------------------
/packages/session-opts/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "session-opts",
3 | "version": "0.0.0",
4 | "private": true,
5 | "main": "dist/index.js",
6 | "files": [
7 | "src/index.ts"
8 | ],
9 | "scripts": {
10 | "build": "npx -y rimraf dist/* && npx tsc"
11 | },
12 | "dependencies": {
13 | "iron-session": "^6.3.1"
14 | },
15 | "devDependencies": {
16 | "@types/node": "^20.2.3",
17 | "rimraf": "^5.0.1",
18 | "typescript": "^5.0.4"
19 | }
20 | }
--------------------------------------------------------------------------------
/test.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Description
4 | # bind mounts project root to /app in container
5 | # sets working directory to /app
6 | # sets network to 'tenancy_example_network'
7 | # sets IPC to 'host'
8 | # '--rm' flag means container is removed after it finishes command
9 | # runs 'yarn workspace frontend test' in container
10 |
11 | docker run -v .:/app -w /app --network tenancy_example_network --ipc=host --rm mcr.microsoft.com/playwright:v1.34.1-jammy /bin/bash -c "yarn workspace frontend test"
--------------------------------------------------------------------------------
/apps/backend/src/app.controller.ts:
--------------------------------------------------------------------------------
1 | import { Controller, Get } from '@nestjs/common';
2 | import { PrismaTenancyService } from './prisma-tenancy/prisma-tenancy.service';
3 |
4 | @Controller()
5 | export class AppController {
6 | constructor(private readonly prisma: PrismaTenancyService) { }
7 |
8 | @Get()
9 | getHello(): string {
10 | return 'Hello World!';
11 | }
12 |
13 | @Get('/stats')
14 | getStats() {
15 | const metrics = this.prisma.switch(true).$metrics.json();
16 | return metrics;
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/apps/frontend/src/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import { createGetInitialProps } from '@mantine/next';
2 | import Document, { Head, Html, Main, NextScript } from 'next/document';
3 |
4 | const getInitialProps = createGetInitialProps();
5 |
6 | export default class _Document extends Document {
7 | static getInitialProps = getInitialProps;
8 |
9 | render() {
10 | return (
11 |
12 |
14 |
15 |
16 |
17 |
18 | );
19 | }
20 | }
--------------------------------------------------------------------------------
/packages/session-opts/src/index.ts:
--------------------------------------------------------------------------------
1 | import { IronSession, IronSessionOptions } from "iron-session";
2 |
3 | export type SessionData = IronSession & { userId?: number, userName?: string, tenantId?: number, tenantName?: string, isAdmin?: boolean, authenticated?: boolean; };
4 |
5 | export const getSessionOpts = (): IronSessionOptions => ({
6 | password: process.env.COOKIE_SECRET as string,
7 | cookieName: process.env.COOKIE_NAME as string,
8 | cookieOptions: {
9 | httpOnly: true,
10 | secure: false,
11 | sameSite: 'lax',
12 | path: '/'
13 | },
14 | });
--------------------------------------------------------------------------------
/apps/backend/src/models/users/users.controller.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Controller,
3 | Get,
4 | Param,
5 | ParseIntPipe
6 | } from '@nestjs/common';
7 |
8 | import { UsersService } from './users.service';
9 | @Controller('users')
10 | export class UsersController {
11 | constructor(private readonly users: UsersService) { }
12 |
13 | @Get()
14 | findMany() {
15 | return this.users.findMany({});
16 | }
17 |
18 | @Get(':id')
19 | findUnique(@Param('id', ParseIntPipe) id: number) {
20 | return this.users.findUnique({ where: { id } });
21 | }
22 | }
23 |
24 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | node_modules
5 | .pnp
6 | .pnp.js
7 |
8 | # testing
9 | coverage
10 | test-results
11 | playwright-report
12 |
13 | # next.js
14 | .next/
15 | out/
16 | build
17 |
18 | # other
19 | dist/
20 |
21 | # misc
22 | .DS_Store
23 | *.pem
24 |
25 | # debug
26 | npm-debug.log*
27 | yarn-debug.log*
28 | yarn-error.log*
29 | .pnpm-debug.log*
30 |
31 | # local env files
32 | .env.local
33 | .env.development.local
34 | .env.test.local
35 | .env.production.local
36 |
37 | # turbo
38 | .turbo
39 |
40 | .vscode
--------------------------------------------------------------------------------
/apps/backend/src/models/tenants/tenants.controller.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Controller,
3 | Get,
4 | Param,
5 | ParseIntPipe
6 | } from '@nestjs/common';
7 |
8 | import { TenantsService } from './tenants.service';
9 | @Controller('tenants')
10 | export class TenantsController {
11 | constructor(private readonly tenants: TenantsService) { }
12 |
13 | @Get()
14 | findMany() {
15 | return this.tenants.findMany({});
16 | }
17 |
18 | @Get(':id')
19 | findUnique(@Param('id', ParseIntPipe) id: number) {
20 | return this.tenants.findUnique({ where: { id } });
21 | }
22 | }
23 |
24 |
--------------------------------------------------------------------------------
/apps/backend/src/prisma-tenancy/prisma-tenancy.module.ts:
--------------------------------------------------------------------------------
1 | import { Module, Global } from '@nestjs/common';
2 | import { PrismaModule, PrismaService } from 'nestjs-prisma';
3 | import { PrismaTenancyReqScopeClientProvider, PrismaBypassReqScopeClientProvider } from './client-extensions';
4 | import { PrismaTenancyService } from './prisma-tenancy.service';
5 |
6 | @Global()
7 | @Module({
8 | imports: [PrismaModule],
9 | providers: [PrismaService, PrismaTenancyService, PrismaTenancyReqScopeClientProvider, PrismaBypassReqScopeClientProvider],
10 | exports: [PrismaTenancyService]
11 | })
12 | export class PrismaTenancyModule { }
--------------------------------------------------------------------------------
/apps/frontend/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/AUTH.md:
--------------------------------------------------------------------------------
1 | # Authentication and Session Handling
2 |
3 | The frontend is a [NextJS](https://nextjs.org/) app using [Mantine](https://mantine.dev) for the UI.
4 |
5 | #
6 |
7 | The app uses [Iron Session](https://github.com/vvo/iron-session) for the encrypted session store (cookie storage).
8 |
9 | #
10 |
11 | Since both the frontend and backend use the same [config](packages/session-opts/src/index.ts), both are able to read and modify the session.
12 |
13 | #
14 |
15 | Access control on the frontend is handled by [NextJS middleware](apps/frontend/src/middleware.ts). Here is the [backend login method](apps/backend/src/auth/auth.service.ts)
16 |
--------------------------------------------------------------------------------
/setup.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | docker pull node:20.2.0-alpine3.17
4 |
5 | docker pull nginx:1.23.4-alpine3.17
6 |
7 | docker pull postgres:15.3-alpine3.17
8 |
9 | docker pull mcr.microsoft.com/playwright:v1.34.1-jammy
10 |
11 | docker volume create tenancy_example_db_data
12 |
13 | docker network create tenancy_example_network
14 |
15 | docker image build -f dockerfiles/Dockerfile.node -t custom-node:latest dockerfiles
16 |
17 | docker compose up -d db
18 |
19 | yarn
20 |
21 | yarn workspace prismaclient local
22 |
23 | yarn workspace session-opts build
24 |
25 | yarn
26 |
27 | docker compose build frontend backend
28 |
29 | docker compose stop
--------------------------------------------------------------------------------
/apps/backend/src/models/patients/patients.controller.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Controller,
3 | Get,
4 | ParseIntPipe,
5 | Param,
6 | Scope
7 | } from '@nestjs/common';
8 |
9 | import { PatientsService } from './patients.service';
10 | @Controller({ path: 'patients', scope: Scope.REQUEST })
11 | export class PatientsController {
12 | constructor(private readonly patients: PatientsService) { }
13 |
14 | @Get()
15 | findMany() {
16 | return this.patients.findMany({});
17 | }
18 |
19 | @Get(':id')
20 | findUnique(@Param('id', ParseIntPipe) id: number) {
21 | return this.patients.findUnique({ where: { id } });
22 | }
23 | }
--------------------------------------------------------------------------------
/apps/backend/src/models/tenants/tenants.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import type { Prisma } from 'prismaclient';
3 | import { PrismaTenancyService } from '@/prisma-tenancy/prisma-tenancy.service';
4 |
5 | @Injectable()
6 | export class TenantsService {
7 | constructor(private readonly prisma: PrismaTenancyService) { }
8 |
9 | findMany(input: Prisma.TenantFindManyArgs, bypass = false) {
10 | return this.prisma.switch(bypass).tenant.findMany(input);
11 | }
12 |
13 | findUnique(input: Prisma.TenantFindUniqueArgs, bypass = false) {
14 | return this.prisma.switch(bypass).tenant.findUnique(input);
15 | }
16 | }
--------------------------------------------------------------------------------
/apps/backend/src/models/patients/patients.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import type { Prisma } from 'prismaclient';
3 | import { PrismaTenancyService } from '@/prisma-tenancy/prisma-tenancy.service';
4 |
5 | @Injectable()
6 | export class PatientsService {
7 | constructor(private readonly prisma: PrismaTenancyService) { }
8 |
9 | findMany(input: Prisma.PatientFindManyArgs, bypass = false) {
10 | return this.prisma.switch(bypass).patient.findMany(input);
11 | }
12 |
13 | findUnique(input: Prisma.PatientFindUniqueArgs, bypass = false) {
14 | return this.prisma.switch(bypass).patient.findUnique(input);
15 | }
16 | }
--------------------------------------------------------------------------------
/apps/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "paths": {
18 | "@/*": ["./src/*"]
19 | }
20 | },
21 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
22 | "exclude": ["node_modules"]
23 | }
24 |
--------------------------------------------------------------------------------
/apps/frontend/src/lib/zustand/app-store.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand';
2 | import { combine, devtools, persist } from 'zustand/middleware';
3 |
4 | type UserInfo = { userName?: string, tenantName?: string; };
5 |
6 | export const useAppStore = create(
7 | devtools(
8 | persist(
9 | combine({ loading: false, user: undefined } as { loading: boolean, user?: UserInfo; }, (set) => ({
10 | setLoading: (loading: boolean) => set((state) => ({ loading })),
11 | setUser: (user: UserInfo) => set((state) => ({ user })),
12 | })), { name: 'app-store-storage', partialize: (state) => ({ user: state.user }) }))
13 | );
14 |
15 |
16 |
--------------------------------------------------------------------------------
/apps/backend/src/main.ts:
--------------------------------------------------------------------------------
1 | import { NestFactory } from '@nestjs/core';
2 | import { PrismaService } from 'nestjs-prisma';
3 | import cookieParser from 'cookie-parser';
4 | import { AppModule } from './app.module';
5 | import { AuthMiddleware } from './auth';
6 |
7 | async function bootstrap() {
8 | const app = await NestFactory.create(AppModule, { cors: true });
9 |
10 | app.setGlobalPrefix('nest');
11 |
12 | app.use(cookieParser());
13 |
14 | app.use(AuthMiddleware);
15 |
16 | const prismaService: PrismaService = app.get(PrismaService);
17 | await prismaService.enableShutdownHooks(app);
18 |
19 | await app.listen(process.env.PORT || 3333, process.env.IN_CONTAINER === '1' ? '0.0.0.0' : '127.0.0.1');
20 | }
21 | bootstrap();
22 |
--------------------------------------------------------------------------------
/nginx/nginx.conf:
--------------------------------------------------------------------------------
1 | # Standard nginx.conf
2 | user nginx;
3 | worker_processes 5;
4 | error_log /var/log/nginx/error.log;
5 | pid /var/run/nginx.pid;
6 | worker_rlimit_nofile 8192;
7 |
8 | events {
9 | worker_connections 1024;
10 | }
11 |
12 | http {
13 | include /etc/nginx/mime.types;
14 | default_type application/octet-stream;
15 |
16 | log_format main '$remote_addr - $remote_user [$time_local] $status '
17 | '"$request" $body_bytes_sent "$http_referer" '
18 | '"$http_user_agent" "$http_x_forwarded_for"';
19 |
20 | access_log /var/log/nginx/access.log main;
21 |
22 | sendfile on;
23 | tcp_nopush on;
24 |
25 | include /etc/nginx/conf.d/*.conf;
26 | }
--------------------------------------------------------------------------------
/apps/backend/src/auth/auth.controller.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Body,
3 | Controller,
4 | Post,
5 | Req
6 | } from '@nestjs/common';
7 |
8 | import { Request } from 'express';
9 |
10 | import { Public } from './public.decorator';
11 |
12 | import { AuthService } from './auth.service';
13 |
14 | @Controller('auth')
15 | export class AuthController {
16 |
17 | constructor(private readonly auth: AuthService) { }
18 |
19 | @Public()
20 | @Post('/login')
21 | login(@Req() req: Request, @Body() credentials: { userName: string, password: string; }) {
22 | return this.auth.login(credentials, req);
23 | }
24 |
25 | @Post('/logout')
26 | logout(@Req() req: Request) {
27 | return this.auth.logout(req);
28 | }
29 | }
--------------------------------------------------------------------------------
/packages/prismaclient/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "prismaclient",
3 | "version": "0.0.0",
4 | "private": true,
5 | "main": "dist/index.js",
6 | "files": [
7 | "src/index.ts"
8 | ],
9 | "scripts": {
10 | "build": "npx -y rimraf dist/* && prisma generate && npx tsc",
11 | "local": "npx -y rimraf dist/* && DATABASE_URL=postgresql://tenant:c7b38884e5c959ac151e4f24320c7a34@localhost:5432/app_db?schema=public prisma generate && npx tsc"
12 | },
13 | "dependencies": {
14 | "@prisma/client": "^4.14.1"
15 | },
16 | "devDependencies": {
17 | "@types/node": "^20.2.3",
18 | "prisma": "^4.14.1",
19 | "rimraf": "^5.0.1",
20 | "typescript": "^5.0.4"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/apps/backend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "isolatedModules": true,
4 | "esModuleInterop": true,
5 | "module": "commonjs",
6 | "declaration": true,
7 | "removeComments": true,
8 | "emitDecoratorMetadata": true,
9 | "experimentalDecorators": true,
10 | "allowSyntheticDefaultImports": true,
11 | "target": "es2017",
12 | "sourceMap": true,
13 | "outDir": "./dist",
14 | "baseUrl": "./",
15 | "incremental": true,
16 | "skipLibCheck": true,
17 | "strictNullChecks": false,
18 | "noImplicitAny": false,
19 | "strictBindCallApply": false,
20 | "forceConsistentCasingInFileNames": false,
21 | "noFallthroughCasesInSwitch": false,
22 | "paths": {
23 | "@/*": ["./src/*"]
24 | }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/apps/frontend/tests/tenancy.spec.ts:
--------------------------------------------------------------------------------
1 | import { userData, adminData } from './lib/data';
2 | import { test, expect } from './lib/fixtures';
3 |
4 |
5 | test('test 1', async ({ page, storageState }) => {
6 |
7 | const fname = storageState?.toString().replace('/app/apps/frontend/test-results/.auth/', '').replace('.json', '');
8 |
9 | const parts: any = fname?.split(".");
10 |
11 | const tenantId = parts[0];
12 | const isAdmin = parts[2] === '1';
13 |
14 | let data = [];
15 |
16 | if (isAdmin) {
17 | data = adminData;
18 | } else {
19 | data = (userData as any)[tenantId];
20 | }
21 |
22 | const response = await page.request.get('http://backend:3333/nest/patients');
23 |
24 | const json = await response.json();
25 |
26 | expect(json).toEqual(data);
27 | });
28 |
29 |
30 |
--------------------------------------------------------------------------------
/apps/backend/src/models/users/users.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import type { Prisma } from 'prismaclient';
3 | import { PrismaTenancyService } from '@/prisma-tenancy/prisma-tenancy.service';
4 |
5 | @Injectable()
6 | export class UsersService {
7 | constructor(private readonly prisma: PrismaTenancyService) { }
8 |
9 | findMany(input: Prisma.UserFindManyArgs, bypass = false) {
10 | return this.prisma.switch(bypass).user.findMany(input);
11 | }
12 |
13 | findUnique(input: Prisma.UserFindUniqueArgs, bypass = false) {
14 | return this.prisma.switch(bypass).user.findUnique(input);
15 | }
16 |
17 | findFirst(input: Prisma.UserFindFirstArgs, bypass = false) {
18 | return this.prisma.switch(bypass).user.findFirst(input);
19 | }
20 | }
--------------------------------------------------------------------------------
/apps/frontend/src/lib/components/patients-table/columns.tsx:
--------------------------------------------------------------------------------
1 | import { MRT_ColumnDef } from 'mantine-react-table';
2 | import { HeaderCell } from './header-cell';
3 | import type { Patient } from 'app-prisma';
4 |
5 | export const columns: MRT_ColumnDef>[] = [
6 | {
7 | Header: ({ column }) => ,
8 | accessorKey: 'firstName',
9 | header: 'First Name',
10 | },
11 | {
12 | Header: ({ column }) => ,
13 | accessorKey: 'lastName',
14 | header: 'Last Name',
15 | },
16 | {
17 | Header: ({ column }) => ,
18 | accessorKey: 'dob',
19 | header: 'Date of Birth',
20 | }
21 | ];
22 |
--------------------------------------------------------------------------------
/apps/backend/src/app.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { APP_GUARD } from '@nestjs/core';
3 | import { AppController } from './app.controller';
4 | import { ConfigModule } from '@nestjs/config';
5 | import { PrismaTenancyModule } from './prisma-tenancy/prisma-tenancy.module';
6 | import { UsersModule } from './models/users/users.module';
7 | import { PatientsModule } from './models/patients/patients.module';
8 | import { TenantsModule } from './models/tenants/tenants.module';
9 | import { AuthModule, AuthGuard } from '@/auth';
10 |
11 | @Module({
12 | imports: [
13 | UsersModule,
14 | PatientsModule,
15 | TenantsModule,
16 | PrismaTenancyModule,
17 | AuthModule,
18 | ConfigModule.forRoot()
19 | ],
20 | controllers: [AppController],
21 | providers: [
22 | {
23 | provide: APP_GUARD,
24 | useClass: AuthGuard,
25 | }
26 | ],
27 | })
28 | export class AppModule { }
29 |
--------------------------------------------------------------------------------
/apps/frontend/src/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import dynamic from 'next/dynamic';
3 | import { GetServerSidePropsContext } from 'next';
4 |
5 | import { getSessionOpts, SessionData } from 'session-opts';
6 | import { getIronSession } from 'iron-session';
7 |
8 | const PatientsTable = dynamic(() => import('@/lib/components/patients-table'), {
9 | loading: () => Loading...
,
10 | });
11 |
12 | export default function Home() {
13 | return ;
14 | }
15 |
16 | export async function getServerSideProps(context: GetServerSidePropsContext) {
17 | const { req, res } = context;
18 |
19 | const sessionOpts = getSessionOpts();
20 |
21 | const session: SessionData = await getIronSession(req, res, sessionOpts);
22 |
23 | const { userName, tenantName } = session;
24 |
25 |
26 | return {
27 | props: { user: { userName, tenantName } }, // will be passed to the page component as props
28 | };
29 | }
30 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nestjs-prisma-postgres-tenancy",
3 | "version": "1.0.0",
4 | "main": "index.js",
5 | "repository": "https://github.com/moofoo/nestjs-prisma-postgres-tenancy.git",
6 | "author": "moofoo ",
7 | "license": "MIT",
8 | "private": true,
9 | "workspaces": [
10 | "apps/*",
11 | "packages/*"
12 | ],
13 | "packageManager": "yarn@1.22.19",
14 | "devDependencies": {
15 | "turbo": "^1.9.8"
16 | },
17 | "scripts": {
18 | "front":"yarn workspace frontend",
19 | "back":"yarn workspace backend",
20 | "setup": "bash setup.sh",
21 | "reset-db": "bash reset-db.sh",
22 | "test":"bash test.sh",
23 | "restart":"docker compose stop && docker compose up -d",
24 | "build-front":"docker compose rm -s -f frontend && docker compose build frontend",
25 | "build-back":"docker compose rm -s -f backend && docker compose build backend"
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/apps/frontend/src/middleware.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { NextRequest } from 'next/server';
3 | import { SessionData, getSessionOpts } from 'session-opts';
4 | import { getIronSession } from 'iron-session/edge';
5 |
6 | const redirectCheck = (req: NextRequest, res: NextResponse, path: string) => {
7 | if (req.nextUrl.pathname !== path) {
8 | return NextResponse.redirect(new URL(path, req.url));
9 | }
10 | return res;
11 | };
12 |
13 | export async function middleware(req: NextRequest) {
14 | let res = NextResponse.next();
15 |
16 |
17 |
18 | if (!req.nextUrl.pathname.includes('_next')) {
19 |
20 | const sessionOpts = getSessionOpts();
21 |
22 | const session: SessionData = await getIronSession(req, res, sessionOpts);
23 |
24 | if (!session.authenticated) {
25 | res = redirectCheck(req, res, '/login');
26 | }
27 | }
28 |
29 | return res;
30 | }
--------------------------------------------------------------------------------
/apps/frontend/src/lib/hooks/use-data.ts:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { getFetchInstance } from '../ofetch-instance';
3 | import debounce from 'lodash.debounce';
4 |
5 | type MapFn = (value: A, index: number, array: A[]) => A;
6 |
7 | export function useData(path: string, mapFn?: MapFn): Partial[] {
8 |
9 | const [oFetch] = React.useState(() => {
10 | return debounce(getFetchInstance(), 250, { leading: true, trailing: false });
11 | });
12 |
13 | const [theData, setData] = React.useState([{} as T]);
14 |
15 | React.useEffect(() => {
16 | (async () => {
17 | const data = await oFetch(path);
18 |
19 | if (Array.isArray(data)) {
20 | if (mapFn) {
21 | setData(data.map(mapFn));
22 | } else {
23 | setData(data);
24 | }
25 | }
26 | })();
27 | }, []);
28 |
29 | return theData;
30 | }
31 |
32 |
--------------------------------------------------------------------------------
/packages/session-opts/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "display": "Default",
4 | "compilerOptions": {
5 | "removeComments": true,
6 | "outDir": "./dist",
7 | "baseUrl": "./",
8 | "target": "ES2020",
9 | "module": "commonjs",
10 | "lib": ["ES2020"],
11 | "esModuleInterop": true,
12 | "isolatedModules": true,
13 | "sourceMap": true,
14 | "declaration": true,
15 | "strict": true,
16 | "noImplicitAny": false,
17 | "noUncheckedIndexedAccess": false,
18 | "noImplicitReturns": true,
19 | "noFallthroughCasesInSwitch": true,
20 | "useUnknownInCatchVariables": false,
21 | "skipDefaultLibCheck": true,
22 | "skipLibCheck": true
23 |
24 | },
25 | "include": [
26 | "."
27 | ],
28 | "exclude": [
29 | "**/dist",
30 | "**/node_modules"
31 | ]
32 | }
33 |
--------------------------------------------------------------------------------
/packages/prismaclient/schema.prisma:
--------------------------------------------------------------------------------
1 | datasource db {
2 | provider = "postgresql"
3 | url = env("DATABASE_URL")
4 | }
5 |
6 | generator client {
7 | provider = "prisma-client-js"
8 | previewFeatures = ["clientExtensions", "metrics"]
9 | }
10 |
11 | model Tenant {
12 | id Int @id @default(autoincrement())
13 | displayName String @map("display_name")
14 | isAdmin Boolean @map("is_admin")
15 |
16 | @@map("tenants")
17 | }
18 |
19 | model User {
20 | id Int @id @default(autoincrement())
21 | tenantId Int @map("tenant_id")
22 | userName String @map("user_name")
23 | password String
24 |
25 | @@map("users")
26 | }
27 |
28 | model Patient {
29 | id Int @id @default(autoincrement())
30 | tenantId Int @map("tenant_id")
31 | firstName String? @map("first_name")
32 | lastName String? @map("last_name")
33 | dob DateTime?
34 |
35 | @@map("patients")
36 | }
37 |
--------------------------------------------------------------------------------
/packages/prismaclient/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "display": "Default",
4 | "compilerOptions": {
5 | "removeComments": true,
6 | "outDir": "./dist",
7 | "baseUrl": "./",
8 | "target": "ES2020",
9 | "module": "commonjs",
10 | "lib": ["ES2020"],
11 | "esModuleInterop": true,
12 | "isolatedModules": true,
13 | "sourceMap": true,
14 | "declaration": true,
15 | "strict": true,
16 | "noImplicitAny": false,
17 | "noUncheckedIndexedAccess": false,
18 | "noImplicitReturns": true,
19 | "noFallthroughCasesInSwitch": true,
20 | "useUnknownInCatchVariables": false,
21 | "skipDefaultLibCheck": true,
22 | "skipLibCheck": true
23 |
24 | },
25 | "include": [
26 | "."
27 | ],
28 | "exclude": [
29 | "**/dist",
30 | "**/node_modules"
31 | ]
32 | }
33 |
--------------------------------------------------------------------------------
/apps/backend/src/prisma-tenancy/prisma-tenancy.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable, Inject, Scope } from '@nestjs/common';
2 | import {
3 | BYPASS_REQ_SCOPE_CLIENT_TOKEN,
4 | TENANCY_REQ_SCOPE_CLIENT_TOKEN,
5 | ExtendedTenantReqScopeClient,
6 | ExtendedBypassReqScopeClient
7 | } from './client-extensions';
8 |
9 | @Injectable()
10 | export class PrismaTenancyService {
11 | constructor(
12 | @Inject(TENANCY_REQ_SCOPE_CLIENT_TOKEN) private readonly tenantService: ExtendedTenantReqScopeClient,
13 | @Inject(BYPASS_REQ_SCOPE_CLIENT_TOKEN) private readonly bypassService: ExtendedBypassReqScopeClient
14 | ) {
15 | console.log("PrismaTenancyService constructer executed");
16 | }
17 | get tenancy() {
18 | return this.tenantService;
19 | }
20 |
21 | get bypass() {
22 | return this.bypassService;
23 | }
24 |
25 | public switch(bypass?: boolean) {
26 | return bypass ? this.bypassService : this.tenantService;
27 | }
28 | }
--------------------------------------------------------------------------------
/apps/frontend/tests/lib/get-user.ts:
--------------------------------------------------------------------------------
1 | const users = [
2 | 't1 user1',
3 | 't1 user2',
4 | 't2 user1',
5 | 't2 user2',
6 | 't3 user1',
7 | 't3 user2',
8 | 't4 user1',
9 | 't4 user2',
10 | 't5 user1',
11 | 't5 user2',
12 | 't6 admin'
13 | ];
14 |
15 | let lastIndex: any;
16 |
17 | export const getUser = () => {
18 | let index = Math.floor(Math.random() * users.length);
19 |
20 | if (index === lastIndex) {
21 | if (index + 1 <= users.length - 1 && (index + 1) !== lastIndex) {
22 | index += 1;
23 | } else if (index - 1 >= 0 && (index - 1) !== lastIndex) {
24 | index -= 1;
25 | } else {
26 | index = Math.floor(Math.random() * users.length);
27 | }
28 | }
29 |
30 | let user = users[index];
31 | let id = Number(user.split(" ")[0].replace('t', ''));
32 | let userId = '1';
33 | let pass = 'user';
34 |
35 | if (user.includes('admin')) {
36 | pass = 'admin';
37 | } else {
38 | userId = user.slice(-1);
39 | }
40 |
41 | lastIndex = index;
42 |
43 | return { user, pass, id, userId };
44 | };
--------------------------------------------------------------------------------
/apps/backend/src/prisma-tenancy/client-extensions/bypass.ts:
--------------------------------------------------------------------------------
1 |
2 | import { PrismaModule, PrismaService } from 'nestjs-prisma';
3 |
4 | const useFactory = (prisma: PrismaService) => {
5 | console.log('Bypass Client useFactory called');
6 |
7 | return prisma.$extends({
8 | query: {
9 | $allModels: {
10 | async $allOperations({ args, query }) {
11 | const [, result] = await prisma.$transaction([
12 | prisma.$executeRaw`SELECT set_config('tenancy.tenant_id', '0', TRUE), set_config('tenancy.bypass', '1', TRUE)`,
13 | query(args),
14 | ]);
15 | return result;
16 | },
17 | },
18 | },
19 | });
20 | };
21 |
22 | export type ExtendedBypassReqScopeClient = ReturnType;
23 |
24 | export const BYPASS_REQ_SCOPE_CLIENT_TOKEN = Symbol('BYPASS_REQ_SCOPE_CLIENT_TOKEN');
25 |
26 | export const PrismaBypassReqScopeClientProvider = {
27 | provide: BYPASS_REQ_SCOPE_CLIENT_TOKEN,
28 | imports: [PrismaModule],
29 | inject: [PrismaService],
30 | useFactory,
31 | };
--------------------------------------------------------------------------------
/dockerfiles/Dockerfile.dev:
--------------------------------------------------------------------------------
1 | # syntax=docker/dockerfile:1.5.2
2 |
3 | FROM custom-node:latest AS builder
4 | WORKDIR /app
5 | ARG APP
6 |
7 | COPY . .
8 |
9 | ## see https://turbo.build/repo/docs/reference/command-line-reference#turbo-prune---scopetarget
10 | RUN turbo prune --scope=$APP --docker
11 |
12 | FROM custom-node:latest AS installer
13 | WORKDIR /app
14 | ARG APP
15 |
16 | COPY --from=builder /app/out/json/ .
17 | COPY --from=builder /app/out/yarn.lock ./yarn.lock
18 |
19 | RUN \
20 | --mount=type=cache,target=/usr/local/share/.cache/yarn/v6,sharing=locked \
21 | yarn --prefer-offline --frozen-lockfile
22 |
23 | COPY --from=builder /app/out/full/ .
24 | COPY turbo.json turbo.json
25 |
26 | ## --filter=frontend^... means all of frontend's dependencies will be built, but not the frontend app itself (which we don't need to do for dev environment)
27 | RUN turbo run build --no-cache --filter=${APP}^...
28 |
29 |
30 | # re-running yarn is necessary to catch changes to deps between workspaces
31 | RUN \
32 | --mount=type=cache,target=/usr/local/share/.cache/yarn/v6,sharing=locked \
33 | yarn --prefer-offline --frozen-lockfile
34 |
35 | FROM node:20.2.0-alpine3.17 AS runner
36 | WORKDIR /app
37 | ARG APP
38 | ARG START_COMMAND=dev
39 |
40 | COPY --from=installer /app .
41 |
42 | CMD yarn workspace ${APP} ${START_COMMAND}
--------------------------------------------------------------------------------
/apps/frontend/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/apps/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "test": "playwright test",
11 | "report": "playwright show-report"
12 | },
13 | "dependencies": {
14 | "@emotion/react": "^11.11.0",
15 | "@emotion/server": "^11.11.0",
16 | "@mantine/core": "^6.0.11",
17 | "@mantine/dates": "^6.0.11",
18 | "@mantine/form": "^6.0.11",
19 | "@mantine/hooks": "^6.0.11",
20 | "@mantine/next": "^6.0.11",
21 | "@tabler/icons-react": "^2.20.0",
22 | "@types/node": "20.2.3",
23 | "@types/react": "18.2.6",
24 | "@types/react-dom": "18.2.4",
25 | "dayjs": "^1.11.7",
26 | "eslint": "8.41.0",
27 | "eslint-config-next": "13.4.3",
28 | "iron-session": "^6.3.1",
29 | "lodash.debounce": "^4.0.8",
30 | "lodash.memoize": "^4.1.2",
31 | "mantine-react-table": "^1.0.0-beta.8",
32 | "next": "13.4.3",
33 | "ofetch": "^1.0.1",
34 | "prismaclient": "*",
35 | "react": "18.2.0",
36 | "react-dom": "18.2.0",
37 | "react-hook-form": "^7.43.9",
38 | "react-hook-form-mantine": "^1.0.10",
39 | "session-opts": "*",
40 | "typescript": "5.0.4",
41 | "zustand": "^4.3.8"
42 | },
43 | "devDependencies": {
44 | "@playwright/test": "^1.34.1",
45 | "@types/lodash.debounce": "^4.0.7",
46 | "@types/lodash.memoize": "^4.1.7"
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/apps/frontend/tests/lib/fixtures.ts:
--------------------------------------------------------------------------------
1 | import { test as baseTest, request } from '@playwright/test';
2 | import fs from 'fs';
3 | import path from 'path';
4 | import { getUser } from './get-user';
5 | export * from '@playwright/test';
6 |
7 | export const test = baseTest.extend<{}, { workerStorageState: string; }>({
8 | storageState: ({ workerStorageState }, use) => use(workerStorageState),
9 |
10 | workerStorageState: [async ({ }, use) => {
11 |
12 | const { user, pass, id: tenantId, userId } = getUser();
13 |
14 | let isAdmin = 0;
15 | if (user.includes('admin')) {
16 | isAdmin = 1;
17 | }
18 |
19 | const fileName = path.resolve(test.info().project.outputDir, `.auth/${tenantId}.${userId}.${isAdmin}.json`);
20 |
21 | if (fs.existsSync(fileName)) {
22 | await use(fileName);
23 | return;
24 | }
25 |
26 | const context = await request.newContext({ storageState: undefined });
27 |
28 | await context.post('http://backend:3333/nest/auth/login', {
29 | data: {
30 | userName: user,
31 | password: pass
32 | }, ignoreHTTPSErrors: true, timeout: 5000
33 | });
34 |
35 | await context.storageState({ path: fileName });
36 | await context.dispose();
37 | await use(fileName);
38 |
39 | }, { scope: 'worker' }],
40 | });
--------------------------------------------------------------------------------
/NGINX.md:
--------------------------------------------------------------------------------
1 | # NGINX Reverse-Proxy
2 |
3 | Here is the proxy definition from docker-compose.yml:
4 |
5 | ```yaml
6 | proxy:
7 | <<: *defaults
8 | image: nginx:1.23.4-alpine
9 | depends_on:
10 | - frontend
11 | - backend
12 | volumes:
13 | - ./nginx/nginx.conf:/etc/nginx/nginx.conf
14 | - ./nginx/proxy.conf:/etc/nginx/proxy.conf
15 | - ./nginx/conf.d/default.conf:/etc/nginx/conf.d/default.conf
16 | ports:
17 | - "80:80"
18 | ```
19 |
20 | [nginx config directory](nginx)
21 |
22 | The NGINX config is minimal. The reverse-proxy routes pathnames that begin with `/nest` to the NestJS server, otherwise it sends requests to the NextJS frontend.
23 |
24 | ### [default.conf](nginx/conf.d/default.conf)
25 |
26 | ```Nginx
27 | upstream nextjs_upstream {
28 | server frontend:3000;
29 | }
30 |
31 | upstream nestjs_upstream {
32 | server backend:3333;
33 | }
34 |
35 | server {
36 | listen 80 reuseport default_server;
37 | listen [::]:80 reuseport default_server;
38 |
39 | gzip on;
40 | gzip_proxied any;
41 | gzip_comp_level 4;
42 | gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
43 |
44 | location /nest {
45 | include /etc/nginx/proxy.conf;
46 | proxy_pass http://nestjs_upstream;
47 | }
48 |
49 | location / {
50 | include /etc/nginx/proxy.conf;
51 | proxy_pass http://nextjs_upstream;
52 | }
53 | }
54 | ```
55 |
--------------------------------------------------------------------------------
/apps/backend/src/auth/auth.guard.ts:
--------------------------------------------------------------------------------
1 | import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
2 | import { Reflector } from '@nestjs/core';
3 | import { Request, Response } from 'express';
4 | import { IS_PUBLIC_KEY } from './public.decorator';
5 | import { SessionData } from 'session-opts';
6 |
7 | @Injectable()
8 | export class AuthGuard implements CanActivate {
9 | constructor(private readonly reflector: Reflector) { }
10 |
11 | public async canActivate(context: ExecutionContext): Promise {
12 |
13 | const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [
14 | context.getHandler(),
15 | context.getClass(),
16 | ]);
17 |
18 | if (context.getType() === 'http' || context.getType() === 'ws') {
19 | const switched = context.switchToHttp();
20 |
21 | return await this.setHttpHeader(
22 | isPublic,
23 | switched.getRequest(),
24 | switched.getResponse(),
25 | context.getType()
26 | );
27 | }
28 | }
29 |
30 | private async setHttpHeader(
31 | isPublic: boolean,
32 | req: Request,
33 | _res: Response,
34 | _type = 'http'
35 | ): Promise {
36 |
37 | if (isPublic === true) {
38 | return true;
39 | }
40 |
41 | const session: SessionData = req.session;
42 |
43 | return !!session?.authenticated;
44 | }
45 |
46 | }
--------------------------------------------------------------------------------
/apps/frontend/src/lib/ofetch-instance.ts:
--------------------------------------------------------------------------------
1 | import { useAppStore } from './zustand/app-store';
2 | import { ofetch, FetchOptions } from 'ofetch';
3 |
4 | function endpoint() {
5 | if (typeof window !== 'undefined') {
6 | if (window.location.host.includes('frontend')) {
7 | return 'http://backend:3333/nest';
8 | }
9 |
10 | return `${window.location.protocol}//${window.location.host}/nest`;
11 | } else {
12 | return 'http://backend:3333/nest';
13 | }
14 | }
15 |
16 |
17 | export const getFetchInstance = (opts?: FetchOptions) => {
18 | opts = opts || {};
19 |
20 | if (typeof window !== 'undefined') {
21 | opts = {
22 | ...opts,
23 | onRequestError(context) {
24 | const { setLoading } = useAppStore.getState();
25 | setLoading(false);
26 |
27 | const { error } = context;
28 | throw new Error(error?.message);
29 | },
30 | onResponseError(context) {
31 | const { setLoading } = useAppStore.getState();
32 | setLoading(false);
33 |
34 |
35 | const { error } = context;
36 | throw new Error(error?.message);
37 | }
38 | };
39 | }
40 |
41 | const instance = ofetch.create({
42 | ...opts,
43 | baseURL: opts.baseURL || endpoint(),
44 | credentials: opts.credentials || 'include',
45 | });
46 |
47 | return instance;
48 | };
--------------------------------------------------------------------------------
/apps/frontend/src/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Head from 'next/head';
3 | import { AppProps } from 'next/app';
4 | import { usePathname, useSearchParams } from 'next/navigation';
5 | import { MantineProvider } from '@mantine/core';
6 | import { useDidUpdate, useShallowEffect } from '@mantine/hooks';
7 |
8 | import { useAppStore } from '@/lib/zustand/app-store';
9 | import { Layout } from '@/lib/components/layout';
10 |
11 | export default function App(props: AppProps) {
12 | const { Component, pageProps } = props;
13 |
14 | const { user } = pageProps;
15 |
16 | const pathname = usePathname();
17 | const searchParams = useSearchParams();
18 |
19 | useShallowEffect(() => {
20 | if (pathname === '/login') {
21 | localStorage.removeItem('app-store-storage');
22 | } else {
23 | const { setUser, user: storeUser } = useAppStore.getState();
24 | if (!!user && storeUser?.userName !== user?.userName) {
25 | setUser(user);
26 | }
27 | }
28 | }, [user, pathname]);
29 |
30 | useDidUpdate(() => {
31 | setTimeout(() => {
32 | const { setLoading } = useAppStore.getState();
33 | setLoading(false);
34 | });
35 |
36 | }, [pathname, searchParams]);
37 |
38 |
39 | return (
40 | <>
41 |
42 | Multi Tenancy Example App
43 |
44 |
45 |
46 |
53 |
54 |
55 | >
56 | );
57 | }
--------------------------------------------------------------------------------
/apps/backend/src/prisma-tenancy/client-extensions/tenant.ts:
--------------------------------------------------------------------------------
1 |
2 | import { REQUEST } from '@nestjs/core';
3 | import { Request } from 'express';
4 | import { Scope } from '@nestjs/common';
5 | import { SessionData } from 'session-opts';
6 | import { PrismaModule, PrismaService } from 'nestjs-prisma';
7 |
8 | const useFactory = (prisma: PrismaService, req: Request & { session: SessionData; }) => {
9 | console.log('Tenant Client useFactory called');
10 |
11 | return prisma.$extends({
12 | query: {
13 | $allModels: {
14 | async $allOperations({ args, query }) {
15 | const session = (req?.session || {}) as SessionData;
16 | const tenantId = session?.tenantId || 0;
17 | const isAdmin = session?.isAdmin || false;
18 |
19 | const [, result] = await prisma.$transaction([
20 | prisma.$executeRaw`SELECT set_config('tenancy.tenant_id', ${`${tenantId || 0}`}, TRUE), set_config('tenancy.bypass', ${`${isAdmin ? 1 : 0}`}, TRUE)`,
21 | query(args),
22 | ]);
23 | return result;
24 | },
25 | },
26 | },
27 | });
28 | };
29 |
30 | export type ExtendedTenantReqScopeClient = ReturnType;
31 |
32 | export const TENANCY_REQ_SCOPE_CLIENT_TOKEN = Symbol('TENANCY_REQ_SCOPE_CLIENT_TOKEN');
33 |
34 | export const PrismaTenancyReqScopeClientProvider = {
35 | provide: TENANCY_REQ_SCOPE_CLIENT_TOKEN,
36 | imports: [PrismaModule],
37 | inject: [PrismaService, REQUEST],
38 | useFactory,
39 | scope: Scope.REQUEST,
40 | durable: true
41 | };
--------------------------------------------------------------------------------
/apps/frontend/README.md:
--------------------------------------------------------------------------------
1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
2 |
3 | ## Getting Started
4 |
5 | First, run the development server:
6 |
7 | ```bash
8 | npm run dev
9 | # or
10 | yarn dev
11 | # or
12 | pnpm dev
13 | ```
14 |
15 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
16 |
17 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
18 |
19 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
20 |
21 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
22 |
23 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
24 |
25 | ## Learn More
26 |
27 | To learn more about Next.js, take a look at the following resources:
28 |
29 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
30 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
31 |
32 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
33 |
34 | ## Deploy on Vercel
35 |
36 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
37 |
38 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
39 |
--------------------------------------------------------------------------------
/apps/frontend/src/lib/components/patients-table/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import dayjs from 'dayjs';
3 | import { Center, Box, Stack, Title } from '@mantine/core';
4 | import { MantineReactTable } from 'mantine-react-table';
5 |
6 | import type { Patient } from 'prismaclient';
7 | import { useData } from '@/lib/hooks/use-data';
8 | import { columns } from './columns';
9 |
10 | export default function PatientsTable() {
11 |
12 | const data = useData('patients', (row) => {
13 | return {
14 | ...row,
15 | dob: dayjs(row.dob).format('MM/DD/YYYY') as any
16 | };
17 | });
18 |
19 | return (
20 |
21 |
22 |
23 |
24 | Patients
25 |
26 |
27 |
46 |
47 |
48 |
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/apps/frontend/src/lib/components/layout.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { AppShell, Header, Group, Button, Title, Box, LoadingOverlay } from '@mantine/core';
3 | import { usePathname, useRouter } from 'next/navigation';
4 | import { shallowEqual } from '@mantine/hooks';
5 |
6 | import { useAppStore } from '../zustand/app-store';
7 | import { getFetchInstance } from '../ofetch-instance';
8 |
9 | export function Layout(props: { children: React.ReactNode, user: { userName?: string, tenantName?: string; }; }) {
10 | const { children, user } = props;
11 |
12 | const path = usePathname();
13 | const router = useRouter();
14 |
15 | const loading = useAppStore(state => {
16 | return state.loading;
17 | });
18 |
19 | const storeUser = useAppStore(state => {
20 | return state.user;
21 | }, shallowEqual) || user;
22 |
23 | const logout = React.useCallback(async () => {
24 | const { setLoading } = useAppStore.getState();
25 |
26 | setLoading(true);
27 |
28 | const oFetch = getFetchInstance();
29 |
30 | await oFetch('auth/logout', { method: 'POST' });
31 |
32 | router.push('/login');
33 | }, []);
34 |
35 | return (
36 | <>
37 |
43 |
44 | Multi Tenant App
45 |
46 |
47 | Welcome, {storeUser?.userName} of {storeUser?.tenantName}
48 |
49 |
50 |
51 |
52 |
53 |
54 | }
55 | >
56 |
57 |
58 | {children}
59 |
60 |
61 | >
62 | );
63 | }
--------------------------------------------------------------------------------
/apps/backend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "backend",
3 | "version": "0.0.1",
4 | "description": "",
5 | "author": "",
6 | "private": true,
7 | "license": "UNLICENSED",
8 | "scripts": {
9 | "build": "nest build",
10 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
11 | "start": "nest start",
12 | "start:dev": "nest start --watch",
13 | "start:debug": "nest start --debug --watch",
14 | "start:prod": "node dist/main",
15 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
16 | "test": "jest",
17 | "test:watch": "jest --watch",
18 | "test:cov": "jest --coverage",
19 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
20 | "test:e2e": "jest --config ./test/jest-e2e.json"
21 | },
22 | "dependencies": {
23 | "@nestjs/common": "^9.4.2",
24 | "@nestjs/config": "^2.3.2",
25 | "@nestjs/core": "^9.4.2",
26 | "@nestjs/platform-express": "^9.4.2",
27 | "bcrypt": "^5.1.0",
28 | "cookie-parser": "^1.4.6",
29 | "nestjs-cls": "^3.3.1",
30 | "nestjs-prisma": "^0.20.0",
31 | "prismaclient": "*",
32 | "reflect-metadata": "^0.1.13",
33 | "rxjs": "^7.2.0",
34 | "session-opts": "*"
35 | },
36 | "devDependencies": {
37 | "@nestjs/cli": "^9.5.0",
38 | "@nestjs/schematics": "^9.2.0",
39 | "@nestjs/testing": "^9.4.2",
40 | "@types/bcrypt": "^5.0.0",
41 | "@types/cookie-parser": "^1.4.3",
42 | "@types/express": "^4.17.17",
43 | "@types/jest": "29.5.1",
44 | "@types/node": "20.2.3",
45 | "@types/supertest": "^2.0.12",
46 | "@typescript-eslint/eslint-plugin": "^5.59.7",
47 | "@typescript-eslint/parser": "^5.59.7",
48 | "eslint": "^8.41.0",
49 | "eslint-config-prettier": "^8.8.0",
50 | "eslint-plugin-prettier": "^4.2.1",
51 | "jest": "29.5.0",
52 | "prettier": "^2.8.8",
53 | "source-map-support": "^0.5.21",
54 | "supertest": "^6.3.3",
55 | "ts-jest": "29.1.0",
56 | "ts-loader": "^9.4.3",
57 | "ts-node": "^10.9.1",
58 | "tsconfig-paths": "4.2.0",
59 | "typescript": "^5.0.4"
60 | },
61 | "jest": {
62 | "moduleFileExtensions": [
63 | "js",
64 | "json",
65 | "ts"
66 | ],
67 | "rootDir": "src",
68 | "testRegex": ".*\\.spec\\.ts$",
69 | "transform": {
70 | "^.+\\.(t|j)s$": "ts-jest"
71 | },
72 | "collectCoverageFrom": [
73 | "**/*.(t|j)s"
74 | ],
75 | "coverageDirectory": "../coverage",
76 | "testEnvironment": "node"
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.8'
2 | name: tenancy-example-dev
3 |
4 | x-defaults:
5 | &defaults
6 | init: true
7 | tty: true
8 | networks:
9 | - tenancy_example_network
10 |
11 | networks:
12 | tenancy_example_network:
13 | external: true
14 |
15 | volumes:
16 | tenancy_example_db_data:
17 | external: true
18 |
19 | services:
20 |
21 | ####################################################################
22 |
23 | db:
24 | <<: *defaults
25 | image: postgres:15.3-alpine3.17
26 | ports:
27 | - '5432:5432'
28 | volumes:
29 | - tenancy_example_db_data:/var/lib/postgresql/data
30 | - type: bind
31 | source: ./db
32 | target: /docker-entrypoint-initdb.d
33 | environment:
34 | POSTGRES_PASSWORD: 07f019e661d8ca48c47bdffd255b12fe
35 |
36 | ####################################################################
37 |
38 | proxy:
39 | <<: *defaults
40 | image: nginx:1.23.4-alpine3.17
41 | depends_on:
42 | - frontend
43 | - backend
44 | volumes:
45 | - ./nginx/nginx.conf:/etc/nginx/nginx.conf
46 | - ./nginx/proxy.conf:/etc/nginx/proxy.conf
47 | - ./nginx/conf.d/default.conf:/etc/nginx/conf.d/default.conf
48 | ports:
49 | - "80:80"
50 |
51 | ####################################################################
52 |
53 | frontend:
54 | <<: *defaults
55 | depends_on:
56 | - backend
57 | expose:
58 | - 3000
59 | command: yarn workspace frontend dev
60 | environment:
61 | - PORT=3000
62 | build:
63 | args:
64 | APP: frontend
65 | START_COMMAND: dev
66 | context: .
67 | dockerfile: ./dockerfiles/Dockerfile.dev
68 | volumes:
69 | - ./apps/frontend:/app/apps/frontend
70 | - /app/node_modules
71 | - /app/apps/frontend/node_modules
72 | - /app/apps/frontend/.next
73 |
74 | ####################################################################
75 |
76 | backend:
77 | <<: *defaults
78 | expose:
79 | - 3333
80 | command: yarn workspace backend start:dev
81 | environment:
82 | - PORT=3333
83 | - IN_CONTAINER=1
84 | build:
85 | args:
86 | APP: backend
87 | START_COMMAND: start:dev
88 | context: .
89 | dockerfile: ./dockerfiles/Dockerfile.dev
90 | volumes:
91 | - ./apps/backend:/app/apps/backend
92 | - /app/node_modules
93 | - /app/apps/backend/node_modules
94 | ####################################################################
95 |
--------------------------------------------------------------------------------
/apps/backend/src/auth/auth.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable, UnauthorizedException, InternalServerErrorException, BadRequestException } from '@nestjs/common';
2 | import { UsersService } from '@/models/users/users.service';
3 | import { TenantsService } from '@/models/tenants/tenants.service';
4 | import { compare } from 'bcrypt';
5 | import { Request } from 'express';
6 | import { SessionData } from 'session-opts';
7 |
8 | @Injectable()
9 | export class AuthService {
10 |
11 | constructor(private readonly user: UsersService, private readonly tenant: TenantsService) { }
12 |
13 | async login(credentials: { userName: string, password: string; }, req: Request) {
14 |
15 | const { userName, password } = credentials;
16 |
17 | if (!userName || !password) {
18 | console.log('Missing username or password', credentials);
19 | throw new BadRequestException('Missing username or password');
20 | }
21 |
22 | const dbUser = await this.user.findFirst({ where: { userName } }, true);
23 |
24 | if (!dbUser) {
25 | console.log('Invalid Username or Password 1', credentials);
26 | throw new UnauthorizedException('Invalid Username or Password 1');
27 | }
28 |
29 | const passCheck = await compare(password, dbUser.password);
30 |
31 | if (!passCheck) {
32 | console.log('Invalid Username or Password 2', credentials);
33 | throw new UnauthorizedException('Invalid Username or Password 2');
34 | }
35 |
36 | const dbTenant = await this.tenant.findUnique({ where: { id: dbUser.tenantId } }, true);
37 |
38 | if (!dbTenant) {
39 | console.log('Tenant Not Found', credentials);
40 | throw new InternalServerErrorException('Tenant Not Found');
41 | }
42 |
43 | const session: SessionData | any = req?.session || {}; //this.store.get('session');
44 |
45 | session.userId = dbUser.id;
46 | session.tenantId = dbUser.tenantId;
47 |
48 | session.userName = dbUser.userName;
49 | session.tenantName = dbTenant.displayName;
50 |
51 | session.isAdmin = dbTenant.isAdmin;
52 | session.authenticated = true;
53 |
54 | await session.save();
55 |
56 | return 'ok';
57 | }
58 |
59 | async logout(req: Request) {
60 | const session = req.session; //this.store.get('session');
61 |
62 | await session.destroy();
63 |
64 | return 'ok';
65 | }
66 | }
--------------------------------------------------------------------------------
/nginx/conf.d/default.conf:
--------------------------------------------------------------------------------
1 | # needed for nextjs ws hmr
2 | map $http_upgrade $connection_upgrade {
3 | default Upgrade;
4 | '' close;
5 | }
6 |
7 | # For an explanation of the map directives below, see: https://www.nginx.com/resources/wiki/start/topics/examples/forwarded/
8 | map $remote_addr $proxy_forwarded_elem {
9 | # IPv4 addresses can be sent as-is
10 | ~^[0-9.]+$ "for=$remote_addr";
11 |
12 | # IPv6 addresses need to be bracketed and quoted
13 | ~^[0-9A-Fa-f:.]+$ "for=\"[$remote_addr]\"";
14 |
15 | # Unix domain socket names cannot be represented in RFC 7239 syntax
16 | default "for=unknown";
17 | }
18 |
19 | map $http_forwarded $proxy_add_forwarded {
20 | # If the incoming Forwarded header is syntactically valid, append to it
21 | "~^(,[ \\t]*)*([!#$%&'*+.^_`|~0-9A-Za-z-]+=([!#$%&'*+.^_`|~0-9A-Za-z-]+|\"([\\t \\x21\\x23-\\x5B\\x5D-\\x7E\\x80-\\xFF]|\\\\[\\t \\x21-\\x7E\\x80-\\xFF])*\"))?(;([!#$%&'*+.^_`|~0-9A-Za-z-]+=([!#$%&'*+.^_`|~0-9A-Za-z-]+|\"([\\t \\x21\\x23-\\x5B\\x5D-\\x7E\\x80-\\xFF]|\\\\[\\t \\x21-\\x7E\\x80-\\xFF])*\"))?)*([ \\t]*,([ \\t]*([!#$%&'*+.^_`|~0-9A-Za-z-]+=([!#$%&'*+.^_`|~0-9A-Za-z-]+|\"([\\t \\x21\\x23-\\x5B\\x5D-\\x7E\\x80-\\xFF]|\\\\[\\t \\x21-\\x7E\\x80-\\xFF])*\"))?(;([!#$%&'*+.^_`|~0-9A-Za-z-]+=([!#$%&'*+.^_`|~0-9A-Za-z-]+|\"([\\t \\x21\\x23-\\x5B\\x5D-\\x7E\\x80-\\xFF]|\\\\[\\t \\x21-\\x7E\\x80-\\xFF])*\"))?)*)?)*$" "$http_forwarded, $proxy_forwarded_elem";
22 |
23 | # Otherwise, replace it
24 | default "$proxy_forwarded_elem";
25 | }
26 |
27 | # note that these upstream blocks use the service name as defined in docker-compose.yml
28 | upstream nextjs_upstream {
29 | server frontend:3000;
30 | }
31 |
32 | upstream nestjs_upstream {
33 | server backend:3333;
34 | }
35 |
36 | server {
37 | listen 80 reuseport default_server;
38 | listen [::]:80 reuseport default_server;
39 |
40 | gzip on;
41 | gzip_proxied any;
42 | gzip_comp_level 4;
43 | gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
44 |
45 | # this location directs requests like 'http://localhost/nest/whatever' to the nestjs backend server
46 | location /nest {
47 | include /etc/nginx/proxy.conf;
48 | proxy_pass http://nestjs_upstream;
49 | }
50 |
51 | # this location maps directs all other requests to the nextjs frontend server
52 | location / {
53 | include /etc/nginx/proxy.conf;
54 | proxy_pass http://nextjs_upstream;
55 | }
56 | }
--------------------------------------------------------------------------------
/apps/frontend/src/styles/globals.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --max-width: 1100px;
3 | --border-radius: 12px;
4 | --font-mono: ui-monospace, Menlo, Monaco, 'Cascadia Mono', 'Segoe UI Mono',
5 | 'Roboto Mono', 'Oxygen Mono', 'Ubuntu Monospace', 'Source Code Pro',
6 | 'Fira Mono', 'Droid Sans Mono', 'Courier New', monospace;
7 |
8 | --foreground-rgb: 0, 0, 0;
9 | --background-start-rgb: 214, 219, 220;
10 | --background-end-rgb: 255, 255, 255;
11 |
12 | --primary-glow: conic-gradient(
13 | from 180deg at 50% 50%,
14 | #16abff33 0deg,
15 | #0885ff33 55deg,
16 | #54d6ff33 120deg,
17 | #0071ff33 160deg,
18 | transparent 360deg
19 | );
20 | --secondary-glow: radial-gradient(
21 | rgba(255, 255, 255, 1),
22 | rgba(255, 255, 255, 0)
23 | );
24 |
25 | --tile-start-rgb: 239, 245, 249;
26 | --tile-end-rgb: 228, 232, 233;
27 | --tile-border: conic-gradient(
28 | #00000080,
29 | #00000040,
30 | #00000030,
31 | #00000020,
32 | #00000010,
33 | #00000010,
34 | #00000080
35 | );
36 |
37 | --callout-rgb: 238, 240, 241;
38 | --callout-border-rgb: 172, 175, 176;
39 | --card-rgb: 180, 185, 188;
40 | --card-border-rgb: 131, 134, 135;
41 | }
42 |
43 | @media (prefers-color-scheme: dark) {
44 | :root {
45 | --foreground-rgb: 255, 255, 255;
46 | --background-start-rgb: 0, 0, 0;
47 | --background-end-rgb: 0, 0, 0;
48 |
49 | --primary-glow: radial-gradient(rgba(1, 65, 255, 0.4), rgba(1, 65, 255, 0));
50 | --secondary-glow: linear-gradient(
51 | to bottom right,
52 | rgba(1, 65, 255, 0),
53 | rgba(1, 65, 255, 0),
54 | rgba(1, 65, 255, 0.3)
55 | );
56 |
57 | --tile-start-rgb: 2, 13, 46;
58 | --tile-end-rgb: 2, 5, 19;
59 | --tile-border: conic-gradient(
60 | #ffffff80,
61 | #ffffff40,
62 | #ffffff30,
63 | #ffffff20,
64 | #ffffff10,
65 | #ffffff10,
66 | #ffffff80
67 | );
68 |
69 | --callout-rgb: 20, 20, 20;
70 | --callout-border-rgb: 108, 108, 108;
71 | --card-rgb: 100, 100, 100;
72 | --card-border-rgb: 200, 200, 200;
73 | }
74 | }
75 |
76 | * {
77 | box-sizing: border-box;
78 | padding: 0;
79 | margin: 0;
80 | }
81 |
82 | html,
83 | body {
84 | max-width: 100vw;
85 | overflow-x: hidden;
86 | }
87 |
88 | body {
89 | color: rgb(var(--foreground-rgb));
90 | background: linear-gradient(
91 | to bottom,
92 | transparent,
93 | rgb(var(--background-end-rgb))
94 | )
95 | rgb(var(--background-start-rgb));
96 | }
97 |
98 | a {
99 | color: inherit;
100 | text-decoration: none;
101 | }
102 |
103 | @media (prefers-color-scheme: dark) {
104 | html {
105 | color-scheme: dark;
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/apps/frontend/src/lib/components/login-form.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | Paper,
4 | Group,
5 | Button,
6 | Stack,
7 | Center,
8 | Title,
9 | Box,
10 | } from '@mantine/core';
11 | import { useRouter } from 'next/navigation';
12 | import { useForm } from "react-hook-form";
13 | import {
14 | PasswordInput,
15 | TextInput,
16 | } from "react-hook-form-mantine";
17 |
18 | import { getFetchInstance } from '../ofetch-instance';
19 | import { useAppStore } from '../zustand/app-store';
20 |
21 | export function LoginForm() {
22 |
23 | const { control, handleSubmit } = useForm({
24 | defaultValues: {
25 | userName: '',
26 | password: ''
27 | }
28 | });
29 |
30 | const router = useRouter();
31 |
32 | const onSubmitOk = async (data: { userName: string, password: string; }) => {
33 | const { setLoading } = useAppStore.getState();
34 | setLoading(true);
35 |
36 | const oFetch = getFetchInstance();
37 |
38 | let result = null;
39 |
40 | try {
41 | result = await oFetch('auth/login', { method: 'POST', body: data });
42 | } catch (err) {
43 | console.error(err);
44 | }
45 |
46 | if (!!result) {
47 | router.push('/');
48 | }
49 | };
50 |
51 | return (
52 |
53 |
54 |
55 |
78 |
79 |
80 |
81 | 5 non-admin tenants with 2 users each (password: user)
82 | 1 admin tenant with a single user (password: admin)
83 | Username format (non-admin): t(1-5) user(1-2)
84 | Example (tenant 3 user 2): t3 user2, password user
85 | Example (tenant 1 user 1): t1 user1, password user
86 | Admin login: t6 admin, password admin
87 |
88 |
89 |
90 |
91 | );
92 | }
93 |
--------------------------------------------------------------------------------
/apps/backend/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | [circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
6 | [circleci-url]: https://circleci.com/gh/nestjs/nest
7 |
8 | A progressive Node.js framework for building efficient and scalable server-side applications.
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
24 |
25 | ## Description
26 |
27 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
28 |
29 | ## Installation
30 |
31 | ```bash
32 | $ yarn install
33 | ```
34 |
35 | ## Running the app
36 |
37 | ```bash
38 | # development
39 | $ yarn run start
40 |
41 | # watch mode
42 | $ yarn run start:dev
43 |
44 | # production mode
45 | $ yarn run start:prod
46 | ```
47 |
48 | ## Test
49 |
50 | ```bash
51 | # unit tests
52 | $ yarn run test
53 |
54 | # e2e tests
55 | $ yarn run test:e2e
56 |
57 | # test coverage
58 | $ yarn run test:cov
59 | ```
60 |
61 | ## Support
62 |
63 | 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).
64 |
65 | ## Stay in touch
66 |
67 | - Author - [Kamil Myśliwiec](https://kamilmysliwiec.com)
68 | - Website - [https://nestjs.com](https://nestjs.com/)
69 | - Twitter - [@nestframework](https://twitter.com/nestframework)
70 |
71 | ## License
72 |
73 | Nest is [MIT licensed](LICENSE).
74 |
--------------------------------------------------------------------------------
/db/1_schema.sql:
--------------------------------------------------------------------------------
1 | -- PG_DUMP BOILERPLATE ---------------------------------
2 | SET statement_timeout = 0;
3 | SET lock_timeout = 0;
4 | SET idle_in_transaction_session_timeout = 0;
5 | SET client_encoding = 'UTF8';
6 | SET standard_conforming_strings = on;
7 | SELECT pg_catalog.set_config('search_path', '', false);
8 | SET check_function_bodies = false;
9 | SET xmloption = content;
10 | SET client_min_messages = warning;
11 | SET row_security = off;
12 |
13 | -- DROP AND CREATE DATABASE ---------------------------------
14 | DROP DATABASE IF EXISTS app_db;
15 |
16 | CREATE DATABASE app_db WITH TEMPLATE = template0 ENCODING = 'UTF8' LOCALE = 'C.UTF-8';
17 |
18 | ALTER DATABASE app_db OWNER TO postgres;
19 |
20 | \connect app_db
21 |
22 | -- CREATE FN SCHEMA ---------------------------------
23 | CREATE SCHEMA IF NOT EXISTS fn;
24 | ALTER SCHEMA fn OWNER TO postgres;
25 |
26 |
27 | -- CREATE TENANT ROLE ---------------------------------
28 | CREATE ROLE tenant WITH
29 | LOGIN
30 | NOSUPERUSER
31 | INHERIT
32 | NOCREATEDB
33 | NOCREATEROLE
34 | NOREPLICATION
35 | ENCRYPTED PASSWORD 'SCRAM-SHA-256$4096:E0Wq8zef+fUG5k++CVd7lg==$TpHflYTTvCNNIvHW2dkOe4UTb5V0yPw9su/kpoNxLy0=:BgselHsx/GYWjgaVxFQPE/NyUBzMM5O5WB168qFwYH8=';
36 | -- c7b38884e5c959ac151e4f24320c7a34
37 |
38 | GRANT USAGE ON SCHEMA public TO tenant;
39 | GRANT USAGE ON SCHEMA fn TO tenant;
40 |
41 |
42 | -- TABLES: TENANTS, USERS, PATIENTS ---------------------------------
43 | create table if not exists public.tenants
44 | (
45 | id bigserial primary key,
46 | display_name varchar,
47 | is_admin boolean default false
48 | );
49 |
50 | create table if not exists public.users
51 | (
52 | id bigserial primary key,
53 | tenant_id bigint
54 | constraint users_tenants_id_fk
55 | references public.tenants
56 | on delete cascade,
57 | user_name varchar,
58 | password varchar
59 | );
60 |
61 | create table if not exists public.patients
62 | (
63 | id bigserial primary key,
64 | tenant_id bigint
65 | constraint patients_tenants_id_fk
66 | references public.tenants
67 | on delete cascade,
68 | first_name varchar,
69 | last_name varchar,
70 | dob date
71 | );
72 |
73 |
74 |
75 | -- GRANTS FOR TENANT ROLE ---------------------------------
76 | grant delete, insert, select, update on public.tenants to tenant;
77 | grant delete, insert, select, update on public.users to tenant;
78 | grant delete, insert, select, update on public.patients to tenant;
79 |
80 |
81 | -- ROW LEVEL SECURITY CHECK FUNCTION ---------------------------------
82 | create or replace function fn.tenant_data_rls_check(row_tenant_id bigint) returns boolean
83 | language plpgsql
84 | as
85 | $$
86 |
87 | BEGIN
88 |
89 | IF current_setting('tenancy.bypass')::text = '1' THEN
90 | return true;
91 | end if;
92 |
93 |
94 | IF current_setting('tenancy.tenant_id')::integer = row_tenant_id THEN
95 | return true;
96 | end if;
97 |
98 | return false;
99 |
100 | END;
101 | $$;
102 |
103 | alter function fn.tenant_data_rls_check(bigint) owner to postgres;
104 |
105 |
106 | -- ENABLE / DISABLE RLS ---------------------------------
107 | create or replace procedure fn.enable_rls()
108 | language plpgsql
109 | as
110 | $$
111 | DECLARE
112 | r record;
113 | BEGIN
114 | FOR r in select * from pg_catalog.pg_policies
115 | LOOP
116 | EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', r.tablename);
117 | END LOOP;
118 |
119 | END;
120 | $$;
121 |
122 | alter procedure fn.enable_rls() owner to postgres;
123 |
124 | create or replace procedure fn.disable_rls()
125 | language plpgsql
126 | as
127 | $$
128 | DECLARE
129 | r record;
130 | BEGIN
131 | FOR r in select * from pg_catalog.pg_policies
132 | LOOP
133 | EXECUTE format('ALTER TABLE public.%I DISABLE ROW LEVEL SECURITY', r.tablename);
134 | END LOOP;
135 |
136 | END;
137 | $$;
138 |
139 | alter procedure fn.enable_rls() owner to postgres;
140 |
141 | -- POLICIES ---------------------------------
142 | create policy tenancy_policy on public.tenants
143 | as permissive
144 | for all
145 | using (fn.tenant_data_rls_check(id) = true)
146 | with check (fn.tenant_data_rls_check(id) = true);
147 |
148 | create policy tenancy_policy on public.users
149 | as permissive
150 | for all
151 | using (fn.tenant_data_rls_check(tenant_id) = true)
152 | with check (fn.tenant_data_rls_check(tenant_id) = true);
153 |
154 | create policy tenancy_policy on public.patients
155 | as permissive
156 | for all
157 | using (fn.tenant_data_rls_check(tenant_id) = true)
158 | with check (fn.tenant_data_rls_check(tenant_id) = true);
159 |
160 | -- ENABLE ROW LEVEL SECURITY ---------------------------------
161 | CALL fn.enable_rls();
--------------------------------------------------------------------------------
/apps/frontend/src/styles/Home.module.css:
--------------------------------------------------------------------------------
1 | .main {
2 | display: flex;
3 | flex-direction: column;
4 | justify-content: space-between;
5 | align-items: center;
6 | padding: 6rem;
7 | min-height: 100vh;
8 | }
9 |
10 | .description {
11 | display: inherit;
12 | justify-content: inherit;
13 | align-items: inherit;
14 | font-size: 0.85rem;
15 | max-width: var(--max-width);
16 | width: 100%;
17 | z-index: 2;
18 | font-family: var(--font-mono);
19 | }
20 |
21 | .description a {
22 | display: flex;
23 | justify-content: center;
24 | align-items: center;
25 | gap: 0.5rem;
26 | }
27 |
28 | .description p {
29 | position: relative;
30 | margin: 0;
31 | padding: 1rem;
32 | background-color: rgba(var(--callout-rgb), 0.5);
33 | border: 1px solid rgba(var(--callout-border-rgb), 0.3);
34 | border-radius: var(--border-radius);
35 | }
36 |
37 | .code {
38 | font-weight: 700;
39 | font-family: var(--font-mono);
40 | }
41 |
42 | .grid {
43 | display: grid;
44 | grid-template-columns: repeat(4, minmax(25%, auto));
45 | width: var(--max-width);
46 | max-width: 100%;
47 | }
48 |
49 | .card {
50 | padding: 1rem 1.2rem;
51 | border-radius: var(--border-radius);
52 | background: rgba(var(--card-rgb), 0);
53 | border: 1px solid rgba(var(--card-border-rgb), 0);
54 | transition: background 200ms, border 200ms;
55 | }
56 |
57 | .card span {
58 | display: inline-block;
59 | transition: transform 200ms;
60 | }
61 |
62 | .card h2 {
63 | font-weight: 600;
64 | margin-bottom: 0.7rem;
65 | }
66 |
67 | .card p {
68 | margin: 0;
69 | opacity: 0.6;
70 | font-size: 0.9rem;
71 | line-height: 1.5;
72 | max-width: 30ch;
73 | }
74 |
75 | .center {
76 | display: flex;
77 | justify-content: center;
78 | align-items: center;
79 | position: relative;
80 | padding: 4rem 0;
81 | }
82 |
83 | .center::before {
84 | background: var(--secondary-glow);
85 | border-radius: 50%;
86 | width: 480px;
87 | height: 360px;
88 | margin-left: -400px;
89 | }
90 |
91 | .center::after {
92 | background: var(--primary-glow);
93 | width: 240px;
94 | height: 180px;
95 | z-index: -1;
96 | }
97 |
98 | .center::before,
99 | .center::after {
100 | content: '';
101 | left: 50%;
102 | position: absolute;
103 | filter: blur(45px);
104 | transform: translateZ(0);
105 | }
106 |
107 | .logo {
108 | position: relative;
109 | }
110 | /* Enable hover only on non-touch devices */
111 | @media (hover: hover) and (pointer: fine) {
112 | .card:hover {
113 | background: rgba(var(--card-rgb), 0.1);
114 | border: 1px solid rgba(var(--card-border-rgb), 0.15);
115 | }
116 |
117 | .card:hover span {
118 | transform: translateX(4px);
119 | }
120 | }
121 |
122 | @media (prefers-reduced-motion) {
123 | .card:hover span {
124 | transform: none;
125 | }
126 | }
127 |
128 | /* Mobile */
129 | @media (max-width: 700px) {
130 | .content {
131 | padding: 4rem;
132 | }
133 |
134 | .grid {
135 | grid-template-columns: 1fr;
136 | margin-bottom: 120px;
137 | max-width: 320px;
138 | text-align: center;
139 | }
140 |
141 | .card {
142 | padding: 1rem 2.5rem;
143 | }
144 |
145 | .card h2 {
146 | margin-bottom: 0.5rem;
147 | }
148 |
149 | .center {
150 | padding: 8rem 0 6rem;
151 | }
152 |
153 | .center::before {
154 | transform: none;
155 | height: 300px;
156 | }
157 |
158 | .description {
159 | font-size: 0.8rem;
160 | }
161 |
162 | .description a {
163 | padding: 1rem;
164 | }
165 |
166 | .description p,
167 | .description div {
168 | display: flex;
169 | justify-content: center;
170 | position: fixed;
171 | width: 100%;
172 | }
173 |
174 | .description p {
175 | align-items: center;
176 | inset: 0 0 auto;
177 | padding: 2rem 1rem 1.4rem;
178 | border-radius: 0;
179 | border: none;
180 | border-bottom: 1px solid rgba(var(--callout-border-rgb), 0.25);
181 | background: linear-gradient(
182 | to bottom,
183 | rgba(var(--background-start-rgb), 1),
184 | rgba(var(--callout-rgb), 0.5)
185 | );
186 | background-clip: padding-box;
187 | backdrop-filter: blur(24px);
188 | }
189 |
190 | .description div {
191 | align-items: flex-end;
192 | pointer-events: none;
193 | inset: auto 0 0;
194 | padding: 2rem;
195 | height: 200px;
196 | background: linear-gradient(
197 | to bottom,
198 | transparent 0%,
199 | rgb(var(--background-end-rgb)) 40%
200 | );
201 | z-index: 1;
202 | }
203 | }
204 |
205 | /* Tablet and Smaller Desktop */
206 | @media (min-width: 701px) and (max-width: 1120px) {
207 | .grid {
208 | grid-template-columns: repeat(2, 50%);
209 | }
210 | }
211 |
212 | @media (prefers-color-scheme: dark) {
213 | .vercelLogo {
214 | filter: invert(1);
215 | }
216 |
217 | .logo {
218 | filter: invert(1) drop-shadow(0 0 0.3rem #ffffff70);
219 | }
220 | }
221 |
222 | @keyframes rotate {
223 | from {
224 | transform: rotate(360deg);
225 | }
226 | to {
227 | transform: rotate(0deg);
228 | }
229 | }
230 |
--------------------------------------------------------------------------------
/db/2_data.sql:
--------------------------------------------------------------------------------
1 | -- PostgreSQL database dump
2 | --
3 |
4 | -- Dumped from database version 15.2
5 | -- Dumped by pg_dump version 15.2 (Homebrew)
6 |
7 | SET statement_timeout = 0;
8 | SET lock_timeout = 0;
9 | SET idle_in_transaction_session_timeout = 0;
10 | SET client_encoding = 'UTF8';
11 | SET standard_conforming_strings = on;
12 | SELECT pg_catalog.set_config('search_path', '', false);
13 | SET check_function_bodies = false;
14 | SET xmloption = content;
15 | SET client_min_messages = warning;
16 | SET row_security = off;
17 |
18 | \connect app_db
19 |
20 | --
21 | -- Data for Name: tenants; Type: TABLE DATA; Schema: public; Owner: postgres
22 | --
23 |
24 | SET SESSION AUTHORIZATION DEFAULT;
25 |
26 | ALTER TABLE public.tenants DISABLE TRIGGER ALL;
27 |
28 | INSERT INTO public.tenants (id, display_name, is_admin) VALUES (1, 'user tenant 1', false);
29 | INSERT INTO public.tenants (id, display_name, is_admin) VALUES (2, 'user tenant 2', false);
30 | INSERT INTO public.tenants (id, display_name, is_admin) VALUES (3, 'user tenant 3', false);
31 | INSERT INTO public.tenants (id, display_name, is_admin) VALUES (4, 'user tenant 4', false);
32 | INSERT INTO public.tenants (id, display_name, is_admin) VALUES (5, 'user tenant 5', false);
33 | INSERT INTO public.tenants (id, display_name, is_admin) VALUES (6, 'admin tenant', true);
34 |
35 |
36 | ALTER TABLE public.tenants ENABLE TRIGGER ALL;
37 |
38 | --
39 | -- Data for Name: patients; Type: TABLE DATA; Schema: public; Owner: postgres
40 | --
41 |
42 | ALTER TABLE public.patients DISABLE TRIGGER ALL;
43 |
44 | INSERT INTO public.patients (id, tenant_id, first_name, last_name, dob) VALUES (1, 1, 'John', 'Doe', '1984-02-11');
45 | INSERT INTO public.patients (id, tenant_id, first_name, last_name, dob) VALUES (2, 1, 'Jim', 'Doe', '1984-02-11');
46 | INSERT INTO public.patients (id, tenant_id, first_name, last_name, dob) VALUES (3, 1, 'Bob', 'Doe', '1992-05-13');
47 | INSERT INTO public.patients (id, tenant_id, first_name, last_name, dob) VALUES (4, 1, 'Jerry', 'Doe', '1984-02-11');
48 | INSERT INTO public.patients (id, tenant_id, first_name, last_name, dob) VALUES (5, 1, 'Fran', 'Doe', '1984-02-11');
49 | INSERT INTO public.patients (id, tenant_id, first_name, last_name, dob) VALUES (6, 2, 'John', 'Doe', '1992-05-13');
50 | INSERT INTO public.patients (id, tenant_id, first_name, last_name, dob) VALUES (7, 2, 'James', 'Doe', '1984-02-11');
51 | INSERT INTO public.patients (id, tenant_id, first_name, last_name, dob) VALUES (8, 2, 'Josh', 'Doe', '1984-02-11');
52 | INSERT INTO public.patients (id, tenant_id, first_name, last_name, dob) VALUES (9, 2, 'Harry', 'Doe', '1984-02-11');
53 | INSERT INTO public.patients (id, tenant_id, first_name, last_name, dob) VALUES (10, 2, 'Mary', 'Doe', '1992-05-13');
54 | INSERT INTO public.patients (id, tenant_id, first_name, last_name, dob) VALUES (11, 3, 'John', 'Doe', '1984-02-11');
55 | INSERT INTO public.patients (id, tenant_id, first_name, last_name, dob) VALUES (12, 3, 'Jeoffrey', 'Doe', '1984-02-11');
56 | INSERT INTO public.patients (id, tenant_id, first_name, last_name, dob) VALUES (13, 3, 'Max', 'Doe', '1984-02-11');
57 | INSERT INTO public.patients (id, tenant_id, first_name, last_name, dob) VALUES (14, 3, 'Min', 'Doe', '1992-05-13');
58 | INSERT INTO public.patients (id, tenant_id, first_name, last_name, dob) VALUES (15, 3, 'Patronius', 'Doe', '1984-02-11');
59 | INSERT INTO public.patients (id, tenant_id, first_name, last_name, dob) VALUES (16, 4, 'John', 'Doe', '1992-05-13');
60 | INSERT INTO public.patients (id, tenant_id, first_name, last_name, dob) VALUES (17, 4, 'Jane', 'Doe', '1992-05-13');
61 | INSERT INTO public.patients (id, tenant_id, first_name, last_name, dob) VALUES (18, 4, 'Homer', 'Doe', '1992-05-13');
62 | INSERT INTO public.patients (id, tenant_id, first_name, last_name, dob) VALUES (19, 4, 'Maggie', 'Doe', '1992-05-13');
63 | INSERT INTO public.patients (id, tenant_id, first_name, last_name, dob) VALUES (20, 4, 'Bart', 'Doe', '1992-05-13');
64 | INSERT INTO public.patients (id, tenant_id, first_name, last_name, dob) VALUES (21, 5, 'John', 'Doe', '1984-02-11');
65 | INSERT INTO public.patients (id, tenant_id, first_name, last_name, dob) VALUES (22, 5, 'Walker', 'Doe', '1992-05-13');
66 | INSERT INTO public.patients (id, tenant_id, first_name, last_name, dob) VALUES (23, 5, 'Yeezy', 'Doe', '1984-02-11');
67 | INSERT INTO public.patients (id, tenant_id, first_name, last_name, dob) VALUES (24, 5, 'Puff Daddy', 'Doe', '1984-02-11');
68 | INSERT INTO public.patients (id, tenant_id, first_name, last_name, dob) VALUES (25, 5, 'The Rock', 'Doe', '1992-05-13');
69 |
70 |
71 |
72 | ALTER TABLE public.patients ENABLE TRIGGER ALL;
73 |
74 | --
75 | -- Data for Name: users; Type: TABLE DATA; Schema: public; Owner: postgres
76 | --
77 |
78 | ALTER TABLE public.users DISABLE TRIGGER ALL;
79 |
80 | INSERT INTO public.users (id, tenant_id, user_name, password) VALUES (1, 1, 't1 user1', '$2b$10$gra37ECOljK.6udDxfwAOOTSyeQSbo9I0zS6l6NoMR1mbE.9T.jF2');
81 | INSERT INTO public.users (id, tenant_id, user_name, password) VALUES (2, 1, 't1 user2', '$2b$10$gra37ECOljK.6udDxfwAOOTSyeQSbo9I0zS6l6NoMR1mbE.9T.jF2');
82 | INSERT INTO public.users (id, tenant_id, user_name, password) VALUES (3, 2, 't2 user1', '$2b$10$gra37ECOljK.6udDxfwAOOTSyeQSbo9I0zS6l6NoMR1mbE.9T.jF2');
83 | INSERT INTO public.users (id, tenant_id, user_name, password) VALUES (4, 2, 't2 user2', '$2b$10$gra37ECOljK.6udDxfwAOOTSyeQSbo9I0zS6l6NoMR1mbE.9T.jF2');
84 | INSERT INTO public.users (id, tenant_id, user_name, password) VALUES (5, 3, 't3 user1', '$2b$10$gra37ECOljK.6udDxfwAOOTSyeQSbo9I0zS6l6NoMR1mbE.9T.jF2');
85 | INSERT INTO public.users (id, tenant_id, user_name, password) VALUES (6, 3, 't3 user2', '$2b$10$gra37ECOljK.6udDxfwAOOTSyeQSbo9I0zS6l6NoMR1mbE.9T.jF2');
86 | INSERT INTO public.users (id, tenant_id, user_name, password) VALUES (7, 4, 't4 user1', '$2b$10$gra37ECOljK.6udDxfwAOOTSyeQSbo9I0zS6l6NoMR1mbE.9T.jF2');
87 | INSERT INTO public.users (id, tenant_id, user_name, password) VALUES (8, 4, 't4 user2', '$2b$10$gra37ECOljK.6udDxfwAOOTSyeQSbo9I0zS6l6NoMR1mbE.9T.jF2');
88 | INSERT INTO public.users (id, tenant_id, user_name, password) VALUES (9, 5, 't5 user1', '$2b$10$gra37ECOljK.6udDxfwAOOTSyeQSbo9I0zS6l6NoMR1mbE.9T.jF2');
89 | INSERT INTO public.users (id, tenant_id, user_name, password) VALUES (10, 5, 't5 user2', '$2b$10$gra37ECOljK.6udDxfwAOOTSyeQSbo9I0zS6l6NoMR1mbE.9T.jF2');
90 | INSERT INTO public.users (id, tenant_id, user_name, password) VALUES (11, 6, 't6 admin', '$2b$10$YJ3paQsDvg7ykcUEB6kmQetsGcaRfPzTwvpOEQSc565epW.P82lMO');
91 |
92 |
93 | ALTER TABLE public.users ENABLE TRIGGER ALL;
94 |
95 | --
96 | -- Name: patients_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres
97 | --
98 |
99 | SELECT pg_catalog.setval('public.patients_id_seq', 25, true);
100 |
101 |
102 | --
103 | -- Name: tenants_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres
104 | --
105 |
106 | SELECT pg_catalog.setval('public.tenants_id_seq', 8, true);
107 |
108 |
109 | --
110 | -- Name: users_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres
111 | --
112 |
113 | SELECT pg_catalog.setval('public.users_id_seq', 16, true);
114 |
115 |
116 | --
117 | -- PostgreSQL database dump complete
118 | --
119 |
120 |
--------------------------------------------------------------------------------
/POSTGRES.md:
--------------------------------------------------------------------------------
1 | # Database Overview
2 |
3 | Here is the database service definition from docker-compose.yml:
4 |
5 | ```YAML
6 | db:
7 | <<: *defaults
8 | image: postgres:15.3-alpine3.17
9 | ports:
10 | - '5432:5432'
11 | volumes:
12 | - tenancy_example_db_data:/var/lib/postgresql/data
13 | - type: bind
14 | source: ./db
15 | target: /docker-entrypoint-initdb.d
16 | environment:
17 | POSTGRES_PASSWORD: 07f019e661d8ca48c47bdffd255b12fe
18 | ```
19 |
20 | The bind mount maps the db directory on the host to the docker entrypoint directory.
21 |
22 | The two SQL files in ./db (1_schema.sql and 2_data.sql) are executed when the container is created, creating the schema and populating it with test data.
23 |
24 | ### [1_schema.sql](db/1_schema.sql)
25 |
26 | ###### The tables:
27 |
28 | ```sql
29 | create table if not exists public.tenants
30 | (
31 | id bigserial primary key,
32 | display_name varchar,
33 | is_admin boolean default false
34 | );
35 |
36 | create table if not exists public.users
37 | (
38 | id bigserial primary key,
39 | tenant_id bigint
40 | constraint users_tenants_id_fk
41 | references public.tenants
42 | on delete cascade,
43 | user_name varchar,
44 | password varchar
45 | );
46 |
47 | create table if not exists public.patients
48 | (
49 | id bigserial primary key,
50 | tenant_id bigint
51 | constraint patients_tenants_id_fk
52 | references public.tenants
53 | on delete cascade,
54 | first_name varchar,
55 | last_name varchar,
56 | dob date
57 | );
58 | ```
59 |
60 | RLS function:
61 |
62 | ```sql
63 | create or replace function fn.tenant_data_rls_check(row_tenant_id bigint) returns boolean
64 | language plpgsql
65 | as
66 | $$
67 | BEGIN
68 |
69 | IF current_setting('tenancy.bypass')::text = '1' THEN
70 | return true;
71 | end if;
72 |
73 | IF current_setting('tenancy.tenant_id')::integer = row_tenant_id THEN
74 | return true;
75 | end if;
76 |
77 | return false;
78 | END;
79 | $$;
80 | ```
81 |
82 | tenant_data_rls_check takes a single argument, the value of 'tenant_id' (or 'id' for the tenants table) for the queried/mutated row.
83 |
84 | Looking at the function body, you'll see that first it checks if the session value 'tenancy.bypass' is equal to '1', and if so it returns true, allowing the operation.
85 |
86 | Next, it compares the session value 'tenancy.tenant_id' with the tenant_id value for the row. If equal, it allows the operation, otherwise the operation fails.
87 |
88 | ###### Policies:
89 |
90 | ```sql
91 | create policy tenancy_policy on public.tenants
92 | as permissive
93 | for all
94 | using (fn.tenant_data_rls_check(id) = true)
95 | with check (fn.tenant_data_rls_check(id) = true);
96 |
97 | create policy tenancy_policy on public.users
98 | as permissive
99 | for all
100 | using (fn.tenant_data_rls_check(tenant_id) = true)
101 | with check (fn.tenant_data_rls_check(tenant_id) = true);
102 |
103 | create policy tenancy_policy on public.patients
104 | as permissive
105 | for all
106 | using (fn.tenant_data_rls_check(tenant_id) = true)
107 | with check (fn.tenant_data_rls_check(tenant_id) = true);
108 | ```
109 |
110 | Note that 1_schema.sql enables these policies at the end of the script, so you don't need to do that yourself.
111 |
112 | ### [2_data.sql](db/2_data.sql)
113 |
114 | This SQL file populates the database with the following test data:
115 |
116 | ## Tenants Test Data
117 |
118 |
119 | | id | display_name | is_admin |
120 | | 1 | user tenant 1 | false |
121 | | 2 | user tenant 2 | false |
122 | | 3 | user tenant 3 | false |
123 | | 4 | user tenant 4 | false |
124 | | 5 | user tenant 5 | false |
125 | | 6 | admin tenant | true |
126 |
127 |
128 | ## Users Test Data
129 |
130 |
131 | | id | tenant_id | user_name | password |
132 | | 1 | 1 | t1 user1 | $2b$10$gra37ECOljK.6udDxfwAOOTSyeQSbo9I0zS6l6NoMR1mbE.9T.jF2 |
133 | | 2 | 1 | t1 user2 | $2b$10$gra37ECOljK.6udDxfwAOOTSyeQSbo9I0zS6l6NoMR1mbE.9T.jF2 |
134 | | 3 | 2 | t2 user1 | $2b$10$gra37ECOljK.6udDxfwAOOTSyeQSbo9I0zS6l6NoMR1mbE.9T.jF2 |
135 | | 4 | 2 | t2 user2 | $2b$10$gra37ECOljK.6udDxfwAOOTSyeQSbo9I0zS6l6NoMR1mbE.9T.jF2 |
136 | | 5 | 3 | t3 user1 | $2b$10$gra37ECOljK.6udDxfwAOOTSyeQSbo9I0zS6l6NoMR1mbE.9T.jF2 |
137 | | 6 | 3 | t3 user2 | $2b$10$gra37ECOljK.6udDxfwAOOTSyeQSbo9I0zS6l6NoMR1mbE.9T.jF2 |
138 | | 7 | 4 | t4 user1 | $2b$10$gra37ECOljK.6udDxfwAOOTSyeQSbo9I0zS6l6NoMR1mbE.9T.jF2 |
139 | | 8 | 4 | t4 user2 | $2b$10$gra37ECOljK.6udDxfwAOOTSyeQSbo9I0zS6l6NoMR1mbE.9T.jF2 |
140 | | 9 | 5 | t5 user1 | $2b$10$gra37ECOljK.6udDxfwAOOTSyeQSbo9I0zS6l6NoMR1mbE.9T.jF2 |
141 | | 10 | 5 | t5 user2 | $2b$10$gra37ECOljK.6udDxfwAOOTSyeQSbo9I0zS6l6NoMR1mbE.9T.jF2 |
142 | | 11 | 6 | t6 admin | $2b$10$YJ3paQsDvg7ykcUEB6kmQetsGcaRfPzTwvpOEQSc565epW.P82lMO |
143 |
144 |
145 | ## Patients Test Data
146 |
147 |
148 | | id | tenant_id | first_name | last_name | dob |
149 | | 1 | 1 | John | Doe | 1984-02-11 |
150 | | 2 | 1 | Jim | Doe | 1984-02-11 |
151 | | 3 | 1 | Bob | Doe | 1992-05-13 |
152 | | 4 | 1 | Jerry | Doe | 1984-02-11 |
153 | | 5 | 1 | Fran | Doe | 1984-02-11 |
154 | | 6 | 2 | John | Doe | 1992-05-13 |
155 | | 7 | 2 | James | Doe | 1984-02-11 |
156 | | 8 | 2 | Josh | Doe | 1984-02-11 |
157 | | 9 | 2 | Harry | Doe | 1984-02-11 |
158 | | 10 | 2 | Mary | Doe | 1992-05-13 |
159 | | 11 | 3 | John | Doe | 1984-02-11 |
160 | | 12 | 3 | Jeoffrey | Doe | 1984-02-11 |
161 | | 13 | 3 | Max | Doe | 1984-02-11 |
162 | | 14 | 3 | Min | Doe | 1992-05-13 |
163 | | 15 | 3 | Patronius | Doe | 1984-02-11 |
164 | | 16 | 4 | John | Doe | 1992-05-13 |
165 | | 17 | 4 | Jane | Doe | 1992-05-13 |
166 | | 18 | 4 | Homer | Doe | 1992-05-13 |
167 | | 19 | 4 | Maggie | Doe | 1992-05-13 |
168 | | 20 | 4 | Bart | Doe | 1992-05-13 |
169 | | 21 | 5 | John | Doe | 1984-02-11 |
170 | | 22 | 5 | Walker | Doe | 1992-05-13 |
171 | | 23 | 5 | Yeezy | Doe | 1984-02-11 |
172 | | 24 | 5 | Puff Daddy | Doe | 1984-02-11 |
173 | | 25 | 5 | The Rock | Doe | 1992-05-13 |
174 |
175 |
--------------------------------------------------------------------------------
/apps/frontend/tests/lib/data.ts:
--------------------------------------------------------------------------------
1 | export const adminData = [
2 | {
3 | id: 1,
4 | tenantId: 1,
5 | firstName: "John",
6 | lastName: "Doe",
7 | dob: "1984-02-11T00:00:00.000Z",
8 | },
9 | {
10 | id: 2,
11 | tenantId: 1,
12 | firstName: "Jim",
13 | lastName: "Doe",
14 | dob: "1984-02-11T00:00:00.000Z",
15 | },
16 | {
17 | id: 3,
18 | tenantId: 1,
19 | firstName: "Bob",
20 | lastName: "Doe",
21 | dob: "1992-05-13T00:00:00.000Z",
22 | },
23 | {
24 | id: 4,
25 | tenantId: 1,
26 | firstName: "Jerry",
27 | lastName: "Doe",
28 | dob: "1984-02-11T00:00:00.000Z",
29 | },
30 | {
31 | id: 5,
32 | tenantId: 1,
33 | firstName: "Fran",
34 | lastName: "Doe",
35 | dob: "1984-02-11T00:00:00.000Z",
36 | },
37 | {
38 | id: 6,
39 | tenantId: 2,
40 | firstName: "John",
41 | lastName: "Doe",
42 | dob: "1992-05-13T00:00:00.000Z",
43 | },
44 | {
45 | id: 7,
46 | tenantId: 2,
47 | firstName: "James",
48 | lastName: "Doe",
49 | dob: "1984-02-11T00:00:00.000Z",
50 | },
51 | {
52 | id: 8,
53 | tenantId: 2,
54 | firstName: "Josh",
55 | lastName: "Doe",
56 | dob: "1984-02-11T00:00:00.000Z",
57 | },
58 | {
59 | id: 9,
60 | tenantId: 2,
61 | firstName: "Harry",
62 | lastName: "Doe",
63 | dob: "1984-02-11T00:00:00.000Z",
64 | },
65 | {
66 | id: 10,
67 | tenantId: 2,
68 | firstName: "Mary",
69 | lastName: "Doe",
70 | dob: "1992-05-13T00:00:00.000Z",
71 | },
72 | {
73 | id: 11,
74 | tenantId: 3,
75 | firstName: "John",
76 | lastName: "Doe",
77 | dob: "1984-02-11T00:00:00.000Z",
78 | },
79 | {
80 | id: 12,
81 | tenantId: 3,
82 | firstName: "Jeoffrey",
83 | lastName: "Doe",
84 | dob: "1984-02-11T00:00:00.000Z",
85 | },
86 | {
87 | id: 13,
88 | tenantId: 3,
89 | firstName: "Max",
90 | lastName: "Doe",
91 | dob: "1984-02-11T00:00:00.000Z",
92 | },
93 | {
94 | id: 14,
95 | tenantId: 3,
96 | firstName: "Min",
97 | lastName: "Doe",
98 | dob: "1992-05-13T00:00:00.000Z",
99 | },
100 | {
101 | id: 15,
102 | tenantId: 3,
103 | firstName: "Patronius",
104 | lastName: "Doe",
105 | dob: "1984-02-11T00:00:00.000Z",
106 | },
107 | {
108 | id: 16,
109 | tenantId: 4,
110 | firstName: "John",
111 | lastName: "Doe",
112 | dob: "1992-05-13T00:00:00.000Z",
113 | },
114 | {
115 | id: 17,
116 | tenantId: 4,
117 | firstName: "Jane",
118 | lastName: "Doe",
119 | dob: "1992-05-13T00:00:00.000Z",
120 | },
121 | {
122 | id: 18,
123 | tenantId: 4,
124 | firstName: "Homer",
125 | lastName: "Doe",
126 | dob: "1992-05-13T00:00:00.000Z",
127 | },
128 | {
129 | id: 19,
130 | tenantId: 4,
131 | firstName: "Maggie",
132 | lastName: "Doe",
133 | dob: "1992-05-13T00:00:00.000Z",
134 | },
135 | {
136 | id: 20,
137 | tenantId: 4,
138 | firstName: "Bart",
139 | lastName: "Doe",
140 | dob: "1992-05-13T00:00:00.000Z",
141 | },
142 | {
143 | id: 21,
144 | tenantId: 5,
145 | firstName: "John",
146 | lastName: "Doe",
147 | dob: "1984-02-11T00:00:00.000Z",
148 | },
149 | {
150 | id: 22,
151 | tenantId: 5,
152 | firstName: "Walker",
153 | lastName: "Doe",
154 | dob: "1992-05-13T00:00:00.000Z",
155 | },
156 | {
157 | id: 23,
158 | tenantId: 5,
159 | firstName: "Yeezy",
160 | lastName: "Doe",
161 | dob: "1984-02-11T00:00:00.000Z",
162 | },
163 | {
164 | id: 24,
165 | tenantId: 5,
166 | firstName: "Puff Daddy",
167 | lastName: "Doe",
168 | dob: "1984-02-11T00:00:00.000Z",
169 | },
170 | {
171 | id: 25,
172 | tenantId: 5,
173 | firstName: "The Rock",
174 | lastName: "Doe",
175 | dob: "1992-05-13T00:00:00.000Z",
176 | },
177 | ];
178 |
179 | export const userData = {
180 | '1': [{ "id": 1, "tenantId": 1, "firstName": "John", "lastName": "Doe", "dob": "1984-02-11T00:00:00.000Z" }, { "id": 2, "tenantId": 1, "firstName": "Jim", "lastName": "Doe", "dob": "1984-02-11T00:00:00.000Z" }, { "id": 3, "tenantId": 1, "firstName": "Bob", "lastName": "Doe", "dob": "1992-05-13T00:00:00.000Z" }, { "id": 4, "tenantId": 1, "firstName": "Jerry", "lastName": "Doe", "dob": "1984-02-11T00:00:00.000Z" }, { "id": 5, "tenantId": 1, "firstName": "Fran", "lastName": "Doe", "dob": "1984-02-11T00:00:00.000Z" }],
181 | '2': [{ "id": 6, "tenantId": 2, "firstName": "John", "lastName": "Doe", "dob": "1992-05-13T00:00:00.000Z" }, { "id": 7, "tenantId": 2, "firstName": "James", "lastName": "Doe", "dob": "1984-02-11T00:00:00.000Z" }, { "id": 8, "tenantId": 2, "firstName": "Josh", "lastName": "Doe", "dob": "1984-02-11T00:00:00.000Z" }, { "id": 9, "tenantId": 2, "firstName": "Harry", "lastName": "Doe", "dob": "1984-02-11T00:00:00.000Z" }, { "id": 10, "tenantId": 2, "firstName": "Mary", "lastName": "Doe", "dob": "1992-05-13T00:00:00.000Z" }],
182 | '3': [{ "id": 11, "tenantId": 3, "firstName": "John", "lastName": "Doe", "dob": "1984-02-11T00:00:00.000Z" }, { "id": 12, "tenantId": 3, "firstName": "Jeoffrey", "lastName": "Doe", "dob": "1984-02-11T00:00:00.000Z" }, { "id": 13, "tenantId": 3, "firstName": "Max", "lastName": "Doe", "dob": "1984-02-11T00:00:00.000Z" }, { "id": 14, "tenantId": 3, "firstName": "Min", "lastName": "Doe", "dob": "1992-05-13T00:00:00.000Z" }, { "id": 15, "tenantId": 3, "firstName": "Patronius", "lastName": "Doe", "dob": "1984-02-11T00:00:00.000Z" }],
183 | '4': [{ "id": 16, "tenantId": 4, "firstName": "John", "lastName": "Doe", "dob": "1992-05-13T00:00:00.000Z" }, { "id": 17, "tenantId": 4, "firstName": "Jane", "lastName": "Doe", "dob": "1992-05-13T00:00:00.000Z" }, { "id": 18, "tenantId": 4, "firstName": "Homer", "lastName": "Doe", "dob": "1992-05-13T00:00:00.000Z" }, { "id": 19, "tenantId": 4, "firstName": "Maggie", "lastName": "Doe", "dob": "1992-05-13T00:00:00.000Z" }, { "id": 20, "tenantId": 4, "firstName": "Bart", "lastName": "Doe", "dob": "1992-05-13T00:00:00.000Z" }],
184 | '5': [{ "id": 21, "tenantId": 5, "firstName": "John", "lastName": "Doe", "dob": "1984-02-11T00:00:00.000Z" }, { "id": 22, "tenantId": 5, "firstName": "Walker", "lastName": "Doe", "dob": "1992-05-13T00:00:00.000Z" }, { "id": 23, "tenantId": 5, "firstName": "Yeezy", "lastName": "Doe", "dob": "1984-02-11T00:00:00.000Z" }, { "id": 24, "tenantId": 5, "firstName": "Puff Daddy", "lastName": "Doe", "dob": "1984-02-11T00:00:00.000Z" }, { "id": 25, "tenantId": 5, "firstName": "The Rock", "lastName": "Doe", "dob": "1992-05-13T00:00:00.000Z" }]
185 | };
186 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # nestjs-prisma-postgres-tenancy
2 |
3 | Full Stack Multi-Tenant Example App in NestJS using Prisma and PostgreSQL. Demonstrates Request Scoped, Durable Request Scoped, and AsyncLocalStorage based implementations
4 |
5 | ### [Postgres Database Info](POSTGRES.md)
6 |
7 | ### [Nginx Reverse-Proxy Info](NGINX.md)
8 |
9 | ### [Authentication and Session Info](AUTH.md)
10 |
11 | #
12 |
13 | ## Branches
14 |
15 | #### branch **[main](https://github.com/moofoo/nestjs-prisma-postgres-tenancy)** - Request scoped providers
16 |
17 | #### branch **[durable](https://github.com/moofoo/nestjs-prisma-postgres-tenancy/tree/durable)** - Durable request scoped providers (scoped to tenant id)
18 |
19 | #### branch **[async-hooks](https://github.com/moofoo/nestjs-prisma-postgres-tenancy/tree/async-hooks)** - Singleton providers using AsyncLocalStorage to manage session state per request
20 |
21 | #### branch **[multi](https://github.com/moofoo/nestjs-prisma-postgres-tenancy/tree/multi)** - Request scoped providers, allows users to belong to multiple tenants
22 |
23 | #
24 |
25 | You will probably need to clear your browser cache when switching to and from the 'multi' branch
26 |
27 | ## Initial Setup
28 |
29 | (make sure ports 5432 and 80 are free and docker is running)
30 |
31 | ```console
32 | yarn setup
33 | ```
34 |
35 | This script performs the following:
36 |
37 | - pull node, nginx, postgres and playwright images used by app
38 | - create tenancy_example_network network (needs to be external to run playwright tests)
39 | - create tenancy_example_db_data volume
40 | - create custom-node:latest image (see [Dockerfile.node](dockerfiles/Dockerfile.node))
41 | - start database service (this creates schema and inserts test data, see [db directory](db))
42 | - run 'yarn' command
43 | - build prismaclient and session-opts packages on host (see [packages directory](packages))
44 | - build frontend and backend images (see [apps directory](apps))
45 | - stop compose project (stops db service)
46 |
47 | see [setup.sh](setup.sh)
48 |
49 | ## Running
50 |
51 | ```console
52 | docker compose up -d
53 | ```
54 |
55 | App should then be accessible at http://localhost.
56 |
57 | Login form shows instructions for signing in as different tenants/users
58 |
59 | For example, to log in as user 2 of tenant 3:
60 |
61 | - username: **t3 user2**
62 | - password: **user**
63 |
64 | Admin login:
65 |
66 | - username: **t6 admin**
67 | - password: **admin**
68 |
69 | Once logged in you will see data from the 'Patients' table, which will be filtered as per the Postgres RLS policy.
70 |
71 | You can see Prisma Metrics json output at http://localhost/nest/stats
72 |
73 | ## Tests
74 |
75 | While compose project is running,
76 |
77 | ```console
78 | yarn test
79 | ```
80 |
81 | see [test.sh](test.sh)
82 |
83 | This will run playwright with the following playwright.config.ts:
84 |
85 | ```typescript
86 | import {defineConfig} from "@playwright/test";
87 |
88 | export default defineConfig({
89 | testDir: "./tests",
90 | fullyParallel: true,
91 | workers: 50,
92 | repeatEach: 50,
93 | reporter: "html",
94 | use: {
95 | trace: "on-first-retry",
96 | bypassCSP: true,
97 | },
98 | });
99 | ```
100 |
101 | The `50` value for `repeatEach` and `worker` means the test (there's only one) runs 50 times in parallel. The test simply authenticates with the backend using a randomly chosen tenant/user and checks the validity of the Patients json returned by GET `http://localhost/nest/patients`.
102 |
103 | #
104 |
105 | ## Notes on branches and backend log output
106 |
107 | If you take a look at the backend Prisma Tenancy Service implementation, you'll see that the useFactory functions for the Bypass and Tenant Providers and the constructor of the Prisma Tenancy Service have console.log statements, to indicate when they are executed / instantiated.
108 |
109 | - [tenant.ts](apps/backend/src/prisma-tenancy/client-extensions/tenant.ts)
110 | - [bypass.ts](apps/backend/src/prisma-tenancy/client-extensions/bypass.ts)
111 | - [prisma-tenancy.service.ts](apps/backend/src/prisma-tenancy/prisma-tenancy.service.ts)
112 |
113 | When and where those console.logs appear in the backend logs depends on how the Providers are scoped (and therefore will vary depending on which branch you have checked out)
114 |
115 | For each branch, you should see `Bypass Client useFactory called` along with the usual NestJS initialization log output, since that provider is not request scoped (it doesn't need to know the tenancy of the connecting user).
116 |
117 | The `main` and `durable` branches will output the following to the backend logs when a user logs in or Patients data is requested:
118 |
119 | ```console
120 | Tenant Client useFactory called
121 | PrismaTenancyService constructer executed
122 | ```
123 |
124 | For the `main` branch (request scoped provider), the above should appear in the logs for every request.
125 |
126 | For the `durable` branch, (durable request scoped provider, based on tenant id), you should see the above only once for each connecting tenant.
127 |
128 | With the `async-hooks` branch, you should see the following along with the usual NestJS initialization output. There should be no additional log output for each request:
129 |
130 | ```console
131 | Tenant Client useFactory called
132 | Bypass Client useFactory called
133 | PrismaTenancyService constructer executed
134 | ```
135 |
136 | ## Docker Notes
137 |
138 | Follow these steps when adding app dependencies:
139 |
140 | #### 1 - Add the dependencies
141 |
142 | ```
143 | yarn workspace add APP_NAME DEPENDENCY (or yarn workspace add -D ... for dev deps)
144 | ```
145 |
146 | for example,
147 |
148 | ```
149 | yarn workspace backend add bcrypt
150 | ```
151 |
152 | #### 2 - Run docker compose up -d --build --force-recreate for service
153 |
154 | ```
155 | docker compose up -d -V --force-recreate --build backend
156 | ```
157 |
158 | #### 3 - Restart the project (so the nginx service doesn't lose the plot)
159 |
160 | ```
161 | docker compose restart
162 | ```
163 |
164 | ### Prisma Resources
165 |
166 | - [Client Extensions](https://www.prisma.io/docs/concepts/components/prisma-client/client-extensions)
167 | - [Client Extensions RLS Example](https://github.com/prisma/prisma-client-extensions/tree/main/row-level-security)
168 | - [Query Extension](https://www.prisma.io/docs/concepts/components/prisma-client/client-extensions/query)
169 | - [Transactions and batch queries](https://www.prisma.io/docs/concepts/components/prisma-client/transactions)
170 | - [Raw database access](https://www.prisma.io/docs/concepts/components/prisma-client/raw-database-access#executeraw)
171 |
172 | ### Postgres Resources
173 |
174 | - [Row Security Policies](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)
175 | - [System Administration Functions (set_config)](https://www.postgresql.org/docs/8.0/functions-admin.html)
176 |
177 | ### NestJS Resources
178 |
179 | - [Custom Factory Provider](https://docs.nestjs.com/fundamentals/custom-providers#factory-providers-usefactory)
180 | - [Recipe: AsyncLocalStorage](https://docs.nestjs.com/recipes/async-local-storage)
181 | - [Recipe: Prisma](https://docs.nestjs.com/recipes/prisma)
182 | - [nestjs-cls](https://github.com/Papooch/nestjs-cls)
183 | - [nestjs-prisma](https://nestjs-prisma.dev/)
184 |
185 | ### NGINX
186 |
187 | - [Beginner's Guide](http://nginx.org/en/docs/beginners_guide.html)
188 | - [Using the Forwarded Header](https://www.nginx.com/resources/wiki/start/topics/examples/forwarded/)
189 | - [Full Config Example](https://www.nginx.com/resources/wiki/start/topics/examples/full/)
190 |
191 | #
192 |
193 | #
194 |
195 | **Please be aware that this is a "toy" app meant to demonstrate the given programming concepts/techniques. It does **NOT** implement security best-practices and isn't intended to be representative of production-ready code**
196 |
--------------------------------------------------------------------------------