├── .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 | 13 | 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 | 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 |
onSubmitOk(data) 58 | )} 59 | > 60 | 61 | 62 | 70 | 71 | 72 | 73 | 76 | 77 |
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): <i>t3 user2</i>, password <i>user</i> 85 | Example (tenant 1 user 1): <i>t1 user1</i>, password <i>user</i> 86 | Admin login: <i>t6 admin</i>, password <i>admin</i> 87 | 88 | 89 |
90 |
91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /apps/backend/README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 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 | NPM Version 11 | Package License 12 | NPM Downloads 13 | CircleCI 14 | Coverage 15 | Discord 16 | Backers on Open Collective 17 | Sponsors on Open Collective 18 | 19 | Support us 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 | 120 | 121 | 122 | 123 | 124 | 125 | 126 |
iddisplay_nameis_admin
1user tenant 1false
2user tenant 2false
3user tenant 3false
4user tenant 4false
5user tenant 5false
6admin tenanttrue
127 | 128 | ## Users Test Data 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 |
idtenant_iduser_namepassword
11t1 user1$2b$10$gra37ECOljK.6udDxfwAOOTSyeQSbo9I0zS6l6NoMR1mbE.9T.jF2
21t1 user2$2b$10$gra37ECOljK.6udDxfwAOOTSyeQSbo9I0zS6l6NoMR1mbE.9T.jF2
32t2 user1$2b$10$gra37ECOljK.6udDxfwAOOTSyeQSbo9I0zS6l6NoMR1mbE.9T.jF2
42t2 user2$2b$10$gra37ECOljK.6udDxfwAOOTSyeQSbo9I0zS6l6NoMR1mbE.9T.jF2
53t3 user1$2b$10$gra37ECOljK.6udDxfwAOOTSyeQSbo9I0zS6l6NoMR1mbE.9T.jF2
63t3 user2$2b$10$gra37ECOljK.6udDxfwAOOTSyeQSbo9I0zS6l6NoMR1mbE.9T.jF2
74t4 user1$2b$10$gra37ECOljK.6udDxfwAOOTSyeQSbo9I0zS6l6NoMR1mbE.9T.jF2
84t4 user2$2b$10$gra37ECOljK.6udDxfwAOOTSyeQSbo9I0zS6l6NoMR1mbE.9T.jF2
95t5 user1$2b$10$gra37ECOljK.6udDxfwAOOTSyeQSbo9I0zS6l6NoMR1mbE.9T.jF2
105t5 user2$2b$10$gra37ECOljK.6udDxfwAOOTSyeQSbo9I0zS6l6NoMR1mbE.9T.jF2
116t6 admin$2b$10$YJ3paQsDvg7ykcUEB6kmQetsGcaRfPzTwvpOEQSc565epW.P82lMO
144 | 145 | ## Patients Test Data 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 |
idtenant_idfirst_namelast_namedob
11JohnDoe1984-02-11
21JimDoe1984-02-11
31BobDoe1992-05-13
41JerryDoe1984-02-11
51FranDoe1984-02-11
62JohnDoe1992-05-13
72JamesDoe1984-02-11
82JoshDoe1984-02-11
92HarryDoe1984-02-11
102MaryDoe1992-05-13
113JohnDoe1984-02-11
123JeoffreyDoe1984-02-11
133MaxDoe1984-02-11
143MinDoe1992-05-13
153PatroniusDoe1984-02-11
164JohnDoe1992-05-13
174JaneDoe1992-05-13
184HomerDoe1992-05-13
194MaggieDoe1992-05-13
204BartDoe1992-05-13
215JohnDoe1984-02-11
225WalkerDoe1992-05-13
235YeezyDoe1984-02-11
245Puff DaddyDoe1984-02-11
255The RockDoe1992-05-13
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 | --------------------------------------------------------------------------------