From ebcf71557b07f519679d3aec6b262c66875e4a55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alberto=20Andr=C3=A9?= Date: Sat, 6 Dec 2025 22:11:09 -0300 Subject: [PATCH 1/2] feat: add UserProvider join table for database-agnostic queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create UserProvider entity for user-provider relationship - Replace database-specific array queries with standard JOINs - Add migration with data migration from providerIds array - Add addProviderToUser/removeProviderFromUser helper methods - Keep legacy providerIds for backwards compatibility πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- ...60000000000-create-user-providers-table.ts | 89 ++++++++++++++++++ .../src/entities/user-provider.entity.ts | 69 ++++++++++++++ apps/backend/src/entities/user.entity.ts | 30 ++++++ .../backend/src/modules/users/users.module.ts | 8 +- .../src/modules/users/users.service.ts | 94 ++++++++++++++----- 5 files changed, 267 insertions(+), 23 deletions(-) create mode 100644 apps/backend/src/database/migrations/1760000000000-create-user-providers-table.ts create mode 100644 apps/backend/src/entities/user-provider.entity.ts diff --git a/apps/backend/src/database/migrations/1760000000000-create-user-providers-table.ts b/apps/backend/src/database/migrations/1760000000000-create-user-providers-table.ts new file mode 100644 index 0000000..2485677 --- /dev/null +++ b/apps/backend/src/database/migrations/1760000000000-create-user-providers-table.ts @@ -0,0 +1,89 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateUserProvidersTable1760000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + // Create the user_providers table + await queryRunner.query(` + CREATE TABLE "user_providers" ( + "id" varchar PRIMARY KEY NOT NULL, + "user_id" varchar NOT NULL, + "provider_name" varchar(50) NOT NULL, + "provider_user_id" varchar(255) NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + CONSTRAINT "FK_user_providers_user" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE + ) + `); + + // Create indexes + await queryRunner.query(` + CREATE UNIQUE INDEX "idx_user_providers_lookup" ON "user_providers" ("provider_name", "provider_user_id") + `); + + await queryRunner.query(` + CREATE INDEX "idx_user_providers_user_id" ON "user_providers" ("user_id") + `); + + // Migrate existing provider_ids data to the new table + // This reads the comma-separated provider_ids and inserts them into user_providers + const users = await queryRunner.query(` + SELECT id, provider_ids FROM users WHERE provider_ids IS NOT NULL AND provider_ids != '' + `); + + for (const user of users) { + const providerIds = user.provider_ids + .split(',') + .filter((id: string) => id.trim()); + + for (const providerId of providerIds) { + const trimmed = providerId.trim(); + const separatorIndex = trimmed.indexOf('|'); + + let providerName: string; + let providerUserId: string; + + if (separatorIndex === -1) { + providerName = 'unknown'; + providerUserId = trimmed; + } else { + providerName = trimmed.substring(0, separatorIndex); + providerUserId = trimmed.substring(separatorIndex + 1); + } + + // Generate a UUID for the new record + const uuid = this.generateUUID(); + + await queryRunner.query( + ` + INSERT OR IGNORE INTO "user_providers" ("id", "user_id", "provider_name", "provider_user_id") + VALUES (?, ?, ?, ?) + `, + [uuid, user.id, providerName, providerUserId], + ); + } + } + } + + public async down(queryRunner: QueryRunner): Promise { + // Drop indexes + await queryRunner.query(`DROP INDEX IF EXISTS "idx_user_providers_lookup"`); + await queryRunner.query( + `DROP INDEX IF EXISTS "idx_user_providers_user_id"`, + ); + + // Drop table + await queryRunner.query(`DROP TABLE IF EXISTS "user_providers"`); + } + + private generateUUID(): string { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace( + /[xy]/g, + function (c) { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }, + ); + } +} diff --git a/apps/backend/src/entities/user-provider.entity.ts b/apps/backend/src/entities/user-provider.entity.ts new file mode 100644 index 0000000..213104c --- /dev/null +++ b/apps/backend/src/entities/user-provider.entity.ts @@ -0,0 +1,69 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, + Index, +} from 'typeorm'; +import { User } from './user.entity'; + +/** + * Join table for User to Identity Provider relationship. + * Eliminates database-specific provider ID queries by using + * standard SQL JOINs instead of array contains operations. + * + * Supports multiple identity providers per user (Google, Auth0, GitHub, etc.) + */ +@Entity('user_providers') +@Index('idx_user_providers_lookup', ['providerName', 'providerUserId'], { + unique: true, +}) +@Index('idx_user_providers_user_id', ['userId']) +export class UserProvider { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'user_id' }) + userId: string; + + @Column({ name: 'provider_name', type: 'varchar', length: 50 }) + providerName: string; + + @Column({ name: 'provider_user_id', type: 'varchar', length: 255 }) + providerUserId: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @ManyToOne(() => User, (user) => user.providers, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: User; + + /** + * Parse a combined provider ID like "google|123456" into name and user ID + */ + static parseProviderId(providerId: string): { + providerName: string; + providerUserId: string; + } { + const separatorIndex = providerId.indexOf('|'); + if (separatorIndex === -1) { + // Legacy format or unknown, use full string as ID with 'unknown' provider + return { providerName: 'unknown', providerUserId: providerId }; + } + + return { + providerName: providerId.substring(0, separatorIndex), + providerUserId: providerId.substring(separatorIndex + 1), + }; + } + + /** + * Create a combined provider ID from name and user ID + */ + static toProviderId(providerName: string, providerUserId: string): string { + return `${providerName}|${providerUserId}`; + } +} diff --git a/apps/backend/src/entities/user.entity.ts b/apps/backend/src/entities/user.entity.ts index 1cecd41..e94a6c0 100644 --- a/apps/backend/src/entities/user.entity.ts +++ b/apps/backend/src/entities/user.entity.ts @@ -1,5 +1,6 @@ import { Entity, PrimaryGeneratedColumn, Column, OneToMany, Index } from 'typeorm'; import { UserAccount } from './user-accounts.entity'; +import { UserProvider } from './user-provider.entity'; import { InteractiveEntity } from './base.entity'; @Entity('users') @@ -53,17 +54,46 @@ export class User extends InteractiveEntity { }) userAccounts?: UserAccount[]; + /** + * Identity providers linked to this user (Google, Auth0, etc.) + * This replaces the legacy providerIds array for database-agnostic queries. + */ + @OneToMany(() => UserProvider, (provider) => provider.user) + providers?: UserProvider[]; + + /** + * @deprecated Use providers relation instead. Kept for backwards compatibility. + */ hasProvider(providerId: string): boolean { return this.providerIds.includes(providerId); } + /** + * @deprecated Use UserProvider entity instead. Kept for backwards compatibility. + */ addProvider(providerId: string): void { if (!this.hasProvider(providerId)) { this.providerIds.push(providerId); } } + /** + * @deprecated Use UserProvider entity instead. Kept for backwards compatibility. + */ removeProvider(providerId: string): void { this.providerIds = this.providerIds.filter((id) => id !== providerId); } + + /** + * Get all provider IDs from the providers relation. + * Falls back to legacy providerIds array if relation not loaded. + */ + getProviderIds(): string[] { + if (this.providers?.length) { + return this.providers.map((p) => + UserProvider.toProviderId(p.providerName, p.providerUserId), + ); + } + return this.providerIds || []; + } } diff --git a/apps/backend/src/modules/users/users.module.ts b/apps/backend/src/modules/users/users.module.ts index f236d54..fc2b576 100644 --- a/apps/backend/src/modules/users/users.module.ts +++ b/apps/backend/src/modules/users/users.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { UserAccount } from 'src/entities/user-accounts.entity'; import { User } from 'src/entities/user.entity'; +import { UserProvider } from 'src/entities/user-provider.entity'; import { UsersController } from './users.controller'; import { UsersService } from './users.service'; import { AuditModule } from '../audit/audit.module'; @@ -10,7 +11,12 @@ import { AccountsModule } from '../accounts/accounts.module'; import { ConfigModule } from '@nestjs/config'; @Module({ - imports: [ConfigModule, TypeOrmModule.forFeature([User, UserAccount]), AuditModule, AccountsModule], + imports: [ + ConfigModule, + TypeOrmModule.forFeature([User, UserAccount, UserProvider]), + AuditModule, + AccountsModule, + ], controllers: [UsersController], providers: [UsersService, Auth0Provider], exports: [UsersService], diff --git a/apps/backend/src/modules/users/users.service.ts b/apps/backend/src/modules/users/users.service.ts index 07bb640..a07c029 100644 --- a/apps/backend/src/modules/users/users.service.ts +++ b/apps/backend/src/modules/users/users.service.ts @@ -7,7 +7,8 @@ import { InjectRepository } from '@nestjs/typeorm'; import { ClsService } from 'nestjs-cls'; import { UserAccount } from '../../entities/user-accounts.entity'; import { User } from '../../entities/user.entity'; -import { Repository } from 'typeorm'; +import { UserProvider } from '../../entities/user-provider.entity'; +import { Repository, In } from 'typeorm'; import { UserDto } from './dto/user.dto'; import { UserAccountDto } from './dto/user-account.dto'; import { LoginDto } from './dto/login.dto'; @@ -24,6 +25,8 @@ export class UsersService { private readonly userRepository: Repository, @InjectRepository(UserAccount) private readonly userAccountRepository: Repository, + @InjectRepository(UserProvider) + private readonly userProviderRepository: Repository, private readonly cls: ClsService, private readonly auth0Provider: Auth0Provider, private readonly accountsService: AccountsService, @@ -119,38 +122,85 @@ export class UsersService { }); } + /** + * Find a user by their identity provider ID. + * Uses a database-agnostic JOIN query through the user_providers table. + * + * @param providerId - Provider ID in format "provider|userId" (e.g., "google|123456") + */ async findByProviderId(providerId: string | string[]): Promise { const providerIds = Array.isArray(providerId) ? providerId : [providerId]; + + // Parse provider IDs into name/userId pairs for the JOIN query + const parsed = providerIds.map((id) => UserProvider.parseProviderId(id)); + + // Build query using standard JOIN - works on any database const queryBuilder = this.userRepository .createQueryBuilder('user') + .innerJoin('user_providers', 'provider', 'provider.user_id = user.id') .leftJoinAndSelect('user.userAccounts', 'userAccount'); - // Detect database type and adapt query accordingly - const databaseType = this.userRepository.manager.connection.options.type; - - if (databaseType === 'postgres') { - // Use array overlap operator (&&) for PostgreSQL to leverage GIN index for optimal performance - queryBuilder.where('user.provider_ids && :providerIds', { providerIds }); - } else { - // For SQLite and other databases, use string-based matching since simple-array becomes comma-separated - const conditions = providerIds - .map((_, index) => `user.provider_ids LIKE :providerId${index}`) - .join(' OR '); - - const parameters = providerIds.reduce( - (acc, id, index) => { - acc[`providerId${index}`] = `%${id}%`; - return acc; - }, - {} as Record, - ); + // Build OR conditions for each provider ID + const conditions = parsed.map((_, index) => { + return `(provider.provider_name = :providerName${index} AND provider.provider_user_id = :providerUserId${index})`; + }); - queryBuilder.where(`(${conditions})`, parameters); - } + const parameters = parsed.reduce( + (acc, { providerName, providerUserId }, index) => { + acc[`providerName${index}`] = providerName; + acc[`providerUserId${index}`] = providerUserId; + return acc; + }, + {} as Record, + ); + + queryBuilder.where(`(${conditions.join(' OR ')})`, parameters); return await queryBuilder.getOne(); } + /** + * Add a provider to a user. + * Creates a record in the user_providers join table. + * + * @param user - User entity or user ID + * @param providerId - Provider ID in format "provider|userId" + */ + async addProviderToUser( + userOrId: User | string, + providerId: string, + ): Promise { + const userId = typeof userOrId === 'string' ? userOrId : userOrId.id; + const { providerName, providerUserId } = + UserProvider.parseProviderId(providerId); + + const provider = this.userProviderRepository.create({ + userId, + providerName, + providerUserId, + }); + + return await this.userProviderRepository.save(provider); + } + + /** + * Remove a provider from a user. + */ + async removeProviderFromUser( + userOrId: User | string, + providerId: string, + ): Promise { + const userId = typeof userOrId === 'string' ? userOrId : userOrId.id; + const { providerName, providerUserId } = + UserProvider.parseProviderId(providerId); + + await this.userProviderRepository.delete({ + userId, + providerName, + providerUserId, + }); + } + async create(user: UserDto) { const currentUser = await this.userRepository.findOne({ where: { email: user.email }, From 22de1aacacb9e0f7dbb7ac1f6886aeeb78bb46c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alberto=20Andr=C3=A9?= Date: Sat, 6 Dec 2025 23:26:51 -0300 Subject: [PATCH 2/2] test: add UserProvider entity and service tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add user-provider.entity.spec.ts with 18 tests for parseProviderId, toProviderId, roundtrip conversion, and entity structure - Add users.service.provider.spec.ts with 12 tests for findByProviderId, addProviderToUser, and removeProviderFromUser - Fix Jest moduleNameMapper for src/* path resolution πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- apps/backend/package.json | 3 + .../src/entities/user-provider.entity.spec.ts | 176 ++ .../users/users.service.provider.spec.ts | 324 +++ docs/hono-boilerplate-plan.md | 1346 ++++++++++ docs/python-boilerplate-plan.md | 2175 +++++++++++++++++ 5 files changed, 4024 insertions(+) create mode 100644 apps/backend/src/entities/user-provider.entity.spec.ts create mode 100644 apps/backend/src/modules/users/users.service.provider.spec.ts create mode 100644 docs/hono-boilerplate-plan.md create mode 100644 docs/python-boilerplate-plan.md diff --git a/apps/backend/package.json b/apps/backend/package.json index 2285e2c..90a6069 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -86,6 +86,9 @@ "transform": { "^.+\\.(t|j)s$": "ts-jest" }, + "moduleNameMapper": { + "^src/(.*)$": "/$1" + }, "collectCoverageFrom": [ "**/*.(t|j)s" ], diff --git a/apps/backend/src/entities/user-provider.entity.spec.ts b/apps/backend/src/entities/user-provider.entity.spec.ts new file mode 100644 index 0000000..47d5419 --- /dev/null +++ b/apps/backend/src/entities/user-provider.entity.spec.ts @@ -0,0 +1,176 @@ +import { UserProvider } from './user-provider.entity'; + +describe('UserProvider', () => { + describe('parseProviderId', () => { + it('should parse google provider ID correctly', () => { + const result = UserProvider.parseProviderId('google|123456789'); + + expect(result).toEqual({ + providerName: 'google', + providerUserId: '123456789', + }); + }); + + it('should parse auth0 provider ID correctly', () => { + const result = UserProvider.parseProviderId('auth0|user_abc123'); + + expect(result).toEqual({ + providerName: 'auth0', + providerUserId: 'user_abc123', + }); + }); + + it('should parse github provider ID correctly', () => { + const result = UserProvider.parseProviderId('github|99887766'); + + expect(result).toEqual({ + providerName: 'github', + providerUserId: '99887766', + }); + }); + + it('should handle provider ID with multiple pipe characters', () => { + // Only first pipe is the separator + const result = UserProvider.parseProviderId('google|id|with|pipes'); + + expect(result).toEqual({ + providerName: 'google', + providerUserId: 'id|with|pipes', + }); + }); + + it('should return "unknown" provider for legacy format without separator', () => { + const result = UserProvider.parseProviderId('legacy_user_id_123'); + + expect(result).toEqual({ + providerName: 'unknown', + providerUserId: 'legacy_user_id_123', + }); + }); + + it('should handle empty provider name (pipe at start)', () => { + const result = UserProvider.parseProviderId('|user123'); + + expect(result).toEqual({ + providerName: '', + providerUserId: 'user123', + }); + }); + + it('should handle empty user ID (pipe at end)', () => { + const result = UserProvider.parseProviderId('google|'); + + expect(result).toEqual({ + providerName: 'google', + providerUserId: '', + }); + }); + + it('should handle just a pipe character', () => { + const result = UserProvider.parseProviderId('|'); + + expect(result).toEqual({ + providerName: '', + providerUserId: '', + }); + }); + + it('should parse provider ID with special characters in user ID', () => { + const result = UserProvider.parseProviderId( + 'google-oauth2|1234567890.apps.googleusercontent.com', + ); + + expect(result).toEqual({ + providerName: 'google-oauth2', + providerUserId: '1234567890.apps.googleusercontent.com', + }); + }); + }); + + describe('toProviderId', () => { + it('should create provider ID from google name and user ID', () => { + const result = UserProvider.toProviderId('google', '123456789'); + + expect(result).toBe('google|123456789'); + }); + + it('should create provider ID from auth0 name and user ID', () => { + const result = UserProvider.toProviderId('auth0', 'user_abc123'); + + expect(result).toBe('auth0|user_abc123'); + }); + + it('should handle empty strings', () => { + const result = UserProvider.toProviderId('', ''); + + expect(result).toBe('|'); + }); + + it('should handle special characters in user ID', () => { + const result = UserProvider.toProviderId( + 'google-oauth2', + '1234567890.apps.googleusercontent.com', + ); + + expect(result).toBe('google-oauth2|1234567890.apps.googleusercontent.com'); + }); + }); + + describe('parseProviderId and toProviderId roundtrip', () => { + it('should roundtrip standard provider IDs', () => { + const original = 'google|123456789'; + const parsed = UserProvider.parseProviderId(original); + const reconstructed = UserProvider.toProviderId( + parsed.providerName, + parsed.providerUserId, + ); + + expect(reconstructed).toBe(original); + }); + + it('should roundtrip auth0 provider IDs', () => { + const original = 'auth0|user_12345'; + const parsed = UserProvider.parseProviderId(original); + const reconstructed = UserProvider.toProviderId( + parsed.providerName, + parsed.providerUserId, + ); + + expect(reconstructed).toBe(original); + }); + + it('should roundtrip complex provider IDs', () => { + const original = 'google-oauth2|id|with|multiple|pipes'; + const parsed = UserProvider.parseProviderId(original); + const reconstructed = UserProvider.toProviderId( + parsed.providerName, + parsed.providerUserId, + ); + + expect(reconstructed).toBe(original); + }); + }); + + describe('entity structure', () => { + it('should create entity instance with all fields', () => { + const provider = new UserProvider(); + provider.id = 'test-uuid'; + provider.userId = 'user-123'; + provider.providerName = 'google'; + provider.providerUserId = '12345'; + provider.createdAt = new Date('2024-01-01'); + + expect(provider.id).toBe('test-uuid'); + expect(provider.userId).toBe('user-123'); + expect(provider.providerName).toBe('google'); + expect(provider.providerUserId).toBe('12345'); + expect(provider.createdAt).toEqual(new Date('2024-01-01')); + }); + + it('should have user relation defined', () => { + const provider = new UserProvider(); + // User relation should be undefined initially + expect(provider.user).toBeUndefined(); + }); + }); +}); diff --git a/apps/backend/src/modules/users/users.service.provider.spec.ts b/apps/backend/src/modules/users/users.service.provider.spec.ts new file mode 100644 index 0000000..bba72f1 --- /dev/null +++ b/apps/backend/src/modules/users/users.service.provider.spec.ts @@ -0,0 +1,324 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ClsService } from 'nestjs-cls'; +import { Repository, SelectQueryBuilder } from 'typeorm'; +import { UsersService } from './users.service'; +import { User } from '../../entities/user.entity'; +import { UserAccount } from '../../entities/user-accounts.entity'; +import { UserProvider } from '../../entities/user-provider.entity'; +import { Auth0Provider } from './providers/auth0.provider'; +import { AccountsService } from '../accounts/accounts.service'; + +describe('UsersService - Provider Methods', () => { + let service: UsersService; + let userRepository: jest.Mocked>; + let userProviderRepository: jest.Mocked>; + + const mockUser: User = { + id: 'user-123', + email: 'test@example.com', + name: 'Test User', + status: 'accepted', + providerIds: [], + isSuperAdmin: false, + profileImage: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + userAccounts: [], + providers: [], + addProvider: jest.fn(), + } as any; + + const createMockQueryBuilder = () => { + const qb: Partial> = { + innerJoin: jest.fn().mockReturnThis(), + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getOne: jest.fn(), + }; + return qb as jest.Mocked>; + }; + + beforeEach(async () => { + const mockQueryBuilder = createMockQueryBuilder(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UsersService, + { + provide: getRepositoryToken(User), + useValue: { + find: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + update: jest.fn(), + softDelete: jest.fn(), + findAndCount: jest.fn(), + createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder), + }, + }, + { + provide: getRepositoryToken(UserAccount), + useValue: { + find: jest.fn(), + save: jest.fn(), + delete: jest.fn(), + }, + }, + { + provide: getRepositoryToken(UserProvider), + useValue: { + create: jest.fn(), + save: jest.fn(), + delete: jest.fn(), + find: jest.fn(), + }, + }, + { + provide: ClsService, + useValue: { + get: jest.fn(), + set: jest.fn(), + }, + }, + { + provide: Auth0Provider, + useValue: { + getUserByEmail: jest.fn(), + sendInvitation: jest.fn(), + }, + }, + { + provide: AccountsService, + useValue: { + findAll: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(UsersService); + userRepository = module.get(getRepositoryToken(User)); + userProviderRepository = module.get(getRepositoryToken(UserProvider)); + }); + + describe('findByProviderId', () => { + it('should find user by single provider ID', async () => { + const mockQueryBuilder = createMockQueryBuilder(); + mockQueryBuilder.getOne.mockResolvedValue(mockUser); + (userRepository.createQueryBuilder as jest.Mock).mockReturnValue( + mockQueryBuilder, + ); + + const result = await service.findByProviderId('google|123456'); + + expect(result).toEqual(mockUser); + expect(mockQueryBuilder.innerJoin).toHaveBeenCalledWith( + 'user_providers', + 'provider', + 'provider.user_id = user.id', + ); + expect(mockQueryBuilder.where).toHaveBeenCalledWith( + '((provider.provider_name = :providerName0 AND provider.provider_user_id = :providerUserId0))', + { + providerName0: 'google', + providerUserId0: '123456', + }, + ); + }); + + it('should find user by array of provider IDs', async () => { + const mockQueryBuilder = createMockQueryBuilder(); + mockQueryBuilder.getOne.mockResolvedValue(mockUser); + (userRepository.createQueryBuilder as jest.Mock).mockReturnValue( + mockQueryBuilder, + ); + + const result = await service.findByProviderId([ + 'google|123456', + 'auth0|user_abc', + ]); + + expect(result).toEqual(mockUser); + expect(mockQueryBuilder.where).toHaveBeenCalledWith( + '((provider.provider_name = :providerName0 AND provider.provider_user_id = :providerUserId0) OR (provider.provider_name = :providerName1 AND provider.provider_user_id = :providerUserId1))', + { + providerName0: 'google', + providerUserId0: '123456', + providerName1: 'auth0', + providerUserId1: 'user_abc', + }, + ); + }); + + it('should return null when user not found', async () => { + const mockQueryBuilder = createMockQueryBuilder(); + mockQueryBuilder.getOne.mockResolvedValue(null); + (userRepository.createQueryBuilder as jest.Mock).mockReturnValue( + mockQueryBuilder, + ); + + const result = await service.findByProviderId('google|nonexistent'); + + expect(result).toBeNull(); + }); + + it('should handle legacy provider IDs without separator', async () => { + const mockQueryBuilder = createMockQueryBuilder(); + mockQueryBuilder.getOne.mockResolvedValue(mockUser); + (userRepository.createQueryBuilder as jest.Mock).mockReturnValue( + mockQueryBuilder, + ); + + await service.findByProviderId('legacy_user_id'); + + expect(mockQueryBuilder.where).toHaveBeenCalledWith( + '((provider.provider_name = :providerName0 AND provider.provider_user_id = :providerUserId0))', + { + providerName0: 'unknown', + providerUserId0: 'legacy_user_id', + }, + ); + }); + + it('should join with userAccounts relation', async () => { + const mockQueryBuilder = createMockQueryBuilder(); + mockQueryBuilder.getOne.mockResolvedValue(mockUser); + (userRepository.createQueryBuilder as jest.Mock).mockReturnValue( + mockQueryBuilder, + ); + + await service.findByProviderId('google|123456'); + + expect(mockQueryBuilder.leftJoinAndSelect).toHaveBeenCalledWith( + 'user.userAccounts', + 'userAccount', + ); + }); + }); + + describe('addProviderToUser', () => { + it('should add provider to user by User entity', async () => { + const createdProvider = { + id: 'provider-uuid', + userId: mockUser.id, + providerName: 'google', + providerUserId: '123456', + createdAt: new Date(), + }; + + userProviderRepository.create.mockReturnValue(createdProvider as any); + userProviderRepository.save.mockResolvedValue(createdProvider as any); + + const result = await service.addProviderToUser(mockUser, 'google|123456'); + + expect(userProviderRepository.create).toHaveBeenCalledWith({ + userId: mockUser.id, + providerName: 'google', + providerUserId: '123456', + }); + expect(userProviderRepository.save).toHaveBeenCalledWith(createdProvider); + expect(result).toEqual(createdProvider); + }); + + it('should add provider to user by user ID string', async () => { + const createdProvider = { + id: 'provider-uuid', + userId: 'user-456', + providerName: 'auth0', + providerUserId: 'user_abc', + createdAt: new Date(), + }; + + userProviderRepository.create.mockReturnValue(createdProvider as any); + userProviderRepository.save.mockResolvedValue(createdProvider as any); + + const result = await service.addProviderToUser('user-456', 'auth0|user_abc'); + + expect(userProviderRepository.create).toHaveBeenCalledWith({ + userId: 'user-456', + providerName: 'auth0', + providerUserId: 'user_abc', + }); + expect(result).toEqual(createdProvider); + }); + + it('should handle provider ID with google-oauth2 format', async () => { + const createdProvider = { + id: 'provider-uuid', + userId: mockUser.id, + providerName: 'google-oauth2', + providerUserId: '12345.apps.googleusercontent.com', + createdAt: new Date(), + }; + + userProviderRepository.create.mockReturnValue(createdProvider as any); + userProviderRepository.save.mockResolvedValue(createdProvider as any); + + await service.addProviderToUser( + mockUser, + 'google-oauth2|12345.apps.googleusercontent.com', + ); + + expect(userProviderRepository.create).toHaveBeenCalledWith({ + userId: mockUser.id, + providerName: 'google-oauth2', + providerUserId: '12345.apps.googleusercontent.com', + }); + }); + }); + + describe('removeProviderFromUser', () => { + it('should remove provider from user by User entity', async () => { + userProviderRepository.delete.mockResolvedValue({ affected: 1 } as any); + + await service.removeProviderFromUser(mockUser, 'google|123456'); + + expect(userProviderRepository.delete).toHaveBeenCalledWith({ + userId: mockUser.id, + providerName: 'google', + providerUserId: '123456', + }); + }); + + it('should remove provider from user by user ID string', async () => { + userProviderRepository.delete.mockResolvedValue({ affected: 1 } as any); + + await service.removeProviderFromUser('user-456', 'auth0|user_abc'); + + expect(userProviderRepository.delete).toHaveBeenCalledWith({ + userId: 'user-456', + providerName: 'auth0', + providerUserId: 'user_abc', + }); + }); + + it('should handle non-existent provider gracefully', async () => { + userProviderRepository.delete.mockResolvedValue({ affected: 0 } as any); + + // Should not throw + await expect( + service.removeProviderFromUser(mockUser, 'nonexistent|provider'), + ).resolves.not.toThrow(); + + expect(userProviderRepository.delete).toHaveBeenCalled(); + }); + + it('should parse complex provider IDs correctly', async () => { + userProviderRepository.delete.mockResolvedValue({ affected: 1 } as any); + + await service.removeProviderFromUser( + mockUser, + 'google-oauth2|id|with|pipes', + ); + + expect(userProviderRepository.delete).toHaveBeenCalledWith({ + userId: mockUser.id, + providerName: 'google-oauth2', + providerUserId: 'id|with|pipes', + }); + }); + }); +}); diff --git a/docs/hono-boilerplate-plan.md b/docs/hono-boilerplate-plan.md new file mode 100644 index 0000000..6bea0ad --- /dev/null +++ b/docs/hono-boilerplate-plan.md @@ -0,0 +1,1346 @@ +# Hono Boilerplate Plan + +This document outlines a plan to create a new backend boilerplate using Hono.js, preserving the core concepts from the NestJS boilerplate while leveraging Hono's lightweight, type-safe approach. + +## Key Hono Features to Leverage + +| Feature | Benefit | +|---------|---------| +| `@hono/zod-openapi` | Schema validation + auto-generated OpenAPI docs in one | +| `OpenAPIHono` | Extended Hono class with built-in OpenAPI support | +| `createRoute()` | Type-safe route definitions with request/response schemas | +| RPC mode | End-to-end type safety if frontend consumes the API | +| `createFactory()` | Centralized type definitions, reduces boilerplate | +| `c.set()`/`c.get()` | Built-in request-scoped context (replaces nestjs-cls) | +| JWT helper | Native JWT verify/sign/decode with multiple algorithms | + +## Project Structure + +``` +src/ +β”œβ”€β”€ index.ts # Entry point with serve() +β”œβ”€β”€ app.ts # OpenAPIHono app factory +β”œβ”€β”€ env.ts # Zod schema for env validation +β”‚ +β”œβ”€β”€ middleware/ +β”‚ β”œβ”€β”€ auth.ts # JWT validation middleware +β”‚ β”œβ”€β”€ account.ts # account-id header + membership check +β”‚ β”œβ”€β”€ request-context.ts # Sets user, requestId, ip in context +β”‚ └── error-handler.ts # defaultHook for validation errors +β”‚ +β”œβ”€β”€ routes/ +β”‚ β”œβ”€β”€ index.ts # app.route() aggregation + OpenAPI doc +β”‚ β”œβ”€β”€ users/ +β”‚ β”‚ β”œβ”€β”€ index.ts # OpenAPIHono sub-app +β”‚ β”‚ β”œβ”€β”€ routes.ts # createRoute() definitions +β”‚ β”‚ β”œβ”€β”€ handlers.ts # Handler implementations +β”‚ β”‚ └── schemas.ts # Zod schemas with .openapi() +β”‚ └── accounts/ +β”‚ └── ... +β”‚ +β”œβ”€β”€ db/ +β”‚ β”œβ”€β”€ client.ts # Drizzle client +β”‚ β”œβ”€β”€ schema/ +β”‚ β”‚ β”œβ”€β”€ users.ts +β”‚ β”‚ β”œβ”€β”€ accounts.ts +β”‚ β”‚ β”œβ”€β”€ user-accounts.ts +β”‚ β”‚ └── audit-logs.ts +β”‚ β”œβ”€β”€ migrations/ +β”‚ └── seed.ts +β”‚ +β”œβ”€β”€ services/ +β”‚ β”œβ”€β”€ users.ts # Business logic +β”‚ β”œβ”€β”€ accounts.ts +β”‚ β”œβ”€β”€ audit.ts +β”‚ └── identity-provider.ts # Auth0 abstraction +β”‚ +β”œβ”€β”€ auth/ +β”‚ β”œβ”€β”€ permissions.ts # Permission enum + matrix +β”‚ β”œβ”€β”€ roles.ts # Role enum + helpers +β”‚ └── guards.ts # requireRole(), requirePermission() +β”‚ +β”œβ”€β”€ lib/ +β”‚ β”œβ”€β”€ pagination.ts # Pagination helpers +β”‚ β”œβ”€β”€ errors.ts # Custom error classes +β”‚ └── result.ts # Result type (optional) +β”‚ +└── types/ + └── index.ts # Shared types, Env definition +``` + +## Core Dependencies + +```json +{ + "dependencies": { + "hono": "^4.x", + "@hono/node-server": "^1.x", + "@hono/zod-openapi": "^0.x", + "@hono/swagger-ui": "^0.x", + "drizzle-orm": "^0.x", + "better-sqlite3": "^11.x", + "zod": "^3.x" + }, + "devDependencies": { + "drizzle-kit": "^0.x", + "typescript": "^5.x", + "@types/node": "^22.x", + "@types/better-sqlite3": "^7.x" + } +} +``` + +## Implementation Details + +### 1. App Factory with Typed Environment + +```typescript +// app.ts +import { OpenAPIHono } from '@hono/zod-openapi' +import { swaggerUI } from '@hono/swagger-ui' + +export type Env = { + Variables: { + requestId: string + user: User | null + accountId: string + userRole: Role | null + } +} + +export const createApp = () => { + const app = new OpenAPIHono({ + defaultHook: (result, c) => { + if (!result.success) { + return c.json({ + error: 'Validation Error', + details: result.error.flatten() + }, 400) + } + } + }) + + return app +} +``` + +### 2. Route Definitions with `createRoute()` + +```typescript +// routes/users/routes.ts +import { createRoute, z } from '@hono/zod-openapi' +import { UserSchema, CreateUserSchema } from './schemas' + +export const listUsers = createRoute({ + method: 'get', + path: '/users', + tags: ['Users'], + security: [{ Bearer: [] }], + request: { + query: z.object({ + page: z.coerce.number().default(1), + limit: z.coerce.number().default(50), + }), + }, + responses: { + 200: { + content: { 'application/json': { schema: PaginatedUsersSchema } }, + description: 'List of users', + }, + 401: { description: 'Unauthorized' }, + }, +}) + +export const createUser = createRoute({ + method: 'post', + path: '/users', + tags: ['Users'], + middleware: [requireRole('ADMIN')], // Inline guard + request: { + body: { content: { 'application/json': { schema: CreateUserSchema } } }, + }, + responses: { + 201: { + content: { 'application/json': { schema: UserSchema } }, + description: 'User created', + }, + }, +}) +``` + +### 3. Schemas with OpenAPI Metadata + +```typescript +// routes/users/schemas.ts +import { z } from '@hono/zod-openapi' + +export const UserSchema = z.object({ + id: z.string().uuid().openapi({ example: '550e8400-e29b-41d4-a716-446655440000' }), + email: z.string().email().openapi({ example: 'user@example.com' }), + name: z.string().openapi({ example: 'John Doe' }), + status: z.enum(['active', 'inactive']).openapi({ example: 'active' }), + isSuperAdmin: z.boolean().openapi({ example: false }), + createdAt: z.string().datetime(), +}).openapi('User') + +export const CreateUserSchema = z.object({ + email: z.string().email(), + name: z.string().min(1).max(255), + accountIds: z.array(z.string().uuid()).optional(), +}).openapi('CreateUserInput') +``` + +### 4. Handlers with Typed Context + +```typescript +// routes/users/handlers.ts +import type { Context } from 'hono' +import type { Env } from '../../app' + +export const handleListUsers = async (c: Context) => { + const { page, limit } = c.req.valid('query') // Typed from route schema + const user = c.get('user') // From middleware + const accountId = c.get('accountId') + + const result = await usersService.findAll({ page, limit, accountId }) + return c.json(result, 200) +} + +export const handleCreateUser = async (c: Context) => { + const body = c.req.valid('json') // Typed as CreateUserSchema + const actor = c.get('user') + + const user = await usersService.create(body, actor) + return c.json(user, 201) +} +``` + +### 5. Route Module Assembly + +```typescript +// routes/users/index.ts +import { OpenAPIHono } from '@hono/zod-openapi' +import { listUsers, createUser } from './routes' +import { handleListUsers, handleCreateUser } from './handlers' +import type { Env } from '../../app' + +const users = new OpenAPIHono() + +users.openapi(listUsers, handleListUsers) +users.openapi(createUser, handleCreateUser) + +export { users } +``` + +### 6. Main App with Swagger UI + +```typescript +// routes/index.ts +import { OpenAPIHono } from '@hono/zod-openapi' +import { swaggerUI } from '@hono/swagger-ui' +import { users } from './users' +import { accounts } from './accounts' +import type { Env } from '../app' + +const api = new OpenAPIHono() + +api.route('/users', users) +api.route('/accounts', accounts) + +// OpenAPI JSON endpoint +api.doc('/doc', { + openapi: '3.1.0', + info: { title: 'Etus API', version: '1.0.0' }, + security: [{ Bearer: [] }], +}) + +// Swagger UI +api.get('/docs', swaggerUI({ url: '/doc' })) + +export { api } +``` + +### 7. Drizzle Schema (Preserving Entity Model) + +```typescript +// db/schema/users.ts +import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core' +import { sql } from 'drizzle-orm' + +export const users = sqliteTable('users', { + id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), + email: text('email').notNull().unique(), + name: text('name').notNull(), + status: text('status', { enum: ['active', 'inactive'] }).default('active'), + providerIds: text('provider_ids', { mode: 'json' }).$type().default([]), + isSuperAdmin: integer('is_super_admin', { mode: 'boolean' }).default(false), + + // Soft delete + audit fields + createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`), + updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`), + deletedAt: text('deleted_at'), + createdById: text('created_by_id').references(() => users.id), + updatedById: text('updated_by_id').references(() => users.id), + deletedById: text('deleted_by_id').references(() => users.id), +}) +``` + +```typescript +// db/schema/accounts.ts +import { sqliteTable, text } from 'drizzle-orm/sqlite-core' +import { sql } from 'drizzle-orm' + +export const accounts = sqliteTable('accounts', { + id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), + name: text('name').notNull(), + description: text('description'), + domain: text('domain').unique(), + + createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`), + updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`), + deletedAt: text('deleted_at'), +}) +``` + +```typescript +// db/schema/user-accounts.ts +import { sqliteTable, text, primaryKey } from 'drizzle-orm/sqlite-core' +import { users } from './users' +import { accounts } from './accounts' + +export const userAccounts = sqliteTable('user_accounts', { + userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + accountId: text('account_id').notNull().references(() => accounts.id, { onDelete: 'cascade' }), + role: text('role', { + enum: ['ADMIN', 'MANAGER', 'EDITOR', 'AUTHOR', 'VIEWER', 'BILLING', 'ANALYTICS'] + }).notNull(), +}, (table) => ({ + pk: primaryKey({ columns: [table.userId, table.accountId] }), +})) +``` + +```typescript +// db/schema/audit-logs.ts +import { sqliteTable, text } from 'drizzle-orm/sqlite-core' +import { sql } from 'drizzle-orm' +import { users } from './users' +import { accounts } from './accounts' + +export const auditLogs = sqliteTable('audit_logs', { + id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), + transactionId: text('transaction_id').notNull(), + accountId: text('account_id').references(() => accounts.id), + userId: text('user_id').references(() => users.id), + entity: text('entity').notNull(), + entityId: text('entity_id').notNull(), + action: text('action', { enum: ['INSERT', 'UPDATE', 'DELETE'] }).notNull(), + changes: text('changes', { mode: 'json' }).$type>(), + ipAddress: text('ip_address'), + userAgent: text('user_agent'), + timestamp: text('timestamp').default(sql`CURRENT_TIMESTAMP`), +}) +``` + +### 8. Role Guard Middleware + +```typescript +// auth/guards.ts +import { createMiddleware } from 'hono/factory' +import type { Env } from '../app' +import { hasMinimumRole, Role } from './roles' + +export const requireRole = (minRole: Role) => { + return createMiddleware(async (c, next) => { + const user = c.get('user') + const userRole = c.get('userRole') + + if (!user) { + return c.json({ error: 'Unauthorized' }, 401) + } + + // Super-admin bypass + if (user.isSuperAdmin) { + return next() + } + + if (!userRole || !hasMinimumRole(userRole, minRole)) { + return c.json({ error: 'Forbidden' }, 403) + } + + return next() + }) +} + +export const requirePermission = (permission: Permission) => { + return createMiddleware(async (c, next) => { + const user = c.get('user') + const userRole = c.get('userRole') + + if (!user) { + return c.json({ error: 'Unauthorized' }, 401) + } + + if (user.isSuperAdmin) { + return next() + } + + if (!userRole || !hasPermission(userRole, permission)) { + return c.json({ error: 'Forbidden' }, 403) + } + + return next() + }) +} +``` + +### 9. Roles and Permissions + +```typescript +// auth/roles.ts +export const Role = { + ADMIN: 'ADMIN', + MANAGER: 'MANAGER', + EDITOR: 'EDITOR', + AUTHOR: 'AUTHOR', + VIEWER: 'VIEWER', + BILLING: 'BILLING', + ANALYTICS: 'ANALYTICS', +} as const + +export type Role = typeof Role[keyof typeof Role] + +const ROLE_HIERARCHY: Record = { + ADMIN: 0, + MANAGER: 1, + EDITOR: 2, + AUTHOR: 3, + VIEWER: 4, + BILLING: -1, // Non-hierarchical + ANALYTICS: -1, // Non-hierarchical +} + +export const hasMinimumRole = (userRole: Role, requiredRole: Role): boolean => { + const userLevel = ROLE_HIERARCHY[userRole] + const requiredLevel = ROLE_HIERARCHY[requiredRole] + + // Non-hierarchical roles can only match exactly + if (userLevel === -1 || requiredLevel === -1) { + return userRole === requiredRole + } + + return userLevel <= requiredLevel +} +``` + +```typescript +// auth/permissions.ts +export const Permission = { + MANAGE_SYSTEM_SETTINGS: 'MANAGE_SYSTEM_SETTINGS', + MANAGE_ALL_USERS: 'MANAGE_ALL_USERS', + MANAGE_TEAM_USERS: 'MANAGE_TEAM_USERS', + VIEW_ALL_USERS: 'VIEW_ALL_USERS', + CREATE_CONTENT: 'CREATE_CONTENT', + EDIT_ALL_CONTENT: 'EDIT_ALL_CONTENT', + EDIT_OWN_CONTENT: 'EDIT_OWN_CONTENT', + DELETE_CONTENT: 'DELETE_CONTENT', + PUBLISH_CONTENT: 'PUBLISH_CONTENT', + VIEW_CONTENT: 'VIEW_CONTENT', + VIEW_ANALYTICS: 'VIEW_ANALYTICS', + MANAGE_BILLING: 'MANAGE_BILLING', + VIEW_BILLING: 'VIEW_BILLING', +} as const + +export type Permission = typeof Permission[keyof typeof Permission] + +const ROLE_PERMISSIONS: Record = { + ADMIN: [ + Permission.MANAGE_SYSTEM_SETTINGS, + Permission.MANAGE_ALL_USERS, + Permission.CREATE_CONTENT, + Permission.EDIT_ALL_CONTENT, + Permission.DELETE_CONTENT, + Permission.PUBLISH_CONTENT, + Permission.VIEW_CONTENT, + Permission.VIEW_ANALYTICS, + Permission.MANAGE_BILLING, + Permission.VIEW_BILLING, + ], + MANAGER: [ + Permission.MANAGE_TEAM_USERS, + Permission.VIEW_ALL_USERS, + Permission.CREATE_CONTENT, + Permission.EDIT_ALL_CONTENT, + Permission.PUBLISH_CONTENT, + Permission.VIEW_CONTENT, + Permission.VIEW_ANALYTICS, + ], + EDITOR: [ + Permission.CREATE_CONTENT, + Permission.EDIT_ALL_CONTENT, + Permission.PUBLISH_CONTENT, + Permission.VIEW_CONTENT, + ], + AUTHOR: [ + Permission.CREATE_CONTENT, + Permission.EDIT_OWN_CONTENT, + Permission.VIEW_CONTENT, + ], + VIEWER: [ + Permission.VIEW_CONTENT, + ], + BILLING: [ + Permission.MANAGE_BILLING, + Permission.VIEW_BILLING, + ], + ANALYTICS: [ + Permission.VIEW_ANALYTICS, + ], +} + +export const hasPermission = (role: Role, permission: Permission): boolean => { + return ROLE_PERMISSIONS[role]?.includes(permission) ?? false +} +``` + +### 10. JWT Auth Middleware + +```typescript +// middleware/auth.ts +import { createMiddleware } from 'hono/factory' +import { verify } from 'hono/jwt' +import type { Env } from '../app' + +export const jwtAuth = createMiddleware(async (c, next) => { + const authHeader = c.req.header('Authorization') + + if (!authHeader?.startsWith('Bearer ')) { + return c.json({ error: 'Missing or invalid Authorization header' }, 401) + } + + const token = authHeader.slice(7) + + try { + // For Auth0 with RS256, you'd use JWKS validation + // This is simplified for HS256 + const payload = await verify(token, process.env.JWT_SECRET!) + + c.set('user', { + id: payload.sub as string, + email: payload.email as string, + isSuperAdmin: payload.isSuperAdmin as boolean ?? false, + }) + + return next() + } catch { + return c.json({ error: 'Invalid token' }, 401) + } +}) +``` + +### 11. Account Middleware + +```typescript +// middleware/account.ts +import { createMiddleware } from 'hono/factory' +import type { Env } from '../app' +import { db } from '../db/client' +import { userAccounts } from '../db/schema' +import { eq, and } from 'drizzle-orm' + +export const accountMiddleware = createMiddleware(async (c, next) => { + const accountId = c.req.header('account-id') + const user = c.get('user') + + if (!accountId) { + return c.json({ error: 'account-id header required' }, 400) + } + + if (!user) { + return c.json({ error: 'Unauthorized' }, 401) + } + + // Super-admin can access any account + if (user.isSuperAdmin) { + c.set('accountId', accountId) + c.set('userRole', 'ADMIN') // Treat as admin for super-admin + return next() + } + + // Check user belongs to account + const [membership] = await db + .select() + .from(userAccounts) + .where(and( + eq(userAccounts.userId, user.id), + eq(userAccounts.accountId, accountId) + )) + .limit(1) + + if (!membership) { + return c.json({ error: 'Forbidden: No access to this account' }, 403) + } + + c.set('accountId', accountId) + c.set('userRole', membership.role) + + return next() +}) +``` + +### 12. Entry Point + +```typescript +// index.ts +import { serve } from '@hono/node-server' +import { createApp } from './app' +import { api } from './routes' +import { jwtAuth } from './middleware/auth' +import { accountMiddleware } from './middleware/account' +import { requestContext } from './middleware/request-context' + +const app = createApp() + +// Global middleware +app.use('*', requestContext) + +// Protected routes +app.use('/users/*', jwtAuth, accountMiddleware) +app.use('/accounts/*', jwtAuth, accountMiddleware) + +// Mount API routes +app.route('/', api) + +// Health check (unprotected) +app.get('/health', (c) => c.json({ status: 'ok' })) + +const port = parseInt(process.env.PORT ?? '3000') + +console.log(`Server running on http://localhost:${port}`) +console.log(`API docs available at http://localhost:${port}/docs`) + +serve({ fetch: app.fetch, port }) +``` + +## What's Preserved from NestJS Boilerplate + +| Concept | Status | Notes | +|---------|--------|-------| +| Entity model (User, Account, UserAccount, AuditLog) | Preserved | Same fields and relationships | +| Multi-tenant via `account-id` header | Preserved | Same pattern | +| Role hierarchy + permissions | Preserved | Same logic, lighter implementation | +| Super-admin bypass | Preserved | Same behavior | +| Soft deletes | Preserved | Drizzle columns instead of TypeORM | +| Request context | Preserved | `c.set()`/`c.get()` instead of CLS | +| JWT auth flow | Preserved | Hono's native JWT helper | +| OpenAPI docs | Preserved | `@hono/zod-openapi` + Swagger UI | +| Validation | Preserved | Zod instead of class-validator | +| Pagination contract | Preserved | Same query params and response shape | + +## What's Different + +| Aspect | NestJS | Hono | +|--------|--------|------| +| Framework weight | Heavy (~50+ deps) | Lightweight (zero deps core) | +| Route definition | Decorators | `createRoute()` function | +| Dependency injection | Built-in DI container | Direct imports / factory functions | +| Validation | class-validator | Zod schemas | +| ORM | TypeORM | Drizzle ORM | +| Context passing | CLS (implicit) | `c.set()`/`c.get()` (explicit) | +| Modules | NestJS modules | File-based organization | +| Guards | Guard classes | Middleware functions | +| OpenAPI | @nestjs/swagger decorators | `@hono/zod-openapi` schemas | + +--- + +# Part 2: Architecture Reference Guide + +This section documents all system design patterns, architectural decisions, and implementation details from the original NestJS boilerplate. Use this as a reference when implementing features in Hono. + +--- + +## 1. Authentication & Authorization Flow + +### 1.1 JWT Validation Flow with Auth0 + +The JWT validation uses a two-stage process: +- **JWKS Validation**: Uses `jwks-rsa` library with JWKS URI to retrieve and validate public keys +- **Token Structure**: Auth0 tokens contain custom claims in the namespace format + +**Environment Variables Required:** +```bash +JWKS_URI=https://domain.region.auth0.com/.well-known/jwks.json +IDP_ISSUER=https://domain.region.auth0.com/ +IDP_AUDIENCE=boilerplate-api +AUTH0_ROLES_NAME=https://your-namespace/roles # Custom claim namespace +``` + +**JWT Payload Structure:** +```typescript +interface Auth0JWT { + 'https://your-namespace/roles': string[]; // Custom roles claim + iss: string; // Issuer + sub: string; // Subject (userId) + aud: string[]; // Audience + iat: number; // Issued at + exp: number; // Expiration + scope: string; // OAuth scopes + azp: string; // Authorized party + permissions: string[]; // Permissions array + identities: string[]; // Identity providers + email: string; // Email claim +} +``` + +**Extracted User Object:** +```typescript +interface Auth0User { + userId: string; // from payload.sub + roles: string[]; // from custom claim namespace + permissions: string[]; + email: string; + identities: string[]; +} +``` + +### 1.2 Guard Execution Order + +Guards/middleware are registered and executed in this order: + +``` +1. JwtAuthGuard β†’ Validates JWT token signature and expiration +2. RolesGuard β†’ Performs account-based role hierarchy checking +``` + +**JwtAuthGuard Behavior:** +- Extracts Bearer token from Authorization header +- Validates signature against JWKS +- Has route exceptions for public endpoints (login, health checks) +- These routes bypass JWT validation entirely + +### 1.3 Middleware Flow in Hono + +```typescript +// Equivalent flow in Hono +app.use('*', requestContext) // 1. Initialize request context +app.use('/api/*', jwtAuth) // 2. Validate JWT +app.use('/api/*', accountMiddleware) // 3. Resolve account + role +// Route handlers execute here +``` + +--- + +## 2. Multi-Tenancy Implementation + +### 2.1 Account Isolation Strategy + +**Core Concept:** Every request requires an `account-id` header that identifies the tenant context. + +**Validation Steps:** +1. **Tenant Identification**: Extract UUID from `account-id` header +2. **Token Validation**: Decode JWT to get user ID +3. **Membership Check**: Verify user belongs to the account +4. **Context Population**: Store tenant data in request context + +```typescript +// Header requirement +const accountId = req.header('account-id'); // UUID format +if (!accountId) throw new Error('Account not exists'); +``` + +### 2.2 Context Data Dictionary + +| Key | Type | Source | Used For | +|-----|------|--------|----------| +| `accountId` | UUID | Request header | Tenant filtering, audit logging | +| `user` | User object | Database lookup via JWT sub | Authorization, audit logging | +| `transactionId` | UUID-v7 | Generated per request | Request correlation, audit trail | +| `ip` | string | `request.ip` | Audit logging, security | +| `userAgent` | string | Request header | Audit logging, analytics | +| `role` | Role enum | From user-account lookup | Authorization decisions | +| `isSystemAdminAccess` | boolean | Set when super-admin | Audit flag for super-admin operations | + +### 2.3 User-Account Relationship Model + +``` +Users can belong to multiple accounts (many-to-many) +Each user-account pair has a specific role assigned + +User ─────┬───── UserAccount ─────┬───── Account + β”‚ β”‚ + β”‚ userId β”‚ accountId + β”‚ accountId β”‚ name + β”‚ role β”‚ domain + β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**Key Design Points:** +- Users can have different roles in different accounts +- `providerIds` array supports multiple identity providers per user +- `isSuperAdmin` flag is independent of account membership + +### 2.4 Data Access Patterns Per Tenant + +```typescript +// Pattern: Filter by user's accessible accounts +async findWithPagination(paginationQuery: PaginationQueryDto) { + const user = getContextUser(); + + if (user?.isSuperAdmin) { + // Super admins see all records across all accounts + return this.findAll(); + } + + // Regular users see only records from their accounts + const accountIds = user.userAccounts.map(ua => ua.accountId); + return this.findByAccountIds(accountIds); +} +``` + +--- + +## 3. Role-Based Access Control (RBAC) + +### 3.1 Role Hierarchy + +The hierarchy uses numeric values where **lower numbers indicate higher privileges**: + +```typescript +const ROLE_HIERARCHY = { + ADMIN: 0, // Highest account role + MANAGER: 1, + EDITOR: 2, + AUTHOR: 3, + VIEWER: 4, // Lowest hierarchical role + BILLING: -1, // Special non-hierarchical role + ANALYTICS: -1, // Special non-hierarchical role +}; +``` + +**Key Design Decisions:** +- **Hierarchical roles** (ADMINβ†’VIEWER) follow strict hierarchy +- **Special roles** (BILLING, ANALYTICS) are non-hierarchical +- A MANAGER can access any EDITOR/AUTHOR/VIEWER endpoint +- BILLING users can ONLY access billing endpoints (no hierarchy) + +### 3.2 Role Checking Logic + +```typescript +function hasMinimumRole( + userRole: Role, + minimumRole: Role, + additionalRoles: Role[] = [] +): boolean { + // Check non-hierarchical additional roles first + if (additionalRoles.includes(userRole)) return true; + + const userLevel = ROLE_HIERARCHY[userRole]; + const minimumLevel = ROLE_HIERARCHY[minimumRole]; + + // Special roles (-1) can't access hierarchical endpoints + if (userLevel === -1) return false; + if (minimumLevel === -1) return false; + + // Lower number = higher privilege + return userLevel <= minimumLevel; +} +``` + +### 3.3 Permission Matrix Structure + +Permissions are grouped by role: + +```typescript +const ROLE_PERMISSIONS = { + ADMIN: [ + 'MANAGE_TENANT_SETTINGS', + 'MANAGE_ALL_USERS', + 'CREATE_CONTENT', + 'EDIT_ANY_CONTENT', + 'DELETE_CONTENT', + 'PUBLISH_CONTENT', + 'VIEW_ALL_CONTENT', + 'VIEW_ANALYTICS', + 'MANAGE_BILLING', + 'VIEW_BILLING', + ], + + MANAGER: [ + 'MANAGE_TEAM_USERS', + 'VIEW_ALL_USERS', + 'CREATE_CONTENT', + 'EDIT_ANY_CONTENT', + 'PUBLISH_CONTENT', + 'VIEW_CONTENT', + 'VIEW_ANALYTICS', + ], + + BILLING: [ + 'MANAGE_BILLING', + 'VIEW_BILLING', + ], + + VIEWER: [ + 'VIEW_PUBLISHED_CONTENT', + ], +}; +``` + +### 3.4 Super-Admin Bypass + +Super admins are identified by a boolean flag on the User entity: + +```typescript +// In role checking: +if (user.isSuperAdmin) { + context.set('role', 'SYSTEM_ADMIN'); + context.set('isSystemAdminAccess', true); + return true; // Bypass all checks +} +``` + +**Use Cases for Super Admin:** +- System maintenance and debugging +- Emergency access when role assignments are broken +- Cross-account operations during migrations +- Platform-level administrative tasks + +**Key Design Notes:** +- Independent of account membership +- Does NOT require entries in user_accounts table +- Provides access to all accounts without role-based filtering +- Logged separately for audit purposes + +--- + +## 4. Entity Design Patterns + +### 4.1 Base Entity Inheritance Hierarchy + +Two-level inheritance structure: + +```typescript +// Level 1: Basic audit timestamps with soft delete +abstract class SoftDeleteEntity { + createdAt: Date; + updatedAt: Date; + deletedAt?: Date; // NULL when not deleted +} + +// Level 2: Adds user tracking for create/update/delete +abstract class InteractiveEntity extends SoftDeleteEntity { + createdBy: User; + updatedBy: User; + deletedBy: User; +} +``` + +**Usage Pattern:** +```typescript +// For entities requiring soft delete only +class Account extends SoftDeleteEntity { } + +// For entities requiring full audit trail +class User extends InteractiveEntity { } +``` + +### 4.2 Soft Delete Implementation + +**Benefits:** +- Data recovery capability +- Historical data preservation +- Audit trail integrity +- No permanent data loss + +**Query Behavior:** +- Queries automatically exclude soft-deleted rows by default +- Use `withDeleted: true` to include soft-deleted rows + +### 4.3 Audit Log Entity + +```typescript +interface AuditLog { + id: string; + transactionId: string; // UUID-v7 from request context + accountId: string; // Tenant context + userId: string; // Who made the change + entity: string; // Entity name (e.g., 'User', 'Account') + entityId: string; // ID of changed entity + action: 'INSERT' | 'UPDATE' | 'DELETE'; + changes: object; // JSON diff of changes + ipAddress: string; // From request context + userAgent: string; // From request context + timestamp: Date; +} +``` + +--- + +## 5. Request Context Flow + +### 5.1 Context Flow Through Application + +``` +Request arrives + ↓ +RequestContext Middleware + β”œβ”€ Generate transactionId (UUID-v7) + β”œβ”€ Extract IP address + └─ Extract User-Agent + ↓ +JWT Auth Middleware + └─ Validate JWT, extract user claims + ↓ +Account Middleware + β”œβ”€ Extract account-id from header + β”œβ”€ Fetch User from database + β”œβ”€ Verify user belongs to account + └─ Set user role for account + ↓ +Role Guard (per-route) + β”œβ”€ Check minimum role requirement + └─ Decide if access allowed + ↓ +Route Handler + └─ Access all context data + ↓ +Service Layer + └─ Uses context for multi-tenant filtering + ↓ +Audit Logger (on entity changes) + β”œβ”€ Reads context + └─ Creates AuditLog entry + ↓ +Response sent +``` + +### 5.2 Hono Context Implementation + +```typescript +// Types for Hono context +type Env = { + Variables: { + requestId: string; + transactionId: string; + ip: string; + userAgent: string; + user: User | null; + accountId: string; + userRole: Role | null; + isSystemAdminAccess: boolean; + }; +}; + +// Access in handlers +const user = c.get('user'); +const accountId = c.get('accountId'); +const transactionId = c.get('transactionId'); +``` + +--- + +## 6. Error Handling + +### 6.1 Error Response Structure + +**Standard HTTP Exception:** +```json +{ + "statusCode": 400, + "message": "Validation failed", + "error": "Bad Request" +} +``` + +**Validation Error (array of issues):** +```json +{ + "statusCode": 400, + "message": [ + "email must be an email", + "name should not be empty" + ], + "error": "Bad Request" +} +``` + +**Zod Validation Error (Hono):** +```json +{ + "error": "Validation Error", + "details": { + "fieldErrors": { + "email": ["Invalid email"] + }, + "formErrors": [] + } +} +``` + +### 6.2 HTTP Status Codes + +| Code | Usage | +|------|-------| +| 200 | Success (GET, PUT) | +| 201 | Created (POST) | +| 400 | Bad Request / Validation Error | +| 401 | Unauthorized (missing/invalid token) | +| 403 | Forbidden (insufficient permissions) | +| 404 | Not Found | +| 500 | Internal Server Error | + +--- + +## 7. API Design Patterns + +### 7.1 Pagination + +**Query Parameters:** +```typescript +interface PaginationQuery { + page?: number; // Default: 1 + limit?: number; // Default: 50, Max: 100 + sortBy?: string; // Field to sort by + sortOrder?: 'ASC' | 'DESC'; // Default: DESC +} +``` + +**Response Format:** +```typescript +interface PaginatedResponse { + data: T[]; + meta: { + currentPage: number; + limit: number; + totalItems: number; + totalPages: number; + hasPreviousPage: boolean; + hasNextPage: boolean; + }; +} +``` + +**Example Response:** +```json +{ + "data": [ + { "id": "...", "name": "..." } + ], + "meta": { + "currentPage": 1, + "limit": 50, + "totalItems": 100, + "totalPages": 2, + "hasPreviousPage": false, + "hasNextPage": true + } +} +``` + +### 7.2 Required Headers + +``` +Authorization: Bearer +account-id: +Content-Type: application/json +``` + +### 7.3 Endpoint Conventions + +| Action | Method | Path | Response | +|--------|--------|------|----------| +| List | GET | `/resources` | Paginated array | +| Get One | GET | `/resources/:id` | Single object | +| Create | POST | `/resources` | Created object (201) | +| Update | PUT | `/resources/:id` | Updated object | +| Delete | DELETE | `/resources/:id` | 204 No Content | + +--- + +## 8. Database Patterns + +### 8.1 Migration Strategy + +**Naming Convention:** +``` +[timestamp]-[description].ts +1742919331481-create-accounts-table.ts +1742919498601-create-users-table.ts +``` + +**Key Decisions:** +- Always use migrations, never `synchronize: true` in production +- Migrations are timestamped for deterministic ordering +- Include both `up()` and `down()` for rollback capability + +### 8.2 Drizzle Migration Example + +```typescript +// drizzle/migrations/0001_create_users.ts +import { sql } from 'drizzle-orm'; +import { sqliteTable, text } from 'drizzle-orm/sqlite-core'; + +export const users = sqliteTable('users', { + id: text('id').primaryKey(), + email: text('email').notNull().unique(), + // ... +}); +``` + +### 8.3 Seeding Pattern + +```typescript +// db/seed.ts +async function seed() { + // Create test account + const [account] = await db.insert(accounts).values({ + name: 'Test Account', + domain: 'test.example.com', + }).returning(); + + // Create test users with different roles + const testUsers = [ + { email: 'admin@test.com', role: 'ADMIN' }, + { email: 'manager@test.com', role: 'MANAGER' }, + { email: 'viewer@test.com', role: 'VIEWER' }, + ]; + + for (const { email, role } of testUsers) { + const [user] = await db.insert(users).values({ + email, + name: email.split('@')[0], + }).returning(); + + await db.insert(userAccounts).values({ + userId: user.id, + accountId: account.id, + role, + }); + } +} +``` + +--- + +## 9. External Service Integration + +### 9.1 Auth0 Provider Abstraction + +```typescript +interface IdentityProvider { + sendInvitation(email: string, name: string): Promise<{ user: any; ticket: string }>; + getUserByEmail(email: string): Promise; +} + +class Auth0Provider implements IdentityProvider { + async sendInvitation(email: string, name: string) { + // Create user in Auth0 + const user = await this.managementClient.users.create({ + email, + name, + connection: 'Username-Password-Authentication', + password: generateStrongPassword(), + }); + + // Generate password reset ticket (invitation link) + const ticket = await this.managementClient.tickets.changePassword({ + email, + result_url: process.env.FRONTEND_URL, + }); + + return { user, ticket: ticket.ticket }; + } +} +``` + +### 9.2 Environment Variables for Auth0 + +```bash +AUTH0_DOMAIN=your-domain +AUTH0_CLIENT_ID=your-client-id +AUTH0_CLIENT_SECRET=your-client-secret +AUTH0_TENANT_ID=your-tenant-id +AUTH0_REGION=us +AUTH0_CONNECTION_ID=connection-id +AUTH0_CONNECTION_TYPE=Username-Password-Authentication +JWKS_URI=https://domain.region.auth0.com/.well-known/jwks.json +IDP_ISSUER=https://domain.region.auth0.com/ +IDP_AUDIENCE=boilerplate-api +``` + +--- + +## 10. Module Organization + +### 10.1 Feature Module Structure + +``` +src/ +β”œβ”€β”€ routes/ +β”‚ └── users/ +β”‚ β”œβ”€β”€ index.ts # Route definitions + handlers wiring +β”‚ β”œβ”€β”€ routes.ts # createRoute() definitions +β”‚ β”œβ”€β”€ handlers.ts # Request handlers +β”‚ β”œβ”€β”€ schemas.ts # Zod schemas +β”‚ └── service.ts # Business logic (optional) +``` + +### 10.2 Service Layer Pattern + +```typescript +// services/users.ts +export const usersService = { + async findAll(options: { accountId: string; page: number; limit: number }) { + // Business logic here + }, + + async findById(id: string, accountId: string) { + // ... + }, + + async create(data: CreateUserInput, actor: User) { + // ... + }, +}; +``` + +### 10.3 Dependency Flow + +``` +Routes (HTTP layer) + ↓ calls +Handlers (Request/Response handling) + ↓ calls +Services (Business logic) + ↓ calls +Database (Drizzle queries) +``` + +--- + +## Implementation Checklist for Hono + +When implementing features in Hono, ensure you cover: + +- [ ] **Request Context** - Set up `c.set()`/`c.get()` for all context values +- [ ] **JWT Validation** - Implement JWKS validation for Auth0 +- [ ] **Account Header** - Require and validate `account-id` header +- [ ] **Role Guards** - Create `requireRole()` and `requirePermission()` middleware +- [ ] **Super-Admin Bypass** - Check `isSuperAdmin` flag before role checks +- [ ] **Audit Logging** - Capture all entity changes with context +- [ ] **Soft Deletes** - Use `deletedAt` column, filter by default +- [ ] **Pagination** - Use consistent query params and response shape +- [ ] **Error Handling** - Standardize error response format +- [ ] **OpenAPI Docs** - Use `@hono/zod-openapi` for all routes + +--- + +## References + +- [Hono Documentation](https://hono.dev/docs/) +- [Zod OpenAPI Example](https://hono.dev/examples/zod-openapi) +- [@hono/zod-openapi](https://www.npmjs.com/package/@hono/zod-openapi) +- [Hono Best Practices](https://hono.dev/docs/guides/best-practices) +- [Hono JWT Helper](https://hono.dev/docs/helpers/jwt) +- [Hono Node.js Guide](https://hono.dev/docs/getting-started/nodejs) +- [Drizzle ORM](https://orm.drizzle.team/) diff --git a/docs/python-boilerplate-plan.md b/docs/python-boilerplate-plan.md new file mode 100644 index 0000000..8c633ec --- /dev/null +++ b/docs/python-boilerplate-plan.md @@ -0,0 +1,2175 @@ +# Python Boilerplate Plan + +This document outlines a plan to create a backend boilerplate using Python (FastAPI), preserving all architectural patterns and system design decisions from the NestJS boilerplate. + +--- + +## Technology Decisions + +### Framework: FastAPI + +**Why FastAPI over alternatives:** + +| Framework | Pros | Cons | Decision | +|-----------|------|------|----------| +| **FastAPI** | Async, OpenAPI built-in, Pydantic validation, dependency injection, type hints | Relatively new | **Selected** | +| Flask | Simple, mature ecosystem | No async, no built-in validation, manual OpenAPI | Not selected | +| Django | Batteries included, mature | Opinionated ORM, synchronous by default, heavy | Not selected | +| Litestar | Modern, performant | Smaller ecosystem than FastAPI | Not selected | + +**FastAPI aligns with our architecture because:** +- Built-in OpenAPI/Swagger (like `@hono/zod-openapi`) +- Pydantic for validation (like Zod) +- Dependency injection system (cleaner than NestJS DI) +- Async-first design (like Hono) +- Type hints throughout + +### ORM: SQLAlchemy 2.0 + +**Why SQLAlchemy 2.0:** +- Industry standard, mature, well-documented +- Excellent async support in 2.0 +- Works with Alembic for migrations +- `mapped_column` for clean typing +- Flexible query builder + +**Alternative considered:** SQLModel (combines SQLAlchemy + Pydantic) - simpler but less control. + +### Key Technology Stack + +``` +Framework: FastAPI 0.115+ +ORM: SQLAlchemy 2.0+ +Migrations: Alembic +Validation: Pydantic v2 +Auth: python-jose (JWT), httpx (JWKS) +Server: Uvicorn +Database: SQLite (dev), PostgreSQL (prod) +Testing: pytest, pytest-asyncio, httpx +``` + +--- + +## Pattern Mapping: NestJS/Hono β†’ Python/FastAPI + +| NestJS/Hono Concept | Python/FastAPI Equivalent | +|---------------------|---------------------------| +| `@hono/zod-openapi` | FastAPI + Pydantic (built-in) | +| `createRoute()` | `@router.get()` / `@router.post()` decorators | +| `c.set()` / `c.get()` | `contextvars` module | +| `createMiddleware()` | FastAPI Middleware or `Depends()` | +| Zod schemas | Pydantic `BaseModel` | +| TypeORM entities | SQLAlchemy models | +| TypeORM migrations | Alembic migrations | +| NestJS Guards | FastAPI dependencies with `Depends()` | +| NestJS Modules | Python packages | +| class-validator | Pydantic validators | +| CLS (nestjs-cls) | Python `contextvars` | +| TypeORM Subscribers | SQLAlchemy event listeners | + +--- + +## Project Structure + +``` +src/ +β”œβ”€β”€ app/ +β”‚ β”œβ”€β”€ __init__.py +β”‚ β”œβ”€β”€ main.py # FastAPI app factory +β”‚ β”œβ”€β”€ config.py # Pydantic Settings +β”‚ β”œβ”€β”€ dependencies.py # Shared dependencies +β”‚ β”‚ +β”‚ β”œβ”€β”€ middleware/ +β”‚ β”‚ β”œβ”€β”€ __init__.py +β”‚ β”‚ β”œβ”€β”€ request_context.py # Request ID, IP, User-Agent +β”‚ β”‚ β”œβ”€β”€ account.py # Account header validation +β”‚ β”‚ └── error_handler.py # Global exception handlers +β”‚ β”‚ +β”‚ β”œβ”€β”€ auth/ +β”‚ β”‚ β”œβ”€β”€ __init__.py +β”‚ β”‚ β”œβ”€β”€ jwt.py # JWT validation, JWKS +β”‚ β”‚ β”œβ”€β”€ dependencies.py # get_current_user, require_role +β”‚ β”‚ β”œβ”€β”€ roles.py # Role enum, hierarchy +β”‚ β”‚ β”œβ”€β”€ permissions.py # Permission enum, matrix +β”‚ β”‚ └── auth0_provider.py # Auth0 Management API +β”‚ β”‚ +β”‚ β”œβ”€β”€ db/ +β”‚ β”‚ β”œβ”€β”€ __init__.py +β”‚ β”‚ β”œβ”€β”€ session.py # Async session factory +β”‚ β”‚ β”œβ”€β”€ base.py # Base model classes +β”‚ β”‚ └── migrations/ # Alembic migrations +β”‚ β”‚ β”œβ”€β”€ versions/ +β”‚ β”‚ β”œβ”€β”€ env.py +β”‚ β”‚ └── alembic.ini +β”‚ β”‚ +β”‚ β”œβ”€β”€ models/ +β”‚ β”‚ β”œβ”€β”€ __init__.py +β”‚ β”‚ β”œβ”€β”€ user.py +β”‚ β”‚ β”œβ”€β”€ account.py +β”‚ β”‚ β”œβ”€β”€ user_account.py +β”‚ β”‚ └── audit_log.py +β”‚ β”‚ +β”‚ β”œβ”€β”€ schemas/ +β”‚ β”‚ β”œβ”€β”€ __init__.py +β”‚ β”‚ β”œβ”€β”€ user.py # Pydantic schemas +β”‚ β”‚ β”œβ”€β”€ account.py +β”‚ β”‚ β”œβ”€β”€ pagination.py +β”‚ β”‚ └── common.py +β”‚ β”‚ +β”‚ β”œβ”€β”€ services/ +β”‚ β”‚ β”œβ”€β”€ __init__.py +β”‚ β”‚ β”œβ”€β”€ user_service.py +β”‚ β”‚ β”œβ”€β”€ account_service.py +β”‚ β”‚ └── audit_service.py +β”‚ β”‚ +β”‚ β”œβ”€β”€ routes/ +β”‚ β”‚ β”œβ”€β”€ __init__.py +β”‚ β”‚ β”œβ”€β”€ router.py # Main router aggregation +β”‚ β”‚ β”œβ”€β”€ users.py +β”‚ β”‚ └── accounts.py +β”‚ β”‚ +β”‚ β”œβ”€β”€ context/ +β”‚ β”‚ β”œβ”€β”€ __init__.py +β”‚ β”‚ └── request_context.py # contextvars implementation +β”‚ β”‚ +β”‚ └── lib/ +β”‚ β”œβ”€β”€ __init__.py +β”‚ β”œβ”€β”€ pagination.py # Pagination utilities +β”‚ └── errors.py # Custom exceptions +β”‚ +β”œβ”€β”€ tests/ +β”‚ β”œβ”€β”€ __init__.py +β”‚ β”œβ”€β”€ conftest.py # Fixtures +β”‚ β”œβ”€β”€ test_users.py +β”‚ └── test_accounts.py +β”‚ +β”œβ”€β”€ scripts/ +β”‚ └── seed.py # Database seeding +β”‚ +β”œβ”€β”€ pyproject.toml # Project config (Poetry/uv) +β”œβ”€β”€ alembic.ini +β”œβ”€β”€ .env.example +└── README.md +``` + +--- + +## Core Dependencies + +```toml +# pyproject.toml +[project] +name = "etus-boilerplate" +version = "1.0.0" +requires-python = ">=3.11" + +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.30.0", + "sqlalchemy[asyncio]>=2.0.0", + "alembic>=1.13.0", + "aiosqlite>=0.19.0", # SQLite async driver + "asyncpg>=0.29.0", # PostgreSQL async driver + "pydantic>=2.0.0", + "pydantic-settings>=2.0.0", + "python-jose[cryptography]>=3.3.0", + "httpx>=0.27.0", # For JWKS fetching + "auth0-python>=4.0.0", # Auth0 Management API + "python-multipart>=0.0.9", # Form data support +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", + "httpx>=0.27.0", # Test client + "ruff>=0.4.0", # Linting + "mypy>=1.10.0", # Type checking +] +``` + +--- + +## Implementation Details + +### 1. Configuration with Pydantic Settings + +```python +# app/config.py +from pydantic_settings import BaseSettings, SettingsConfigDict +from functools import lru_cache + + +class Settings(BaseSettings): + """Application settings with environment variable validation.""" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + ) + + # Application + app_name: str = "Etus API" + debug: bool = False + port: int = 8000 + + # Database + database_url: str = "sqlite+aiosqlite:///./db.sqlite" + database_echo: bool = False + + # Auth0 / JWT + jwks_uri: str + idp_issuer: str + idp_audience: str + auth0_domain: str + auth0_client_id: str + auth0_client_secret: str + auth0_connection_id: str + auth0_region: str = "us" + + # Frontend + frontend_url: str = "http://localhost:3000" + + +@lru_cache +def get_settings() -> Settings: + return Settings() +``` + +### 2. Request Context with contextvars + +```python +# app/context/request_context.py +from contextvars import ContextVar +from dataclasses import dataclass +from typing import Optional +from uuid import uuid4 +import time + + +@dataclass +class RequestContext: + """Request-scoped context data.""" + request_id: str + transaction_id: str + ip_address: str + user_agent: str + account_id: Optional[str] = None + user_id: Optional[str] = None + user_email: Optional[str] = None + user_role: Optional[str] = None + is_super_admin: bool = False + is_system_admin_access: bool = False + timestamp: float = None + + def __post_init__(self): + if self.timestamp is None: + self.timestamp = time.time() + + +# Context variable for request-scoped data +_request_context: ContextVar[Optional[RequestContext]] = ContextVar( + "request_context", + default=None +) + + +def get_request_context() -> RequestContext: + """Get the current request context.""" + ctx = _request_context.get() + if ctx is None: + raise RuntimeError("Request context not initialized") + return ctx + + +def set_request_context(ctx: RequestContext) -> None: + """Set the current request context.""" + _request_context.set(ctx) + + +def create_request_context( + ip_address: str, + user_agent: str, +) -> RequestContext: + """Create a new request context.""" + from uuid import uuid4 + + # UUID v7 would be ideal, but uuid4 works + # Can use uuid7 library for sortable IDs + ctx = RequestContext( + request_id=str(uuid4()), + transaction_id=str(uuid4()), + ip_address=ip_address, + user_agent=user_agent or "", + ) + set_request_context(ctx) + return ctx +``` + +### 3. Database Setup with SQLAlchemy 2.0 + +```python +# app/db/session.py +from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_sessionmaker, + create_async_engine, +) +from sqlalchemy.orm import DeclarativeBase +from app.config import get_settings + + +settings = get_settings() + +engine = create_async_engine( + settings.database_url, + echo=settings.database_echo, + future=True, +) + +async_session_factory = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, + autoflush=False, +) + + +async def get_db() -> AsyncSession: + """Dependency for database session.""" + async with async_session_factory() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + finally: + await session.close() +``` + +### 4. Base Model Classes + +```python +# app/db/base.py +from datetime import datetime +from typing import Optional +from sqlalchemy import DateTime, String, ForeignKey, func +from sqlalchemy.orm import ( + DeclarativeBase, + Mapped, + mapped_column, + declared_attr, +) +from uuid import uuid4 + + +class Base(DeclarativeBase): + """Base class for all models.""" + pass + + +class SoftDeleteMixin: + """Mixin for soft delete support with timestamps.""" + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + deleted_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), + nullable=True, + default=None, + ) + + @property + def is_deleted(self) -> bool: + return self.deleted_at is not None + + +class InteractiveMixin(SoftDeleteMixin): + """Mixin for tracking who created/updated/deleted records.""" + + @declared_attr + def created_by_id(cls) -> Mapped[Optional[str]]: + return mapped_column( + String(36), + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ) + + @declared_attr + def updated_by_id(cls) -> Mapped[Optional[str]]: + return mapped_column( + String(36), + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ) + + @declared_attr + def deleted_by_id(cls) -> Mapped[Optional[str]]: + return mapped_column( + String(36), + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ) + + +def generate_uuid() -> str: + """Generate a UUID string.""" + return str(uuid4()) +``` + +### 5. Entity Models + +```python +# app/models/user.py +from typing import Optional, List, TYPE_CHECKING +from sqlalchemy import String, Boolean, JSON +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.db.base import Base, InteractiveMixin, generate_uuid + +if TYPE_CHECKING: + from app.models.user_account import UserAccount + + +class User(Base, InteractiveMixin): + """User entity with multi-provider support.""" + + __tablename__ = "users" + + id: Mapped[str] = mapped_column( + String(36), + primary_key=True, + default=generate_uuid, + ) + + email: Mapped[str] = mapped_column( + String(255), + unique=True, + nullable=False, + index=True, + ) + + name: Mapped[str] = mapped_column( + String(255), + nullable=False, + ) + + profile_image: Mapped[Optional[str]] = mapped_column( + String(500), + nullable=True, + ) + + status: Mapped[str] = mapped_column( + String(50), + default="pending", + nullable=False, + ) + + # Array of identity provider IDs (e.g., auth0|xxx, google-oauth2|xxx) + provider_ids: Mapped[List[str]] = mapped_column( + JSON, + default=list, + nullable=False, + ) + + is_super_admin: Mapped[bool] = mapped_column( + Boolean, + default=False, + nullable=False, + ) + + # Relationships + user_accounts: Mapped[List["UserAccount"]] = relationship( + "UserAccount", + back_populates="user", + lazy="selectin", # Eager load + ) + + def has_provider(self, provider_id: str) -> bool: + """Check if user has a specific provider ID.""" + return provider_id in self.provider_ids + + def add_provider(self, provider_id: str) -> None: + """Add a provider ID to the user.""" + if provider_id not in self.provider_ids: + self.provider_ids = [*self.provider_ids, provider_id] + + def remove_provider(self, provider_id: str) -> None: + """Remove a provider ID from the user.""" + self.provider_ids = [p for p in self.provider_ids if p != provider_id] +``` + +```python +# app/models/account.py +from typing import Optional +from sqlalchemy import String +from sqlalchemy.orm import Mapped, mapped_column +from app.db.base import Base, SoftDeleteMixin, generate_uuid + + +class Account(Base, SoftDeleteMixin): + """Tenant/Organization entity.""" + + __tablename__ = "accounts" + + id: Mapped[str] = mapped_column( + String(36), + primary_key=True, + default=generate_uuid, + ) + + name: Mapped[str] = mapped_column( + String(255), + unique=True, + nullable=False, + ) + + description: Mapped[Optional[str]] = mapped_column( + String(1000), + nullable=True, + ) + + domain: Mapped[Optional[str]] = mapped_column( + String(255), + unique=True, + nullable=True, + ) +``` + +```python +# app/models/user_account.py +from typing import TYPE_CHECKING +from sqlalchemy import String, ForeignKey, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.db.base import Base +from app.auth.roles import Role + +if TYPE_CHECKING: + from app.models.user import User + from app.models.account import Account + + +class UserAccount(Base): + """Junction table for user-account relationships with role.""" + + __tablename__ = "user_accounts" + __table_args__ = ( + UniqueConstraint("account_id", "user_id", name="uq_account_user"), + ) + + id: Mapped[str] = mapped_column( + String(36), + primary_key=True, + ) + + user_id: Mapped[str] = mapped_column( + String(36), + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + account_id: Mapped[str] = mapped_column( + String(36), + ForeignKey("accounts.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + role: Mapped[str] = mapped_column( + String(50), + default=Role.VIEWER, + nullable=False, + ) + + # Relationships + user: Mapped["User"] = relationship( + "User", + back_populates="user_accounts", + ) + + account: Mapped["Account"] = relationship( + "Account", + lazy="selectin", + ) +``` + +```python +# app/models/audit_log.py +from datetime import datetime +from typing import Optional, Any +from sqlalchemy import String, DateTime, JSON, ForeignKey, func +from sqlalchemy.orm import Mapped, mapped_column +from app.db.base import Base, generate_uuid + + +class AuditLog(Base): + """Immutable audit trail for entity changes.""" + + __tablename__ = "audit_logs" + + id: Mapped[str] = mapped_column( + String(36), + primary_key=True, + default=generate_uuid, + ) + + transaction_id: Mapped[str] = mapped_column( + String(36), + nullable=False, + index=True, + ) + + account_id: Mapped[Optional[str]] = mapped_column( + String(36), + ForeignKey("accounts.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + + user_id: Mapped[Optional[str]] = mapped_column( + String(36), + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ) + + entity: Mapped[str] = mapped_column( + String(100), + nullable=False, + ) + + entity_id: Mapped[str] = mapped_column( + String(36), + nullable=False, + ) + + action: Mapped[str] = mapped_column( + String(20), # INSERT, UPDATE, DELETE + nullable=False, + ) + + changes: Mapped[Optional[dict[str, Any]]] = mapped_column( + JSON, + nullable=True, + ) + + ip_address: Mapped[Optional[str]] = mapped_column( + String(45), # IPv6 max length + nullable=True, + ) + + user_agent: Mapped[Optional[str]] = mapped_column( + String(500), + nullable=True, + ) + + timestamp: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) +``` + +### 6. Roles and Permissions + +```python +# app/auth/roles.py +from enum import Enum +from typing import List, Optional + + +class Role(str, Enum): + """User roles with hierarchy.""" + ADMIN = "ADMIN" + MANAGER = "MANAGER" + EDITOR = "EDITOR" + AUTHOR = "AUTHOR" + VIEWER = "VIEWER" + BILLING = "BILLING" + ANALYTICS = "ANALYTICS" + + +# Role hierarchy: lower number = higher privilege +ROLE_HIERARCHY: dict[Role, int] = { + Role.ADMIN: 0, + Role.MANAGER: 1, + Role.EDITOR: 2, + Role.AUTHOR: 3, + Role.VIEWER: 4, + Role.BILLING: -1, # Non-hierarchical + Role.ANALYTICS: -1, # Non-hierarchical +} + + +def has_minimum_role( + user_role: Role, + required_role: Role, + additional_roles: Optional[List[Role]] = None, +) -> bool: + """ + Check if user has minimum required role. + + Hierarchical roles: ADMIN > MANAGER > EDITOR > AUTHOR > VIEWER + Special roles (BILLING, ANALYTICS) are non-hierarchical. + """ + # Check non-hierarchical additional roles first + if additional_roles and user_role in additional_roles: + return True + + user_level = ROLE_HIERARCHY.get(user_role, 999) + required_level = ROLE_HIERARCHY.get(required_role, 999) + + # Special roles (-1) can't access hierarchical endpoints + if user_level == -1: + return False + if required_level == -1: + return False + + # Lower number = higher privilege + return user_level <= required_level + + +def get_role_display_name(role: Role) -> str: + """Get human-readable role name.""" + return role.value.replace("_", " ").title() +``` + +```python +# app/auth/permissions.py +from enum import Enum +from typing import List +from app.auth.roles import Role + + +class Permission(str, Enum): + """Fine-grained permissions.""" + # System + MANAGE_SYSTEM_SETTINGS = "MANAGE_SYSTEM_SETTINGS" + + # Users + MANAGE_ALL_USERS = "MANAGE_ALL_USERS" + MANAGE_TEAM_USERS = "MANAGE_TEAM_USERS" + VIEW_ALL_USERS = "VIEW_ALL_USERS" + + # Content + CREATE_CONTENT = "CREATE_CONTENT" + EDIT_ALL_CONTENT = "EDIT_ALL_CONTENT" + EDIT_OWN_CONTENT = "EDIT_OWN_CONTENT" + DELETE_CONTENT = "DELETE_CONTENT" + PUBLISH_CONTENT = "PUBLISH_CONTENT" + VIEW_CONTENT = "VIEW_CONTENT" + + # Analytics & Billing + VIEW_ANALYTICS = "VIEW_ANALYTICS" + MANAGE_BILLING = "MANAGE_BILLING" + VIEW_BILLING = "VIEW_BILLING" + + +# Permission matrix: role -> list of permissions +ROLE_PERMISSIONS: dict[Role, List[Permission]] = { + Role.ADMIN: [ + Permission.MANAGE_SYSTEM_SETTINGS, + Permission.MANAGE_ALL_USERS, + Permission.CREATE_CONTENT, + Permission.EDIT_ALL_CONTENT, + Permission.DELETE_CONTENT, + Permission.PUBLISH_CONTENT, + Permission.VIEW_CONTENT, + Permission.VIEW_ANALYTICS, + Permission.MANAGE_BILLING, + Permission.VIEW_BILLING, + ], + + Role.MANAGER: [ + Permission.MANAGE_TEAM_USERS, + Permission.VIEW_ALL_USERS, + Permission.CREATE_CONTENT, + Permission.EDIT_ALL_CONTENT, + Permission.PUBLISH_CONTENT, + Permission.VIEW_CONTENT, + Permission.VIEW_ANALYTICS, + ], + + Role.EDITOR: [ + Permission.CREATE_CONTENT, + Permission.EDIT_ALL_CONTENT, + Permission.PUBLISH_CONTENT, + Permission.VIEW_CONTENT, + ], + + Role.AUTHOR: [ + Permission.CREATE_CONTENT, + Permission.EDIT_OWN_CONTENT, + Permission.VIEW_CONTENT, + ], + + Role.VIEWER: [ + Permission.VIEW_CONTENT, + ], + + Role.BILLING: [ + Permission.MANAGE_BILLING, + Permission.VIEW_BILLING, + ], + + Role.ANALYTICS: [ + Permission.VIEW_ANALYTICS, + ], +} + + +def has_permission(role: Role, permission: Permission) -> bool: + """Check if role has a specific permission.""" + return permission in ROLE_PERMISSIONS.get(role, []) + + +def has_any_permission(role: Role, permissions: List[Permission]) -> bool: + """Check if role has any of the given permissions.""" + role_perms = ROLE_PERMISSIONS.get(role, []) + return any(p in role_perms for p in permissions) + + +def has_all_permissions(role: Role, permissions: List[Permission]) -> bool: + """Check if role has all of the given permissions.""" + role_perms = ROLE_PERMISSIONS.get(role, []) + return all(p in role_perms for p in permissions) +``` + +### 7. JWT Authentication + +```python +# app/auth/jwt.py +from datetime import datetime, timezone +from typing import Optional, Any +import httpx +from jose import jwt, JWTError, jwk +from jose.exceptions import JWKError +from pydantic import BaseModel +from functools import lru_cache +from app.config import get_settings + + +class TokenPayload(BaseModel): + """Decoded JWT token payload.""" + sub: str # Subject (user ID in Auth0) + iss: str # Issuer + aud: list[str] | str # Audience + exp: int # Expiration + iat: int # Issued at + email: Optional[str] = None + permissions: list[str] = [] + identities: list[str] = [] + + # Custom claims (namespace varies) + roles: list[str] = [] + + class Config: + extra = "allow" # Allow additional claims + + +class JWKSClient: + """Client for fetching and caching JWKS keys.""" + + def __init__(self, jwks_uri: str, cache_ttl: int = 3600): + self.jwks_uri = jwks_uri + self.cache_ttl = cache_ttl + self._keys: dict[str, Any] = {} + self._last_fetch: Optional[datetime] = None + + async def get_signing_key(self, kid: str) -> Any: + """Get signing key by key ID.""" + await self._refresh_keys_if_needed() + + if kid not in self._keys: + # Force refresh if key not found + await self._fetch_keys() + + if kid not in self._keys: + raise JWKError(f"Key {kid} not found in JWKS") + + return self._keys[kid] + + async def _refresh_keys_if_needed(self) -> None: + """Refresh keys if cache is stale.""" + now = datetime.now(timezone.utc) + + if self._last_fetch is None: + await self._fetch_keys() + elif (now - self._last_fetch).total_seconds() > self.cache_ttl: + await self._fetch_keys() + + async def _fetch_keys(self) -> None: + """Fetch keys from JWKS endpoint.""" + async with httpx.AsyncClient() as client: + response = await client.get(self.jwks_uri) + response.raise_for_status() + + jwks = response.json() + + self._keys = {} + for key_data in jwks.get("keys", []): + if key_data.get("use") == "sig": + kid = key_data.get("kid") + if kid: + self._keys[kid] = jwk.construct(key_data) + + self._last_fetch = datetime.now(timezone.utc) + + +# Singleton JWKS client +@lru_cache +def get_jwks_client() -> JWKSClient: + settings = get_settings() + return JWKSClient(settings.jwks_uri) + + +async def decode_token(token: str) -> TokenPayload: + """Decode and validate a JWT token.""" + settings = get_settings() + jwks_client = get_jwks_client() + + try: + # Get unverified header to find key ID + unverified_header = jwt.get_unverified_header(token) + kid = unverified_header.get("kid") + + if not kid: + raise JWTError("Token missing key ID") + + # Get signing key + signing_key = await jwks_client.get_signing_key(kid) + + # Decode and verify token + payload = jwt.decode( + token, + signing_key, + algorithms=["RS256"], + audience=settings.idp_audience, + issuer=settings.idp_issuer, + ) + + # Extract roles from custom claim namespace + roles_claim = settings.auth0_roles_name if hasattr(settings, 'auth0_roles_name') else None + roles = [] + if roles_claim and roles_claim in payload: + roles = payload[roles_claim] + + return TokenPayload( + sub=payload["sub"], + iss=payload["iss"], + aud=payload.get("aud", []), + exp=payload["exp"], + iat=payload["iat"], + email=payload.get("email"), + permissions=payload.get("permissions", []), + identities=payload.get("identities", []), + roles=roles, + ) + + except JWTError as e: + raise ValueError(f"Invalid token: {e}") +``` + +### 8. Authentication Dependencies + +```python +# app/auth/dependencies.py +from typing import Optional, List, Annotated +from fastapi import Depends, HTTPException, status, Header +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.db.session import get_db +from app.models.user import User +from app.models.user_account import UserAccount +from app.auth.jwt import decode_token, TokenPayload +from app.auth.roles import Role, has_minimum_role +from app.auth.permissions import Permission, has_permission +from app.context.request_context import get_request_context, RequestContext + + +security = HTTPBearer(auto_error=False) + + +async def get_token_payload( + credentials: Annotated[ + Optional[HTTPAuthorizationCredentials], + Depends(security) + ], +) -> TokenPayload: + """Extract and validate JWT token from Authorization header.""" + if credentials is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing authorization header", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + payload = await decode_token(credentials.credentials) + return payload + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=str(e), + headers={"WWW-Authenticate": "Bearer"}, + ) + + +async def get_current_user( + token: Annotated[TokenPayload, Depends(get_token_payload)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> User: + """Get current user from database based on JWT token.""" + # Find user by provider ID + result = await db.execute( + select(User).where( + User.provider_ids.contains([token.sub]) + ) + ) + user = result.scalar_one_or_none() + + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found", + ) + + if user.deleted_at is not None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User account is deactivated", + ) + + return user + + +async def get_account_id( + account_id: Annotated[Optional[str], Header(alias="account-id")] = None, +) -> str: + """Extract and validate account-id header.""" + if not account_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="account-id header is required", + ) + return account_id + + +async def get_user_account( + user: Annotated[User, Depends(get_current_user)], + account_id: Annotated[str, Depends(get_account_id)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> Optional[UserAccount]: + """Get user's membership in the specified account.""" + # Super admin bypass - they have access to all accounts + if user.is_super_admin: + return None # No specific membership needed + + result = await db.execute( + select(UserAccount).where( + UserAccount.user_id == user.id, + UserAccount.account_id == account_id, + ) + ) + return result.scalar_one_or_none() + + +async def get_current_user_with_account( + user: Annotated[User, Depends(get_current_user)], + account_id: Annotated[str, Depends(get_account_id)], + user_account: Annotated[Optional[UserAccount], Depends(get_user_account)], +) -> tuple[User, str, Optional[Role]]: + """ + Get current user with account context. + Returns (user, account_id, role). + """ + # Super admin bypass + if user.is_super_admin: + # Update request context + ctx = get_request_context() + ctx.user_id = user.id + ctx.user_email = user.email + ctx.account_id = account_id + ctx.is_super_admin = True + ctx.is_system_admin_access = True + ctx.user_role = Role.ADMIN.value + + return (user, account_id, Role.ADMIN) + + # Check membership + if user_account is None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="No access to this account", + ) + + role = Role(user_account.role) + + # Update request context + ctx = get_request_context() + ctx.user_id = user.id + ctx.user_email = user.email + ctx.account_id = account_id + ctx.user_role = role.value + + return (user, account_id, role) + + +def require_role( + min_role: Role, + additional_roles: Optional[List[Role]] = None, +): + """ + Dependency factory for role-based access control. + + Usage: + @router.get("/admin-only") + async def admin_endpoint( + auth: Annotated[tuple, Depends(require_role(Role.ADMIN))] + ): + user, account_id, role = auth + ... + """ + async def dependency( + auth: Annotated[ + tuple[User, str, Optional[Role]], + Depends(get_current_user_with_account) + ], + ) -> tuple[User, str, Role]: + user, account_id, role = auth + + # Super admin always passes + if user.is_super_admin: + return (user, account_id, Role.ADMIN) + + if role is None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="No role assigned", + ) + + if not has_minimum_role(role, min_role, additional_roles): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Requires {min_role.value} role or higher", + ) + + return (user, account_id, role) + + return dependency + + +def require_permission(permission: Permission): + """ + Dependency factory for permission-based access control. + + Usage: + @router.delete("/content/{id}") + async def delete_content( + auth: Annotated[tuple, Depends(require_permission(Permission.DELETE_CONTENT))] + ): + user, account_id, role = auth + ... + """ + async def dependency( + auth: Annotated[ + tuple[User, str, Optional[Role]], + Depends(get_current_user_with_account) + ], + ) -> tuple[User, str, Role]: + user, account_id, role = auth + + # Super admin always passes + if user.is_super_admin: + return (user, account_id, Role.ADMIN) + + if role is None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="No role assigned", + ) + + if not has_permission(role, permission): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Missing permission: {permission.value}", + ) + + return (user, account_id, role) + + return dependency +``` + +### 9. Middleware + +```python +# app/middleware/request_context.py +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response +from app.context.request_context import create_request_context + + +class RequestContextMiddleware(BaseHTTPMiddleware): + """Middleware to initialize request context for each request.""" + + async def dispatch(self, request: Request, call_next) -> Response: + # Get client IP (handle proxies) + forwarded_for = request.headers.get("x-forwarded-for") + if forwarded_for: + ip_address = forwarded_for.split(",")[0].strip() + else: + ip_address = request.client.host if request.client else "unknown" + + # Get user agent + user_agent = request.headers.get("user-agent", "") + + # Create request context + create_request_context( + ip_address=ip_address, + user_agent=user_agent, + ) + + response = await call_next(request) + return response +``` + +```python +# app/middleware/error_handler.py +from fastapi import Request, status +from fastapi.responses import JSONResponse +from fastapi.exceptions import RequestValidationError +from starlette.exceptions import HTTPException as StarletteHTTPException +import logging + +logger = logging.getLogger(__name__) + + +async def http_exception_handler( + request: Request, + exc: StarletteHTTPException, +) -> JSONResponse: + """Handle HTTP exceptions.""" + return JSONResponse( + status_code=exc.status_code, + content={ + "statusCode": exc.status_code, + "message": exc.detail, + "error": _get_error_name(exc.status_code), + }, + ) + + +async def validation_exception_handler( + request: Request, + exc: RequestValidationError, +) -> JSONResponse: + """Handle Pydantic validation errors.""" + errors = [] + for error in exc.errors(): + field = ".".join(str(loc) for loc in error["loc"]) + errors.append(f"{field}: {error['msg']}") + + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={ + "statusCode": 400, + "message": errors, + "error": "Bad Request", + }, + ) + + +async def general_exception_handler( + request: Request, + exc: Exception, +) -> JSONResponse: + """Handle unhandled exceptions.""" + logger.exception("Unhandled exception", exc_info=exc) + + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={ + "statusCode": 500, + "message": "Internal server error", + "error": "Internal Server Error", + }, + ) + + +def _get_error_name(status_code: int) -> str: + """Get error name for status code.""" + names = { + 400: "Bad Request", + 401: "Unauthorized", + 403: "Forbidden", + 404: "Not Found", + 409: "Conflict", + 422: "Unprocessable Entity", + 500: "Internal Server Error", + } + return names.get(status_code, "Error") +``` + +### 10. Pydantic Schemas + +```python +# app/schemas/common.py +from typing import TypeVar, Generic, List, Optional +from pydantic import BaseModel, ConfigDict + + +T = TypeVar("T") + + +class PaginationMeta(BaseModel): + """Pagination metadata.""" + current_page: int + limit: int + total_items: int + total_pages: int + has_previous_page: bool + has_next_page: bool + + +class PaginatedResponse(BaseModel, Generic[T]): + """Generic paginated response.""" + data: List[T] + meta: PaginationMeta + + +def create_pagination_meta( + total_items: int, + page: int, + limit: int, +) -> PaginationMeta: + """Create pagination metadata.""" + total_pages = (total_items + limit - 1) // limit if limit > 0 else 0 + + return PaginationMeta( + current_page=page, + limit=limit, + total_items=total_items, + total_pages=total_pages, + has_previous_page=page > 1, + has_next_page=page < total_pages, + ) +``` + +```python +# app/schemas/pagination.py +from typing import Optional, Literal +from pydantic import BaseModel, Field + + +class PaginationQuery(BaseModel): + """Query parameters for pagination.""" + page: int = Field(default=1, ge=1, description="Page number") + limit: int = Field(default=50, ge=1, le=100, description="Items per page") + sort_by: Optional[str] = Field(default=None, description="Field to sort by") + sort_order: Literal["asc", "desc"] = Field(default="desc", description="Sort order") +``` + +```python +# app/schemas/user.py +from typing import Optional, List +from datetime import datetime +from pydantic import BaseModel, EmailStr, Field, ConfigDict + + +class UserAccountSchema(BaseModel): + """User's account membership.""" + account_id: str + role: str + + model_config = ConfigDict(from_attributes=True) + + +class UserBase(BaseModel): + """Base user schema.""" + email: EmailStr + name: str = Field(..., min_length=1, max_length=255) + profile_image: Optional[str] = None + + +class UserCreate(UserBase): + """Schema for creating a user.""" + account_ids: Optional[List[str]] = None + + +class UserUpdate(BaseModel): + """Schema for updating a user.""" + name: Optional[str] = Field(None, min_length=1, max_length=255) + profile_image: Optional[str] = None + status: Optional[str] = None + + +class UserResponse(UserBase): + """Schema for user response.""" + id: str + status: str + is_super_admin: bool + created_at: datetime + updated_at: datetime + user_accounts: List[UserAccountSchema] = [] + + model_config = ConfigDict(from_attributes=True) + + +class UserBrief(BaseModel): + """Brief user info for lists.""" + id: str + email: str + name: str + status: str + + model_config = ConfigDict(from_attributes=True) +``` + +```python +# app/schemas/account.py +from typing import Optional +from datetime import datetime +from pydantic import BaseModel, Field, ConfigDict + + +class AccountBase(BaseModel): + """Base account schema.""" + name: str = Field(..., min_length=1, max_length=255) + description: Optional[str] = Field(None, max_length=1000) + domain: Optional[str] = Field(None, max_length=255) + + +class AccountCreate(AccountBase): + """Schema for creating an account.""" + pass + + +class AccountUpdate(BaseModel): + """Schema for updating an account.""" + name: Optional[str] = Field(None, min_length=1, max_length=255) + description: Optional[str] = Field(None, max_length=1000) + domain: Optional[str] = Field(None, max_length=255) + + +class AccountResponse(AccountBase): + """Schema for account response.""" + id: str + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) +``` + +### 11. Services + +```python +# app/services/user_service.py +from typing import Optional, List, Tuple +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, or_ +from sqlalchemy.orm import selectinload + +from app.models.user import User +from app.models.user_account import UserAccount +from app.models.account import Account +from app.schemas.user import UserCreate, UserUpdate +from app.schemas.pagination import PaginationQuery +from app.schemas.common import create_pagination_meta, PaginationMeta +from app.auth.roles import Role +from app.context.request_context import get_request_context + + +class UserService: + """Service for user operations.""" + + def __init__(self, db: AsyncSession): + self.db = db + + async def find_by_id(self, user_id: str) -> Optional[User]: + """Find user by ID.""" + result = await self.db.execute( + select(User) + .options(selectinload(User.user_accounts)) + .where(User.id == user_id, User.deleted_at.is_(None)) + ) + return result.scalar_one_or_none() + + async def find_by_email(self, email: str) -> Optional[User]: + """Find user by email.""" + result = await self.db.execute( + select(User) + .where(User.email == email, User.deleted_at.is_(None)) + ) + return result.scalar_one_or_none() + + async def find_by_provider_id(self, provider_id: str) -> Optional[User]: + """Find user by provider ID.""" + # SQLite uses JSON, need to check if array contains value + result = await self.db.execute( + select(User) + .options(selectinload(User.user_accounts)) + .where( + User.provider_ids.contains([provider_id]), + User.deleted_at.is_(None), + ) + ) + return result.scalar_one_or_none() + + async def find_all_paginated( + self, + pagination: PaginationQuery, + account_id: Optional[str] = None, + is_super_admin: bool = False, + ) -> Tuple[List[User], PaginationMeta]: + """Find all users with pagination.""" + query = ( + select(User) + .options(selectinload(User.user_accounts)) + .where(User.deleted_at.is_(None)) + ) + + # Filter by account if not super admin + if not is_super_admin and account_id: + query = query.join(UserAccount).where( + UserAccount.account_id == account_id + ) + + # Count total + count_query = select(func.count()).select_from(query.subquery()) + total_result = await self.db.execute(count_query) + total_items = total_result.scalar() or 0 + + # Apply sorting + sort_column = getattr(User, pagination.sort_by or "created_at", User.created_at) + if pagination.sort_order == "asc": + query = query.order_by(sort_column.asc()) + else: + query = query.order_by(sort_column.desc()) + + # Apply pagination + offset = (pagination.page - 1) * pagination.limit + query = query.offset(offset).limit(pagination.limit) + + result = await self.db.execute(query) + users = list(result.scalars().all()) + + meta = create_pagination_meta(total_items, pagination.page, pagination.limit) + + return users, meta + + async def create( + self, + data: UserCreate, + actor: Optional[User] = None, + ) -> User: + """Create a new user.""" + user = User( + email=data.email, + name=data.name, + profile_image=data.profile_image, + status="pending", + provider_ids=[], + created_by_id=actor.id if actor else None, + ) + + self.db.add(user) + await self.db.flush() + + # Create user-account relationships + if data.account_ids: + for account_id in data.account_ids: + user_account = UserAccount( + id=str(uuid4()), + user_id=user.id, + account_id=account_id, + role=Role.VIEWER.value, + ) + self.db.add(user_account) + + await self.db.flush() + await self.db.refresh(user) + + return user + + async def update( + self, + user: User, + data: UserUpdate, + actor: Optional[User] = None, + ) -> User: + """Update an existing user.""" + update_data = data.model_dump(exclude_unset=True) + + for field, value in update_data.items(): + setattr(user, field, value) + + user.updated_by_id = actor.id if actor else None + + await self.db.flush() + await self.db.refresh(user) + + return user + + async def soft_delete( + self, + user: User, + actor: Optional[User] = None, + ) -> None: + """Soft delete a user.""" + from datetime import datetime, timezone + + user.deleted_at = datetime.now(timezone.utc) + user.deleted_by_id = actor.id if actor else None + + await self.db.flush() + + +from uuid import uuid4 +``` + +### 12. Routes + +```python +# app/routes/users.py +from typing import Annotated, List +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.session import get_db +from app.models.user import User +from app.schemas.user import UserCreate, UserUpdate, UserResponse, UserBrief +from app.schemas.pagination import PaginationQuery +from app.schemas.common import PaginatedResponse +from app.services.user_service import UserService +from app.auth.dependencies import require_role, get_current_user_with_account +from app.auth.roles import Role + + +router = APIRouter(prefix="/users", tags=["Users"]) + + +@router.get( + "", + response_model=PaginatedResponse[UserBrief], + summary="List users", + description="Get paginated list of users. Requires MANAGER role or higher.", +) +async def list_users( + auth: Annotated[tuple[User, str, Role], Depends(require_role(Role.MANAGER))], + db: Annotated[AsyncSession, Depends(get_db)], + page: int = Query(default=1, ge=1), + limit: int = Query(default=50, ge=1, le=100), + sort_by: str = Query(default="created_at"), + sort_order: str = Query(default="desc", pattern="^(asc|desc)$"), +): + """List all users with pagination.""" + user, account_id, role = auth + + service = UserService(db) + pagination = PaginationQuery( + page=page, + limit=limit, + sort_by=sort_by, + sort_order=sort_order, + ) + + users, meta = await service.find_all_paginated( + pagination=pagination, + account_id=account_id, + is_super_admin=user.is_super_admin, + ) + + return PaginatedResponse( + data=[UserBrief.model_validate(u) for u in users], + meta=meta, + ) + + +@router.get( + "/{user_id}", + response_model=UserResponse, + summary="Get user by ID", + description="Get a specific user by ID. Requires MANAGER role or higher.", +) +async def get_user( + user_id: str, + auth: Annotated[tuple[User, str, Role], Depends(require_role(Role.MANAGER))], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Get a specific user by ID.""" + service = UserService(db) + user = await service.find_by_id(user_id) + + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + + return UserResponse.model_validate(user) + + +@router.post( + "", + response_model=UserResponse, + status_code=status.HTTP_201_CREATED, + summary="Create user", + description="Create a new user. Requires ADMIN role.", +) +async def create_user( + data: UserCreate, + auth: Annotated[tuple[User, str, Role], Depends(require_role(Role.ADMIN))], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Create a new user.""" + actor, account_id, role = auth + + service = UserService(db) + + # Check if email already exists + existing = await service.find_by_email(data.email) + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="User with this email already exists", + ) + + user = await service.create(data, actor) + + # TODO: Send invitation via Auth0 + + return UserResponse.model_validate(user) + + +@router.put( + "/{user_id}", + response_model=UserResponse, + summary="Update user", + description="Update an existing user. Requires ADMIN role.", +) +async def update_user( + user_id: str, + data: UserUpdate, + auth: Annotated[tuple[User, str, Role], Depends(require_role(Role.ADMIN))], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Update an existing user.""" + actor, account_id, role = auth + + service = UserService(db) + user = await service.find_by_id(user_id) + + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + + updated_user = await service.update(user, data, actor) + return UserResponse.model_validate(updated_user) + + +@router.delete( + "/{user_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete user", + description="Soft delete a user. Requires ADMIN role.", +) +async def delete_user( + user_id: str, + auth: Annotated[tuple[User, str, Role], Depends(require_role(Role.ADMIN))], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Soft delete a user.""" + actor, account_id, role = auth + + service = UserService(db) + user = await service.find_by_id(user_id) + + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + + await service.soft_delete(user, actor) +``` + +```python +# app/routes/router.py +from fastapi import APIRouter +from app.routes import users, accounts + +api_router = APIRouter(prefix="/api/v1") + +api_router.include_router(users.router) +api_router.include_router(accounts.router) +``` + +### 13. Main Application + +```python +# app/main.py +from contextlib import asynccontextmanager +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.exceptions import RequestValidationError +from starlette.exceptions import HTTPException as StarletteHTTPException + +from app.config import get_settings +from app.routes.router import api_router +from app.middleware.request_context import RequestContextMiddleware +from app.middleware.error_handler import ( + http_exception_handler, + validation_exception_handler, + general_exception_handler, +) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Application lifespan events.""" + # Startup + settings = get_settings() + print(f"Starting {settings.app_name}") + + yield + + # Shutdown + print("Shutting down") + + +def create_app() -> FastAPI: + """Application factory.""" + settings = get_settings() + + app = FastAPI( + title=settings.app_name, + version="1.0.0", + docs_url="/api/docs", + redoc_url="/api/redoc", + openapi_url="/api/openapi.json", + lifespan=lifespan, + ) + + # CORS + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # Configure for production + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # Request context middleware + app.add_middleware(RequestContextMiddleware) + + # Exception handlers + app.add_exception_handler(StarletteHTTPException, http_exception_handler) + app.add_exception_handler(RequestValidationError, validation_exception_handler) + app.add_exception_handler(Exception, general_exception_handler) + + # Routes + app.include_router(api_router) + + # Health check + @app.get("/health", tags=["Health"]) + async def health_check(): + return {"status": "ok"} + + return app + + +app = create_app() + + +if __name__ == "__main__": + import uvicorn + + settings = get_settings() + uvicorn.run( + "app.main:app", + host="0.0.0.0", + port=settings.port, + reload=settings.debug, + ) +``` + +### 14. Audit Logging with SQLAlchemy Events + +```python +# app/services/audit_service.py +from typing import Any, Optional +from sqlalchemy import event +from sqlalchemy.orm import Session, Mapper +from sqlalchemy.orm.attributes import get_history + +from app.models.audit_log import AuditLog +from app.context.request_context import get_request_context +from app.db.base import generate_uuid + + +def get_changes(instance: Any) -> dict[str, Any]: + """Get changed attributes of an instance.""" + changes = {} + + for attr in instance.__mapper__.columns.keys(): + history = get_history(instance, attr) + + if history.has_changes(): + old_value = history.deleted[0] if history.deleted else None + new_value = history.added[0] if history.added else None + + changes[attr] = { + "old": old_value, + "new": new_value, + } + + return changes + + +def create_audit_log( + session: Session, + entity: str, + entity_id: str, + action: str, + changes: Optional[dict] = None, +) -> None: + """Create an audit log entry.""" + try: + ctx = get_request_context() + + audit_log = AuditLog( + id=generate_uuid(), + transaction_id=ctx.transaction_id, + account_id=ctx.account_id, + user_id=ctx.user_id, + entity=entity, + entity_id=entity_id, + action=action, + changes=changes, + ip_address=ctx.ip_address, + user_agent=ctx.user_agent, + ) + + session.add(audit_log) + except RuntimeError: + # No request context (e.g., during migrations) + pass + + +def setup_audit_listeners(Base) -> None: + """Set up SQLAlchemy event listeners for auditing.""" + + @event.listens_for(Base, "after_insert", propagate=True) + def after_insert(mapper: Mapper, connection, target): + if target.__tablename__ == "audit_logs": + return # Don't audit the audit log itself + + # Create audit log in a new session + # Note: In production, use async-compatible approach + pass + + @event.listens_for(Base, "after_update", propagate=True) + def after_update(mapper: Mapper, connection, target): + if target.__tablename__ == "audit_logs": + return + + changes = get_changes(target) + if changes: + pass # Create audit log + + @event.listens_for(Base, "after_delete", propagate=True) + def after_delete(mapper: Mapper, connection, target): + if target.__tablename__ == "audit_logs": + return + + pass # Create audit log +``` + +--- + +## Alembic Migration Setup + +```python +# alembic/env.py +import asyncio +from logging.config import fileConfig +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config +from alembic import context + +from app.config import get_settings +from app.db.base import Base +from app.models import user, account, user_account, audit_log # Import all models + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + +settings = get_settings() +config.set_main_option("sqlalchemy.url", settings.database_url) + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode.""" + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """Run migrations in 'online' mode with async engine.""" + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() +``` + +--- + +## What's Preserved from NestJS Boilerplate + +| Concept | Python/FastAPI Equivalent | Status | +|---------|---------------------------|--------| +| Entity model (User, Account, etc.) | SQLAlchemy models | Preserved | +| Multi-tenant via `account-id` header | FastAPI dependency | Preserved | +| Role hierarchy + permissions | Python enums + functions | Preserved | +| Super-admin bypass | Dependency check | Preserved | +| Soft deletes | Mixin with deleted_at | Preserved | +| Request context (CLS) | Python contextvars | Preserved | +| JWT auth with JWKS | python-jose + httpx | Preserved | +| OpenAPI docs | FastAPI built-in | Preserved | +| Validation | Pydantic v2 | Preserved | +| Pagination | Pydantic schemas | Preserved | +| Audit logging | SQLAlchemy events | Preserved | +| Base entity inheritance | SQLAlchemy mixins | Preserved | + +--- + +## Key Differences from Hono Implementation + +| Aspect | Hono | FastAPI | +|--------|------|---------| +| Routing | `createRoute()` + `app.openapi()` | Decorators `@router.get()` | +| Validation | Zod schemas | Pydantic models | +| Context | `c.set()` / `c.get()` | `contextvars` module | +| Dependencies | Middleware functions | `Depends()` injection | +| ORM | Drizzle | SQLAlchemy 2.0 | +| Migrations | Drizzle Kit | Alembic | +| Guards | Middleware | Dependencies with Depends() | +| OpenAPI | `@hono/zod-openapi` | Built-in FastAPI | + +--- + +## Implementation Checklist + +- [ ] Project setup with pyproject.toml +- [ ] Configuration with Pydantic Settings +- [ ] Database connection with SQLAlchemy async +- [ ] Base model classes (SoftDeleteMixin, InteractiveMixin) +- [ ] User, Account, UserAccount, AuditLog models +- [ ] Alembic migrations setup +- [ ] Request context with contextvars +- [ ] JWT validation with JWKS +- [ ] Authentication dependencies +- [ ] Role hierarchy implementation +- [ ] Permission matrix +- [ ] require_role() dependency +- [ ] require_permission() dependency +- [ ] Request context middleware +- [ ] Error handling middleware +- [ ] User service +- [ ] Account service +- [ ] User routes +- [ ] Account routes +- [ ] OpenAPI documentation +- [ ] Health check endpoint +- [ ] Database seeding script +- [ ] Tests setup with pytest-asyncio + +--- + +## Running the Application + +```bash +# Install dependencies +pip install -e ".[dev]" + +# Create .env file +cp .env.example .env + +# Run migrations +alembic upgrade head + +# Seed database +python scripts/seed.py + +# Run development server +uvicorn app.main:app --reload --port 8000 + +# Access docs +# http://localhost:8000/api/docs (Swagger UI) +# http://localhost:8000/api/redoc (ReDoc) +``` + +--- + +## References + +- [FastAPI Documentation](https://fastapi.tiangolo.com/) +- [SQLAlchemy 2.0 Documentation](https://docs.sqlalchemy.org/en/20/) +- [Pydantic v2 Documentation](https://docs.pydantic.dev/latest/) +- [Alembic Documentation](https://alembic.sqlalchemy.org/) +- [python-jose Documentation](https://python-jose.readthedocs.io/) +- [Auth0 Python SDK](https://github.com/auth0/auth0-python)