From 94d60527f48e27f9336826ac9e16aa644bab442f Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Wed, 7 Jan 2026 13:26:31 +0100 Subject: [PATCH] driver to user id --- .../inmemory/InMemoryCompanyRepository.ts | 50 + .../typeorm/entities/CompanyOrmEntity.ts | 19 + .../typeorm/entities/UserOrmEntity.ts | 3 + .../typeorm/mappers/CompanyOrmMapper.ts | 25 + .../typeorm/mappers/UserOrmMapper.ts | 4 + .../repositories/TypeOrmAuthRepository.ts | 1 + .../repositories/TypeOrmCompanyRepository.ts | 47 + apps/api/src/config/feature-loader.test.ts | 4 +- apps/api/src/config/integration.test.ts | 2 +- .../src/domain/auth/AuthController.test.ts | 30 +- apps/api/src/domain/auth/AuthController.ts | 7 +- apps/api/src/domain/auth/AuthProviders.ts | 21 + .../src/domain/auth/AuthService.new.test.ts | 155 --- apps/api/src/domain/auth/AuthService.test.ts | 9 + apps/api/src/domain/auth/AuthService.ts | 43 +- apps/api/src/domain/auth/dtos/AuthDto.ts | 23 + .../auth/presenters/AuthSessionPresenter.ts | 5 +- .../identity/IdentityPersistenceTokens.ts | 3 +- .../InMemoryIdentityPersistenceModule.ts | 9 +- .../PostgresIdentityPersistenceModule.ts | 14 +- apps/website/CLEAN_ARCHITECTURE_PLAN.md | 1003 ----------------- .../use-cases/SignupSponsorUseCase.test.ts | 218 ++++ .../use-cases/SignupSponsorUseCase.ts | 180 +++ core/identity/domain/entities/Company.ts | 98 ++ core/identity/domain/entities/User.ts | 19 + .../domain/repositories/ICompanyRepository.ts | 33 + .../domain/repositories/IUserRepository.ts | 1 + 27 files changed, 856 insertions(+), 1170 deletions(-) create mode 100644 adapters/identity/persistence/inmemory/InMemoryCompanyRepository.ts create mode 100644 adapters/identity/persistence/typeorm/entities/CompanyOrmEntity.ts create mode 100644 adapters/identity/persistence/typeorm/mappers/CompanyOrmMapper.ts create mode 100644 adapters/identity/persistence/typeorm/repositories/TypeOrmCompanyRepository.ts delete mode 100644 apps/api/src/domain/auth/AuthService.new.test.ts delete mode 100644 apps/website/CLEAN_ARCHITECTURE_PLAN.md create mode 100644 core/identity/application/use-cases/SignupSponsorUseCase.test.ts create mode 100644 core/identity/application/use-cases/SignupSponsorUseCase.ts create mode 100644 core/identity/domain/entities/Company.ts create mode 100644 core/identity/domain/repositories/ICompanyRepository.ts diff --git a/adapters/identity/persistence/inmemory/InMemoryCompanyRepository.ts b/adapters/identity/persistence/inmemory/InMemoryCompanyRepository.ts new file mode 100644 index 000000000..2f9289e92 --- /dev/null +++ b/adapters/identity/persistence/inmemory/InMemoryCompanyRepository.ts @@ -0,0 +1,50 @@ +import { Company } from '@core/identity/domain/entities/Company'; +import { ICompanyRepository } from '@core/identity/domain/repositories/ICompanyRepository'; + +/** + * In-memory implementation of ICompanyRepository for testing + */ +export class InMemoryCompanyRepository implements ICompanyRepository { + private companies: Map = new Map(); + + create(company: Pick): Company { + // Create a new Company instance with generated ID + const contactEmail = company.getContactEmail(); + return Company.create({ + name: company.getName(), + ownerUserId: company.getOwnerUserId(), + ...(contactEmail !== undefined ? { contactEmail } : {}), + }); + } + + async save(company: Company): Promise { + this.companies.set(company.getId(), company); + } + + async delete(id: string): Promise { + this.companies.delete(id); + } + + async findById(id: string): Promise { + return this.companies.get(id) || null; + } + + async findByOwnerUserId(userId: string): Promise { + for (const company of this.companies.values()) { + if (company.getOwnerUserId().toString() === userId) { + return company; + } + } + return null; + } + + // Helper method for testing to get all companies + getAll(): Company[] { + return Array.from(this.companies.values()); + } + + // Helper method for testing to clear all + clear(): void { + this.companies.clear(); + } +} \ No newline at end of file diff --git a/adapters/identity/persistence/typeorm/entities/CompanyOrmEntity.ts b/adapters/identity/persistence/typeorm/entities/CompanyOrmEntity.ts new file mode 100644 index 000000000..ade5bcf37 --- /dev/null +++ b/adapters/identity/persistence/typeorm/entities/CompanyOrmEntity.ts @@ -0,0 +1,19 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm'; + +@Entity('companies') +export class CompanyOrmEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ type: 'varchar', length: 100 }) + name!: string; + + @Column({ name: 'owner_user_id', type: 'varchar' }) + ownerUserId!: string; + + @Column({ type: 'varchar', nullable: true }) + contactEmail: string | null = null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; +} \ No newline at end of file diff --git a/adapters/identity/persistence/typeorm/entities/UserOrmEntity.ts b/adapters/identity/persistence/typeorm/entities/UserOrmEntity.ts index 3778ffb35..11a5bfcd9 100644 --- a/adapters/identity/persistence/typeorm/entities/UserOrmEntity.ts +++ b/adapters/identity/persistence/typeorm/entities/UserOrmEntity.ts @@ -21,6 +21,9 @@ export class UserOrmEntity { @Column({ type: 'text', nullable: true }) primaryDriverId!: string | null; + @Column({ name: 'company_id', type: 'text', nullable: true }) + companyId!: string | null; + @CreateDateColumn({ type: 'timestamptz' }) createdAt!: Date; } \ No newline at end of file diff --git a/adapters/identity/persistence/typeorm/mappers/CompanyOrmMapper.ts b/adapters/identity/persistence/typeorm/mappers/CompanyOrmMapper.ts new file mode 100644 index 000000000..8ef897c3c --- /dev/null +++ b/adapters/identity/persistence/typeorm/mappers/CompanyOrmMapper.ts @@ -0,0 +1,25 @@ +import { Company } from '@core/identity/domain/entities/Company'; +import { CompanyOrmEntity } from '../entities/CompanyOrmEntity'; + +export class CompanyOrmMapper { + toDomain(entity: CompanyOrmEntity): Company { + const contactEmail = entity.contactEmail ?? undefined; + return Company.rehydrate({ + id: entity.id, + name: entity.name, + ownerUserId: entity.ownerUserId, + ...(contactEmail !== undefined ? { contactEmail } : {}), + createdAt: entity.createdAt, + }); + } + + toPersistence(domain: Company): CompanyOrmEntity { + const entity = new CompanyOrmEntity(); + entity.id = domain.getId(); + entity.name = domain.getName(); + entity.ownerUserId = domain.getOwnerUserId().toString(); + entity.contactEmail = domain.getContactEmail() ?? null; + // Note: createdAt is handled by @CreateDateColumn + return entity; + } +} \ No newline at end of file diff --git a/adapters/identity/persistence/typeorm/mappers/UserOrmMapper.ts b/adapters/identity/persistence/typeorm/mappers/UserOrmMapper.ts index 592ccf21a..c4460b27c 100644 --- a/adapters/identity/persistence/typeorm/mappers/UserOrmMapper.ts +++ b/adapters/identity/persistence/typeorm/mappers/UserOrmMapper.ts @@ -17,6 +17,7 @@ export class UserOrmMapper { assertNonEmptyString(entityName, 'passwordHash', entity.passwordHash); assertOptionalStringOrNull(entityName, 'salt', entity.salt); assertOptionalStringOrNull(entityName, 'primaryDriverId', entity.primaryDriverId); + assertOptionalStringOrNull(entityName, 'companyId', entity.companyId); assertDate(entityName, 'createdAt', entity.createdAt); } catch (error) { if (error instanceof TypeOrmIdentitySchemaError) { @@ -35,6 +36,7 @@ export class UserOrmMapper { displayName: entity.displayName, ...(passwordHash ? { passwordHash } : {}), ...(entity.primaryDriverId ? { primaryDriverId: entity.primaryDriverId } : {}), + ...(entity.companyId ? { companyId: entity.companyId } : {}), }); } catch (error) { const message = error instanceof Error ? error.message : 'Invalid persisted User'; @@ -50,6 +52,7 @@ export class UserOrmMapper { entity.passwordHash = stored.passwordHash; entity.salt = stored.salt ?? ''; entity.primaryDriverId = stored.primaryDriverId ?? null; + entity.companyId = stored.companyId ?? null; entity.createdAt = stored.createdAt; return entity; } @@ -62,6 +65,7 @@ export class UserOrmMapper { passwordHash: entity.passwordHash, ...(entity.salt ? { salt: entity.salt } : {}), primaryDriverId: entity.primaryDriverId ?? undefined, + companyId: entity.companyId ?? undefined, createdAt: entity.createdAt, }; } diff --git a/adapters/identity/persistence/typeorm/repositories/TypeOrmAuthRepository.ts b/adapters/identity/persistence/typeorm/repositories/TypeOrmAuthRepository.ts index 62ddf8e31..4faede698 100644 --- a/adapters/identity/persistence/typeorm/repositories/TypeOrmAuthRepository.ts +++ b/adapters/identity/persistence/typeorm/repositories/TypeOrmAuthRepository.ts @@ -42,6 +42,7 @@ export class TypeOrmAuthRepository implements IAuthRepository { entity.passwordHash = passwordHash; entity.salt = ''; entity.primaryDriverId = user.getPrimaryDriverId() ?? null; + entity.companyId = user.getCompanyId() ?? null; entity.createdAt = existing?.createdAt ?? new Date(); await repo.save(entity); diff --git a/adapters/identity/persistence/typeorm/repositories/TypeOrmCompanyRepository.ts b/adapters/identity/persistence/typeorm/repositories/TypeOrmCompanyRepository.ts new file mode 100644 index 000000000..5ac4e3c98 --- /dev/null +++ b/adapters/identity/persistence/typeorm/repositories/TypeOrmCompanyRepository.ts @@ -0,0 +1,47 @@ +import type { DataSource } from 'typeorm'; + +import { Company } from '@core/identity/domain/entities/Company'; +import type { ICompanyRepository } from '@core/identity/domain/repositories/ICompanyRepository'; + +import { CompanyOrmEntity } from '../entities/CompanyOrmEntity'; +import { CompanyOrmMapper } from '../mappers/CompanyOrmMapper'; + +export class TypeOrmCompanyRepository implements ICompanyRepository { + constructor( + private readonly dataSource: DataSource, + private readonly mapper: CompanyOrmMapper, + ) {} + + create(company: Pick): Company { + // Create a new Company instance with generated ID + const contactEmail = company.getContactEmail(); + return Company.create({ + name: company.getName(), + ownerUserId: company.getOwnerUserId(), + ...(contactEmail !== undefined ? { contactEmail } : {}), + }); + } + + async save(company: Company): Promise { + const repo = this.dataSource.getRepository(CompanyOrmEntity); + const entity = this.mapper.toPersistence(company); + await repo.save(entity); + } + + async delete(id: string): Promise { + const repo = this.dataSource.getRepository(CompanyOrmEntity); + await repo.delete(id); + } + + async findById(id: string): Promise { + const repo = this.dataSource.getRepository(CompanyOrmEntity); + const entity = await repo.findOne({ where: { id } }); + return entity ? this.mapper.toDomain(entity) : null; + } + + async findByOwnerUserId(userId: string): Promise { + const repo = this.dataSource.getRepository(CompanyOrmEntity); + const entity = await repo.findOne({ where: { ownerUserId: userId } }); + return entity ? this.mapper.toDomain(entity) : null; + } +} \ No newline at end of file diff --git a/apps/api/src/config/feature-loader.test.ts b/apps/api/src/config/feature-loader.test.ts index f0431fb81..060805c58 100644 --- a/apps/api/src/config/feature-loader.test.ts +++ b/apps/api/src/config/feature-loader.test.ts @@ -4,9 +4,9 @@ * Run with: npm test -- feature-loader.test.ts */ -import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; import { loadFeatureConfig, isFeatureEnabled, getFeatureState, getAllFeatures } from './feature-loader'; -import { FlattenedFeatures, FeatureState } from './feature-types'; +import { FlattenedFeatures } from './feature-types'; describe('Feature Flag Configuration', () => { const originalEnv = process.env.NODE_ENV; diff --git a/apps/api/src/config/integration.test.ts b/apps/api/src/config/integration.test.ts index a3482f884..394a39d69 100644 --- a/apps/api/src/config/integration.test.ts +++ b/apps/api/src/config/integration.test.ts @@ -2,7 +2,7 @@ * Integration test to verify the feature flag system works end-to-end */ -import { describe, it, expect, beforeAll } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { loadFeatureConfig, isFeatureEnabled, getFeatureState } from './feature-loader'; describe('Feature Flag Integration Test', () => { diff --git a/apps/api/src/domain/auth/AuthController.test.ts b/apps/api/src/domain/auth/AuthController.test.ts index 9b5e70270..ea3644a62 100644 --- a/apps/api/src/domain/auth/AuthController.test.ts +++ b/apps/api/src/domain/auth/AuthController.test.ts @@ -6,7 +6,7 @@ import request from 'supertest'; import { Mock, vi } from 'vitest'; import { AuthController } from './AuthController'; import { AuthService } from './AuthService'; -import { AuthSessionDTO, LoginParamsDTO, SignupParamsDTO } from './dtos/AuthDto'; +import { AuthSessionDTO, LoginParamsDTO, SignupParamsDTO, SignupSponsorParamsDTO } from './dtos/AuthDto'; import type { CommandResultDTO } from './presenters/CommandResultPresenter'; import { AuthenticationGuard } from './AuthenticationGuard'; import { AuthorizationGuard } from './AuthorizationGuard'; @@ -21,6 +21,7 @@ describe('AuthController', () => { beforeEach(() => { service = { signupWithEmail: vi.fn(), + signupSponsor: vi.fn(), loginWithEmail: vi.fn(), getCurrentSession: vi.fn(), logout: vi.fn(), @@ -56,6 +57,32 @@ describe('AuthController', () => { }); }); + describe('signupSponsor', () => { + it('should call service.signupSponsor and return session DTO', async () => { + const params: SignupSponsorParamsDTO = { + email: 'sponsor@example.com', + password: 'Password123', + displayName: 'John Doe', + companyName: 'Acme Racing Co.', + }; + const session: AuthSessionDTO = { + token: 'token123', + user: { + userId: 'user1', + email: 'sponsor@example.com', + displayName: 'John Doe', + companyId: 'company-123', + }, + }; + (service.signupSponsor as Mock).mockResolvedValue(session); + + const result = await controller.signupSponsor(params); + + expect(service.signupSponsor).toHaveBeenCalledWith(params); + expect(result).toEqual(session); + }); + }); + describe('login', () => { it('should call service.loginWithEmail and return session DTO', async () => { const params: LoginParamsDTO = { @@ -155,6 +182,7 @@ describe('AuthController', () => { getCurrentSession: vi.fn(async () => null), loginWithEmail: vi.fn(), signupWithEmail: vi.fn(), + signupSponsor: vi.fn(), logout: vi.fn(), startIracingAuth: vi.fn(), iracingCallback: vi.fn(), diff --git a/apps/api/src/domain/auth/AuthController.ts b/apps/api/src/domain/auth/AuthController.ts index d176f8912..7dd3ab3f4 100644 --- a/apps/api/src/domain/auth/AuthController.ts +++ b/apps/api/src/domain/auth/AuthController.ts @@ -1,7 +1,7 @@ import { Controller, Get, Post, Body, Query, Inject, Res } from '@nestjs/common'; import { Public } from './Public'; import { AuthService } from './AuthService'; -import { LoginParamsDTO, SignupParamsDTO, AuthSessionDTO, ForgotPasswordDTO, ResetPasswordDTO } from './dtos/AuthDto'; +import { LoginParamsDTO, SignupParamsDTO, SignupSponsorParamsDTO, AuthSessionDTO, ForgotPasswordDTO, ResetPasswordDTO } from './dtos/AuthDto'; import type { CommandResultDTO } from './presenters/CommandResultPresenter'; import type { Response } from 'express'; @@ -15,6 +15,11 @@ export class AuthController { return this.authService.signupWithEmail(params); } + @Post('signup-sponsor') + async signupSponsor(@Body() params: SignupSponsorParamsDTO): Promise { + return this.authService.signupSponsor(params); + } + @Post('login') async login(@Body() params: LoginParamsDTO): Promise { return this.authService.loginWithEmail(params); diff --git a/apps/api/src/domain/auth/AuthProviders.ts b/apps/api/src/domain/auth/AuthProviders.ts index c03555750..cf303209b 100644 --- a/apps/api/src/domain/auth/AuthProviders.ts +++ b/apps/api/src/domain/auth/AuthProviders.ts @@ -4,16 +4,19 @@ import { CookieIdentitySessionAdapter } from '@adapters/identity/session/CookieI import { LoginUseCase } from '@core/identity/application/use-cases/LoginUseCase'; import { LogoutUseCase } from '@core/identity/application/use-cases/LogoutUseCase'; import { SignupUseCase } from '@core/identity/application/use-cases/SignupUseCase'; +import { SignupSponsorUseCase } from '@core/identity/application/use-cases/SignupSponsorUseCase'; import { ForgotPasswordUseCase } from '@core/identity/application/use-cases/ForgotPasswordUseCase'; import { ResetPasswordUseCase } from '@core/identity/application/use-cases/ResetPasswordUseCase'; import type { IdentitySessionPort } from '@core/identity/application/ports/IdentitySessionPort'; import type { IAuthRepository } from '@core/identity/domain/repositories/IAuthRepository'; +import type { ICompanyRepository } from '@core/identity/domain/repositories/ICompanyRepository'; import type { IMagicLinkRepository } from '@core/identity/domain/repositories/IMagicLinkRepository'; import type { IPasswordHashingService } from '@core/identity/domain/services/PasswordHashingService'; import type { IMagicLinkNotificationPort } from '@core/identity/domain/ports/IMagicLinkNotificationPort'; import type { LoginResult } from '@core/identity/application/use-cases/LoginUseCase'; import type { LogoutResult } from '@core/identity/application/use-cases/LogoutUseCase'; import type { SignupResult } from '@core/identity/application/use-cases/SignupUseCase'; +import type { SignupSponsorResult } from '@core/identity/application/use-cases/SignupSponsorUseCase'; import type { ForgotPasswordResult } from '@core/identity/application/use-cases/ForgotPasswordUseCase'; import type { ResetPasswordResult } from '@core/identity/application/use-cases/ResetPasswordUseCase'; import type { Logger, UseCaseOutputPort } from '@core/shared/application'; @@ -23,6 +26,7 @@ import { PASSWORD_HASHING_SERVICE_TOKEN, USER_REPOSITORY_TOKEN, MAGIC_LINK_REPOSITORY_TOKEN, + COMPANY_REPOSITORY_TOKEN, } from '../../persistence/identity/IdentityPersistenceTokens'; import { AuthSessionPresenter } from './presenters/AuthSessionPresenter'; @@ -37,6 +41,7 @@ export const LOGGER_TOKEN = 'Logger'; export const IDENTITY_SESSION_PORT_TOKEN = 'IdentitySessionPort'; export const LOGIN_USE_CASE_TOKEN = 'LoginUseCase'; export const SIGNUP_USE_CASE_TOKEN = 'SignupUseCase'; +export const SIGNUP_SPONSOR_USE_CASE_TOKEN = 'SignupSponsorUseCase'; export const LOGOUT_USE_CASE_TOKEN = 'LogoutUseCase'; export const FORGOT_PASSWORD_USE_CASE_TOKEN = 'ForgotPasswordUseCase'; export const RESET_PASSWORD_USE_CASE_TOKEN = 'ResetPasswordUseCase'; @@ -45,6 +50,7 @@ export const AUTH_SESSION_OUTPUT_PORT_TOKEN = 'AuthSessionOutputPort'; export const COMMAND_RESULT_OUTPUT_PORT_TOKEN = 'CommandResultOutputPort'; export const FORGOT_PASSWORD_OUTPUT_PORT_TOKEN = 'ForgotPasswordOutputPort'; export const RESET_PASSWORD_OUTPUT_PORT_TOKEN = 'ResetPasswordOutputPort'; +export const SIGNUP_SPONSOR_OUTPUT_PORT_TOKEN = 'SignupSponsorOutputPort'; export const MAGIC_LINK_NOTIFICATION_PORT_TOKEN = 'MagicLinkNotificationPort'; export const AuthProviders: Provider[] = [ @@ -83,6 +89,17 @@ export const AuthProviders: Provider[] = [ ) => new SignupUseCase(authRepo, passwordHashing, logger, output), inject: [AUTH_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, LOGGER_TOKEN, AUTH_SESSION_OUTPUT_PORT_TOKEN], }, + { + provide: SIGNUP_SPONSOR_USE_CASE_TOKEN, + useFactory: ( + authRepo: IAuthRepository, + companyRepo: ICompanyRepository, + passwordHashing: IPasswordHashingService, + logger: Logger, + output: UseCaseOutputPort, + ) => new SignupSponsorUseCase(authRepo, companyRepo, passwordHashing, logger, output), + inject: [AUTH_REPOSITORY_TOKEN, COMPANY_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, LOGGER_TOKEN, SIGNUP_SPONSOR_OUTPUT_PORT_TOKEN], + }, { provide: LOGOUT_USE_CASE_TOKEN, useFactory: (sessionPort: IdentitySessionPort, logger: Logger, output: UseCaseOutputPort) => @@ -99,6 +116,10 @@ export const AuthProviders: Provider[] = [ provide: RESET_PASSWORD_OUTPUT_PORT_TOKEN, useExisting: ResetPasswordPresenter, }, + { + provide: SIGNUP_SPONSOR_OUTPUT_PORT_TOKEN, + useExisting: AuthSessionPresenter, + }, { provide: MAGIC_LINK_NOTIFICATION_PORT_TOKEN, useFactory: (logger: Logger) => new ConsoleMagicLinkNotificationAdapter(logger), diff --git a/apps/api/src/domain/auth/AuthService.new.test.ts b/apps/api/src/domain/auth/AuthService.new.test.ts deleted file mode 100644 index 98b6c1b32..000000000 --- a/apps/api/src/domain/auth/AuthService.new.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { AuthService } from './AuthService'; -import { Result } from '@core/shared/application/Result'; - -class FakeAuthSessionPresenter { - private model: any = null; - reset() { this.model = null; } - present(model: any) { this.model = model; } - get responseModel() { - if (!this.model) throw new Error('Presenter not presented'); - return this.model; - } -} - -class FakeCommandResultPresenter { - private model: any = null; - reset() { this.model = null; } - present(model: any) { this.model = model; } - get responseModel() { - if (!this.model) throw new Error('Presenter not presented'); - return this.model; - } -} - -class FakeForgotPasswordPresenter { - private model: any = null; - reset() { this.model = null; } - present(model: any) { this.model = model; } - get responseModel() { - if (!this.model) throw new Error('Presenter not presented'); - return this.model; - } -} - -class FakeResetPasswordPresenter { - private model: any = null; - reset() { this.model = null; } - present(model: any) { this.model = model; } - get responseModel() { - if (!this.model) throw new Error('Presenter not presented'); - return this.model; - } -} - -describe('AuthService - New Methods', () => { - describe('forgotPassword', () => { - it('should execute forgot password use case and return result', async () => { - const forgotPasswordPresenter = new FakeForgotPasswordPresenter(); - const forgotPasswordUseCase = { - execute: vi.fn(async () => { - forgotPasswordPresenter.present({ message: 'Reset link sent', magicLink: 'http://example.com/reset?token=abc123' }); - return Result.ok(undefined); - }), - }; - - const service = new AuthService( - { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any, - { getCurrentSession: vi.fn(), createSession: vi.fn() } as any, - { execute: vi.fn() } as any, - { execute: vi.fn() } as any, - { execute: vi.fn() } as any, - forgotPasswordUseCase as any, - { execute: vi.fn() } as any, - new FakeAuthSessionPresenter() as any, - new FakeCommandResultPresenter() as any, - forgotPasswordPresenter as any, - new FakeResetPasswordPresenter() as any, - ); - - const result = await service.forgotPassword({ email: 'test@example.com' }); - - expect(forgotPasswordUseCase.execute).toHaveBeenCalledWith({ email: 'test@example.com' }); - expect(result).toEqual({ - message: 'Reset link sent', - magicLink: 'http://example.com/reset?token=abc123', - }); - }); - - it('should throw error on use case failure', async () => { - const service = new AuthService( - { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any, - { getCurrentSession: vi.fn(), createSession: vi.fn() } as any, - { execute: vi.fn() } as any, - { execute: vi.fn() } as any, - { execute: vi.fn() } as any, - { execute: vi.fn(async () => Result.err({ code: 'RATE_LIMIT_EXCEEDED', details: { message: 'Too many attempts' } })) } as any, - { execute: vi.fn() } as any, - new FakeAuthSessionPresenter() as any, - new FakeCommandResultPresenter() as any, - new FakeForgotPasswordPresenter() as any, - new FakeResetPasswordPresenter() as any, - ); - - await expect(service.forgotPassword({ email: 'test@example.com' })).rejects.toThrow('Too many attempts'); - }); - }); - - describe('resetPassword', () => { - it('should execute reset password use case and return result', async () => { - const resetPasswordPresenter = new FakeResetPasswordPresenter(); - const resetPasswordUseCase = { - execute: vi.fn(async () => { - resetPasswordPresenter.present({ message: 'Password reset successfully' }); - return Result.ok(undefined); - }), - }; - - const service = new AuthService( - { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any, - { getCurrentSession: vi.fn(), createSession: vi.fn() } as any, - { execute: vi.fn() } as any, - { execute: vi.fn() } as any, - { execute: vi.fn() } as any, - { execute: vi.fn() } as any, - resetPasswordUseCase as any, - new FakeAuthSessionPresenter() as any, - new FakeCommandResultPresenter() as any, - new FakeForgotPasswordPresenter() as any, - resetPasswordPresenter as any, - ); - - const result = await service.resetPassword({ - token: 'abc123', - newPassword: 'NewPass123!', - }); - - expect(resetPasswordUseCase.execute).toHaveBeenCalledWith({ - token: 'abc123', - newPassword: 'NewPass123!', - }); - expect(result).toEqual({ message: 'Password reset successfully' }); - }); - - it('should throw error on use case failure', async () => { - const resetPasswordPresenter = new FakeResetPasswordPresenter(); - const service = new AuthService( - { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any, - { getCurrentSession: vi.fn(), createSession: vi.fn() } as any, - { execute: vi.fn() } as any, - { execute: vi.fn() } as any, - { execute: vi.fn() } as any, - { execute: vi.fn() } as any, - { execute: vi.fn(async () => Result.err({ code: 'INVALID_TOKEN', details: { message: 'Invalid token' } })) } as any, - new FakeAuthSessionPresenter() as any, - new FakeCommandResultPresenter() as any, - new FakeForgotPasswordPresenter() as any, - resetPasswordPresenter as any, - ); - - await expect( - service.resetPassword({ token: 'invalid', newPassword: 'NewPass123!' }) - ).rejects.toThrow('Invalid token'); - }); - }); -}); \ No newline at end of file diff --git a/apps/api/src/domain/auth/AuthService.test.ts b/apps/api/src/domain/auth/AuthService.test.ts index bb717e073..63b8cf48c 100644 --- a/apps/api/src/domain/auth/AuthService.test.ts +++ b/apps/api/src/domain/auth/AuthService.test.ts @@ -40,6 +40,7 @@ describe('AuthService', () => { { execute: vi.fn() } as any, { execute: vi.fn() } as any, { execute: vi.fn() } as any, + { execute: vi.fn() } as any, new FakeAuthSessionPresenter() as any, new FakeCommandResultPresenter() as any, new FakeAuthSessionPresenter() as any, @@ -64,6 +65,7 @@ describe('AuthService', () => { { execute: vi.fn() } as any, { execute: vi.fn() } as any, { execute: vi.fn() } as any, + { execute: vi.fn() } as any, new FakeAuthSessionPresenter() as any, new FakeCommandResultPresenter() as any, new FakeAuthSessionPresenter() as any, @@ -98,6 +100,7 @@ describe('AuthService', () => { { execute: vi.fn() } as any, { execute: vi.fn() } as any, { execute: vi.fn() } as any, + { execute: vi.fn() } as any, authSessionPresenter as any, new FakeCommandResultPresenter() as any, new FakeAuthSessionPresenter() as any, @@ -132,6 +135,7 @@ describe('AuthService', () => { { execute: vi.fn() } as any, { execute: vi.fn() } as any, { execute: vi.fn() } as any, + { execute: vi.fn() } as any, new FakeAuthSessionPresenter() as any, new FakeCommandResultPresenter() as any, new FakeAuthSessionPresenter() as any, @@ -165,6 +169,7 @@ describe('AuthService', () => { { execute: vi.fn() } as any, { execute: vi.fn() } as any, { execute: vi.fn() } as any, + { execute: vi.fn() } as any, authSessionPresenter as any, new FakeCommandResultPresenter() as any, new FakeAuthSessionPresenter() as any, @@ -196,6 +201,7 @@ describe('AuthService', () => { { execute: vi.fn() } as any, { execute: vi.fn() } as any, { execute: vi.fn() } as any, + { execute: vi.fn() } as any, new FakeAuthSessionPresenter() as any, new FakeCommandResultPresenter() as any, new FakeAuthSessionPresenter() as any, @@ -214,6 +220,7 @@ describe('AuthService', () => { { execute: vi.fn() } as any, { execute: vi.fn() } as any, { execute: vi.fn() } as any, + { execute: vi.fn() } as any, new FakeAuthSessionPresenter() as any, new FakeCommandResultPresenter() as any, new FakeAuthSessionPresenter() as any, @@ -237,6 +244,7 @@ describe('AuthService', () => { { getCurrentSession: vi.fn(), createSession: vi.fn() } as any, { execute: vi.fn() } as any, { execute: vi.fn() } as any, + { execute: vi.fn() } as any, logoutUseCase as any, { execute: vi.fn() } as any, { execute: vi.fn() } as any, @@ -255,6 +263,7 @@ describe('AuthService', () => { { getCurrentSession: vi.fn(), createSession: vi.fn() } as any, { execute: vi.fn() } as any, { execute: vi.fn() } as any, + { execute: vi.fn() } as any, { execute: vi.fn(async () => Result.err({ code: 'REPOSITORY_ERROR' } as any)) } as any, { execute: vi.fn() } as any, { execute: vi.fn() } as any, diff --git a/apps/api/src/domain/auth/AuthService.ts b/apps/api/src/domain/auth/AuthService.ts index 0e7e9be8c..8f1e1f32c 100644 --- a/apps/api/src/domain/auth/AuthService.ts +++ b/apps/api/src/domain/auth/AuthService.ts @@ -13,6 +13,11 @@ import { type SignupApplicationError, type SignupInput, } from '@core/identity/application/use-cases/SignupUseCase'; +import { + SignupSponsorUseCase, + type SignupSponsorApplicationError, + type SignupSponsorInput, +} from '@core/identity/application/use-cases/SignupSponsorUseCase'; import { ForgotPasswordUseCase, type ForgotPasswordApplicationError, @@ -36,11 +41,12 @@ import { LOGIN_USE_CASE_TOKEN, LOGOUT_USE_CASE_TOKEN, SIGNUP_USE_CASE_TOKEN, + SIGNUP_SPONSOR_USE_CASE_TOKEN, FORGOT_PASSWORD_USE_CASE_TOKEN, RESET_PASSWORD_USE_CASE_TOKEN, } from './AuthProviders'; import type { AuthSessionDTO } from './dtos/AuthDto'; -import { LoginParamsDTO, SignupParamsDTO } from './dtos/AuthDto'; +import { LoginParamsDTO, SignupParamsDTO, SignupSponsorParamsDTO } from './dtos/AuthDto'; import { AuthSessionPresenter } from './presenters/AuthSessionPresenter'; import type { CommandResultDTO } from './presenters/CommandResultPresenter'; import { CommandResultPresenter } from './presenters/CommandResultPresenter'; @@ -72,6 +78,7 @@ export class AuthService { private readonly identitySessionPort: IdentitySessionPort, @Inject(LOGIN_USE_CASE_TOKEN) private readonly loginUseCase: LoginUseCase, @Inject(SIGNUP_USE_CASE_TOKEN) private readonly signupUseCase: SignupUseCase, + @Inject(SIGNUP_SPONSOR_USE_CASE_TOKEN) private readonly signupSponsorUseCase: SignupSponsorUseCase, @Inject(LOGOUT_USE_CASE_TOKEN) private readonly logoutUseCase: LogoutUseCase, @Inject(FORGOT_PASSWORD_USE_CASE_TOKEN) private readonly forgotPasswordUseCase: ForgotPasswordUseCase, @Inject(RESET_PASSWORD_USE_CASE_TOKEN) private readonly resetPasswordUseCase: ResetPasswordUseCase, @@ -139,6 +146,40 @@ export class AuthService { }; } + async signupSponsor(params: SignupSponsorParamsDTO): Promise { + this.logger.debug(`[AuthService] Attempting sponsor signup for email: ${params.email}`); + + this.authSessionPresenter.reset(); + + const input: SignupSponsorInput = { + email: params.email, + password: params.password, + displayName: params.displayName, + companyName: params.companyName, + }; + + const result = await this.signupSponsorUseCase.execute(input); + + if (result.isErr()) { + const error = result.unwrapErr() as SignupSponsorApplicationError; + throw new Error(mapApplicationErrorToMessage(error, 'Sponsor signup failed')); + } + + const userDTO = this.authSessionPresenter.responseModel; + const inferredRole = inferDemoRoleFromEmail(userDTO.email); + const session = await this.identitySessionPort.createSession({ + id: userDTO.userId, + displayName: userDTO.displayName, + email: userDTO.email, + ...(inferredRole ? { role: inferredRole } : {}), + }); + + return { + token: session.token, + user: userDTO, + }; + } + async loginWithEmail(params: LoginParamsDTO): Promise { this.logger.debug(`[AuthService] Attempting login for email: ${params.email}`); diff --git a/apps/api/src/domain/auth/dtos/AuthDto.ts b/apps/api/src/domain/auth/dtos/AuthDto.ts index 232485932..c122461a1 100644 --- a/apps/api/src/domain/auth/dtos/AuthDto.ts +++ b/apps/api/src/domain/auth/dtos/AuthDto.ts @@ -12,6 +12,8 @@ export class AuthenticatedUserDTO { primaryDriverId?: string; @ApiProperty({ required: false, nullable: true }) avatarUrl?: string | null; + @ApiProperty({ required: false }) + companyId?: string; @ApiProperty({ required: false, enum: ['driver', 'sponsor', 'league-owner', 'league-steward', 'league-admin', 'system-owner', 'super-admin'] }) role?: 'driver' | 'sponsor' | 'league-owner' | 'league-steward' | 'league-admin' | 'system-owner' | 'super-admin'; } @@ -48,6 +50,27 @@ export class SignupParamsDTO { avatarUrl?: string | null; } +export class SignupSponsorParamsDTO { + @ApiProperty() + @IsEmail() + email!: string; + + @ApiProperty() + @IsString() + @MinLength(8) + password!: string; + + @ApiProperty() + @IsString() + @MinLength(2) + displayName!: string; + + @ApiProperty() + @IsString() + @MinLength(2) + companyName!: string; +} + export class LoginParamsDTO { @ApiProperty() @IsEmail() diff --git a/apps/api/src/domain/auth/presenters/AuthSessionPresenter.ts b/apps/api/src/domain/auth/presenters/AuthSessionPresenter.ts index e489707aa..b0a04a7c0 100644 --- a/apps/api/src/domain/auth/presenters/AuthSessionPresenter.ts +++ b/apps/api/src/domain/auth/presenters/AuthSessionPresenter.ts @@ -2,8 +2,9 @@ import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPo import { AuthenticatedUserDTO } from '../dtos/AuthDto'; import type { LoginResult } from '@core/identity/application/use-cases/LoginUseCase'; import type { SignupResult } from '@core/identity/application/use-cases/SignupUseCase'; +import type { SignupSponsorResult } from '@core/identity/application/use-cases/SignupSponsorUseCase'; -type AuthSessionResult = LoginResult | SignupResult; +type AuthSessionResult = LoginResult | SignupResult | SignupSponsorResult; export class AuthSessionPresenter implements UseCaseOutputPort { private model: AuthenticatedUserDTO | null = null; @@ -15,12 +16,14 @@ export class AuthSessionPresenter implements UseCaseOutputPort new InMemoryMagicLinkRepository(logger), inject: ['Logger'], }, + { + provide: COMPANY_REPOSITORY_TOKEN, + useClass: InMemoryCompanyRepository, + }, ], - exports: [USER_REPOSITORY_TOKEN, AUTH_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, MAGIC_LINK_REPOSITORY_TOKEN], + exports: [USER_REPOSITORY_TOKEN, AUTH_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, MAGIC_LINK_REPOSITORY_TOKEN, COMPANY_REPOSITORY_TOKEN], }) export class InMemoryIdentityPersistenceModule {} \ No newline at end of file diff --git a/apps/api/src/persistence/postgres/PostgresIdentityPersistenceModule.ts b/apps/api/src/persistence/postgres/PostgresIdentityPersistenceModule.ts index 6ff18d3fa..7193ac6c0 100644 --- a/apps/api/src/persistence/postgres/PostgresIdentityPersistenceModule.ts +++ b/apps/api/src/persistence/postgres/PostgresIdentityPersistenceModule.ts @@ -4,11 +4,14 @@ import type { DataSource } from 'typeorm'; import type { Logger } from '@core/shared/application/Logger'; import { UserOrmEntity } from '@adapters/identity/persistence/typeorm/entities/UserOrmEntity'; +import { CompanyOrmEntity } from '@adapters/identity/persistence/typeorm/entities/CompanyOrmEntity'; import { PasswordResetRequestOrmEntity } from '@adapters/identity/persistence/typeorm/entities/PasswordResetRequestOrmEntity'; import { TypeOrmAuthRepository } from '@adapters/identity/persistence/typeorm/repositories/TypeOrmAuthRepository'; import { TypeOrmUserRepository } from '@adapters/identity/persistence/typeorm/repositories/TypeOrmUserRepository'; import { TypeOrmMagicLinkRepository } from '@adapters/identity/persistence/typeorm/repositories/TypeOrmMagicLinkRepository'; +import { TypeOrmCompanyRepository } from '@adapters/identity/persistence/typeorm/repositories/TypeOrmCompanyRepository'; import { UserOrmMapper } from '@adapters/identity/persistence/typeorm/mappers/UserOrmMapper'; +import { CompanyOrmMapper } from '@adapters/identity/persistence/typeorm/mappers/CompanyOrmMapper'; import { InMemoryPasswordHashingService } from '@adapters/identity/services/InMemoryPasswordHashingService'; import { @@ -16,9 +19,10 @@ import { PASSWORD_HASHING_SERVICE_TOKEN, USER_REPOSITORY_TOKEN, MAGIC_LINK_REPOSITORY_TOKEN, + COMPANY_REPOSITORY_TOKEN, } from '../identity/IdentityPersistenceTokens'; -const typeOrmFeatureImports = [TypeOrmModule.forFeature([UserOrmEntity, PasswordResetRequestOrmEntity])]; +const typeOrmFeatureImports = [TypeOrmModule.forFeature([UserOrmEntity, CompanyOrmEntity, PasswordResetRequestOrmEntity])]; @Module({ imports: [...typeOrmFeatureImports], @@ -43,7 +47,13 @@ const typeOrmFeatureImports = [TypeOrmModule.forFeature([UserOrmEntity, Password useFactory: (dataSource: DataSource, logger: Logger) => new TypeOrmMagicLinkRepository(dataSource, logger), inject: [getDataSourceToken(), 'Logger'], }, + { + provide: COMPANY_REPOSITORY_TOKEN, + useFactory: (dataSource: DataSource, mapper: CompanyOrmMapper) => new TypeOrmCompanyRepository(dataSource, mapper), + inject: [getDataSourceToken(), CompanyOrmMapper], + }, + { provide: CompanyOrmMapper, useFactory: () => new CompanyOrmMapper() }, ], - exports: [USER_REPOSITORY_TOKEN, AUTH_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, MAGIC_LINK_REPOSITORY_TOKEN], + exports: [USER_REPOSITORY_TOKEN, AUTH_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, MAGIC_LINK_REPOSITORY_TOKEN, COMPANY_REPOSITORY_TOKEN], }) export class PostgresIdentityPersistenceModule {} \ No newline at end of file diff --git a/apps/website/CLEAN_ARCHITECTURE_PLAN.md b/apps/website/CLEAN_ARCHITECTURE_PLAN.md deleted file mode 100644 index 89ae3812e..000000000 --- a/apps/website/CLEAN_ARCHITECTURE_PLAN.md +++ /dev/null @@ -1,1003 +0,0 @@ -# Clean Architecture Plan: Unified Data Fetching with SOLID OOP - -## Executive Summary -This plan eliminates file proliferation and establishes a unified, type-safe data fetching architecture that handles **all** real-world scenarios: SSR, CSR, complex state, mutations, and multi-service dependencies. - -## Core Principles - -### 1. Single Responsibility -- **Data Fetching**: `PageDataFetcher` (SSR) + `usePageData` (CSR) -- **State Management**: `PageWrapper` handles loading/error/empty states -- **Business Logic**: Service classes handle domain logic -- **UI Rendering**: Templates handle presentation - -### 2. Open/Closed Principle -- Extend via composition, not modification -- Add new service methods without changing fetchers -- Support new patterns via strategy pattern - -### 3. Dependency Inversion -- High-level modules depend on abstractions -- Use DI container for SSR -- Use hooks for CSR - ---- - -## Architecture Components - -### 1. Unified Data Fetcher (SSR) - UPDATED FOR REALITY -**File**: `lib/page/PageDataFetcher.ts` - -```typescript -import { ContainerManager } from '@/lib/di/container'; - -export interface FetchResult { - data: T | null; - errors: Record; - hasErrors: boolean; -} - -export class PageDataFetcher { - /** - * Fetch data using DI container - * Use for: Simple SSR pages with single service - * WARNING: Container is singleton - avoid stateful services - */ - static async fetch( - ServiceToken: string | symbol, - method: TMethod, - ...args: TService[TMethod] extends (...params: infer P) => Promise ? P : never - ): Promise<(TService[TMethod] extends (...params: any[]) => Promise ? R : never) | null> { - try { - const container = ContainerManager.getInstance().getContainer(); - const service = container.get(ServiceToken); - const result = await (service[method] as Function)(...args); - return result; - } catch (error) { - console.error(`Failed to fetch: ${String(ServiceToken)}.${String(method)}`, error); - return null; - } - } - - /** - * Fetch using manual service instantiation - * Use for: Multiple dependencies, request-scoped services, or auth context - * RECOMMENDED for SSR over fetch() with DI - */ - static async fetchManual( - serviceFactory: () => Promise | TData - ): Promise { - try { - const result = await serviceFactory(); - return result; - } catch (error) { - console.error('Failed to fetch manual:', error); - return null; - } - } - - /** - * Fetch multiple datasets in parallel with error aggregation - * Use for: Pages needing multiple service calls - * UPDATED: Returns both data and errors for proper handling - */ - static async fetchMultiple>( - queries: T - ): Promise> { - const results = {} as { [K in keyof T]: T[K] }; - const errors = {} as Record; - - const entries = await Promise.all( - Object.entries(queries).map(async ([key, query]) => { - try { - const result = await query(); - return [key, { success: true, data: result }]; - } catch (error) { - console.error(`Failed to fetch ${key}:`, error); - return [key, { success: false, error: error instanceof Error ? error : new Error(String(error)) }]; - } - }) - ); - - entries.forEach(([key, result]) => { - if (result.success) { - results[key as keyof T] = result.data; - } else { - errors[key] = result.error; - } - }); - - return { - data: results, - errors, - hasErrors: Object.keys(errors).length > 0 - }; - } -} -``` - -### 2. Client-Side Data Hook - UPDATED FOR REALITY -**File**: `lib/page/usePageData.ts` - -```typescript -'use client'; - -import { useQuery, useQueries, UseQueryOptions, useMutation, UseMutationOptions } from '@tanstack/react-query'; -import { useInject } from '@/lib/di/hooks/useInject'; -import { ApiError } from '@/lib/api/base/ApiError'; - -export interface PageDataConfig { - queryKey: string[]; - queryFn: () => Promise; - enabled?: boolean; - staleTime?: number; - onError?: (error: TError) => void; -} - -/** - * Single query hook - STANDARDIZED PATTERN - * Use for: Simple CSR pages - * - * @example - * const { data, isLoading, error, refetch } = usePageData({ - * queryKey: ['profile'], - * queryFn: () => driverService.getProfile(), - * }); - */ -export function usePageData( - config: PageDataConfig -) { - return useQuery({ - queryKey: config.queryKey, - queryFn: config.queryFn, - enabled: config.enabled ?? true, - staleTime: config.staleTime ?? 1000 * 60 * 5, - onError: config.onError, - }); -} - -/** - * Multiple queries hook - STANDARDIZED PATTERN - * Use for: Complex CSR pages with multiple data sources - * - * @example - * const { data, isLoading, error, refetch } = usePageDataMultiple({ - * results: { - * queryKey: ['raceResults', raceId], - * queryFn: () => service.getResults(raceId), - * }, - * sof: { - * queryKey: ['raceSOF', raceId], - * queryFn: () => service.getSOF(raceId), - * }, - * }); - */ -export function usePageDataMultiple>( - queries: { - [K in keyof T]: PageDataConfig; - } -) { - const queryResults = useQueries({ - queries: Object.entries(queries).map(([key, config]) => ({ - queryKey: config.queryKey, - queryFn: config.queryFn, - enabled: config.enabled ?? true, - staleTime: config.staleTime ?? 1000 * 60 * 5, - onError: config.onError, - })), - }); - - // Combine results - const combined = {} as { [K in keyof T]: T[K] | null }; - const keys = Object.keys(queries) as (keyof T)[]; - - keys.forEach((key, index) => { - combined[key] = queryResults[index].data ?? null; - }); - - const isLoading = queryResults.some(q => q.isLoading); - const error = queryResults.find(q => q.error)?.error ?? null; - - return { - data: combined, - isLoading, - error, - refetch: () => queryResults.forEach(q => q.refetch()), - }; -} - -/** - * Mutation hook wrapper - STANDARDIZED PATTERN - * Use for: All mutation operations - * - * @example - * const mutation = usePageMutation( - * (variables) => service.mutateData(variables), - * { onSuccess: () => refetch() } - * ); - */ -export function usePageMutation( - mutationFn: (variables: TVariables) => Promise, - options?: Omit, 'mutationFn'> -) { - return useMutation({ - mutationFn, - ...options, - }); -} - -/** - * SSR Hydration Hook - NEW - * Use for: Passing SSR data to CSR to avoid re-fetching - * - * @example - * // In SSR page - * const ssrData = await PageDataFetcher.fetch(...); - * - * // In client component - * const { data } = useHydrateSSRData(ssrData, ['queryKey']); - */ -export function useHydrateSSRData( - ssrData: TData | null, - queryKey: string[] -): { data: TData | null; isHydrated: boolean } { - const [isHydrated, setIsHydrated] = React.useState(false); - - React.useEffect(() => { - if (ssrData !== null) { - setIsHydrated(true); - } - }, [ssrData]); - - return { - data: ssrData, - isHydrated, - }; -} -``` - -### 3. Universal Page Wrapper - UPDATED FOR SSR/CSR COMPATIBILITY -**File**: `components/shared/state/PageWrapper.tsx` - -```typescript -import React from 'react'; -import { ApiError } from '@/lib/api/base/ApiError'; -import { LoadingWrapper } from './LoadingWrapper'; -import { ErrorDisplay } from './ErrorDisplay'; -import { EmptyState } from './EmptyState'; - -export interface PageWrapperLoadingConfig { - variant?: 'skeleton' | 'full-screen'; - message?: string; -} - -export interface PageWrapperErrorConfig { - variant?: 'full-screen' | 'card'; - card?: { - title?: string; - description?: string; - }; -} - -export interface PageWrapperEmptyConfig { - icon?: React.ElementType; - title?: string; - description?: string; - action?: { - label: string; - onClick: () => void; - }; -} - -export interface PageWrapperProps { - /** Data to be rendered */ - data: TData | undefined; - /** Loading state (default: false) */ - isLoading?: boolean; - /** Error state (default: null) */ - error?: Error | null; - /** Retry function for errors */ - retry?: () => void; - /** Template component that receives the data */ - Template: React.ComponentType<{ data: TData }>; - /** Loading configuration */ - loading?: PageWrapperLoadingConfig; - /** Error configuration */ - errorConfig?: PageWrapperErrorConfig; - /** Empty configuration */ - empty?: PageWrapperEmptyConfig; - /** Children for flexible content rendering */ - children?: React.ReactNode; - /** Additional CSS classes */ - className?: string; -} - -/** - * PageWrapper Component - SSR/CSR COMPATIBLE - * - * CRITICAL: This component is NOT marked 'use client' to work in SSR pages - * For CSR pages, use the wrapper version below - * - * Usage in SSR: - * ```typescript - * export default async function Page() { - * const data = await PageDataFetcher.fetch(...); - * return ; - * } - * ``` - * - * Usage in CSR: - * ```typescript - * export default function Page() { - * const { data, isLoading, error } = usePageData(...); - * return ( - * - * ); - * } - * ``` - */ -export function PageWrapper({ - data, - isLoading = false, - error = null, - retry, - Template, - loading, - errorConfig, - empty, - children, - className = '', -}: PageWrapperProps) { - // Priority order: Loading > Error > Empty > Success - - // 1. Loading State - if (isLoading) { - const loadingVariant = loading?.variant || 'skeleton'; - const loadingMessage = loading?.message || 'Loading...'; - - if (loadingVariant === 'full-screen') { - return ( - - ); - } - - // Default to skeleton - return ( -
- - {children} -
- ); - } - - // 2. Error State - if (error) { - const errorVariant = errorConfig?.variant || 'full-screen'; - - if (errorVariant === 'card') { - const cardTitle = errorConfig?.card?.title || 'Error'; - const cardDescription = errorConfig?.card?.description || 'Something went wrong'; - - return ( -
- - {children} -
- ); - } - - // Default to full-screen - return ( - - ); - } - - // 3. Empty State - if (!data || (Array.isArray(data) && data.length === 0)) { - if (empty) { - const Icon = empty.icon; - const hasAction = empty.action && retry; - - return ( -
- - {children} -
- ); - } - - // If no empty config provided but data is empty, show nothing - return ( -
- {children} -
- ); - } - - // 4. Success State - Render Template with data - return ( -
-