From e62673ac11dc5e9e1b17f590765b3a00c7ea91f3 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Mon, 20 Jul 2026 09:01:36 +0200 Subject: [PATCH] mvp --- .env.example | 5 + .prettierrc | 3 +- apps/backend/.prettierrc | 3 +- apps/backend/package.json | 9 +- apps/backend/src/app.module.ts | 4 + apps/backend/src/auth/auth.controller.ts | 10 +- apps/backend/src/auth/auth.service.spec.ts | 44 + apps/backend/src/auth/auth.service.ts | 42 +- .../auth/entities/oidc-login-state.entity.ts | 3 + apps/backend/src/auth/oidc.types.ts | 1 + .../src/common/errors/api-exception.filter.ts | 24 +- apps/backend/src/common/errors/error-codes.ts | 10 + apps/backend/src/config/config.service.ts | 10 + apps/backend/src/config/config.types.ts | 8 + apps/backend/src/config/env.ts | 17 + apps/backend/src/database/entities.ts | 50 + .../1720000002000-AddRoleDescription.spec.ts | 32 + .../1720000002000-AddRoleDescription.ts | 8 +- .../1720000003000-AddHausPilotProjects.ts | 92 + .../1720000004000-AddRenovationDomain.ts | 64 + .../1720000005000-AddMentionsAndReminders.ts | 25 + .../1720000006000-AddDefaultProjectFloors.ts | 44 + .../1720000007000-AddFurniturePlanning.ts | 95 + .../src/database/run-development-seed.ts | 21 + .../src/database/typeorm-cli.datasource.ts | 10 + apps/backend/src/database/typeorm-options.ts | 10 + .../src/notifications/notification-types.ts | 14 + apps/backend/src/projects/dto/project.dto.ts | 53 + .../entities/project-activity.entity.ts | 43 + .../entities/project-invitation.entity.ts | 101 + .../entities/project-membership.entity.ts | 57 + .../src/projects/entities/project.entity.ts | 53 + .../src/projects/project-access.service.ts | 34 + .../src/projects/projects.controller.ts | 178 ++ apps/backend/src/projects/projects.module.ts | 34 + apps/backend/src/projects/projects.service.ts | 732 +++++++ .../repositories/projects.repository.ts | 201 ++ .../tests/project-access.service.spec.ts | 42 + .../projects/tests/projects.service.spec.ts | 236 +++ .../renovation/development-seed.service.ts | 1044 ++++++++++ .../renovation/document-storage.service.ts | 101 + .../src/renovation/dto/furniture.dto.ts | 231 +++ .../src/renovation/dto/renovation.dto.ts | 296 +++ .../renovation/entities/furniture.entities.ts | 426 +++++ .../entities/renovation.entities.ts | 445 +++++ .../src/renovation/furniture-pricing.ts | 38 + .../src/renovation/furniture.controller.ts | 299 +++ .../src/renovation/furniture.repository.ts | 265 +++ .../src/renovation/furniture.service.ts | 1332 +++++++++++++ apps/backend/src/renovation/mentions.ts | 15 + apps/backend/src/renovation/progress.ts | 65 + .../src/renovation/reminder.service.ts | 298 +++ .../src/renovation/renovation.controller.ts | 415 ++++ .../src/renovation/renovation.module.ts | 98 + .../src/renovation/renovation.repository.ts | 289 +++ .../src/renovation/renovation.service.ts | 1702 +++++++++++++++++ apps/backend/src/renovation/templates.ts | 128 ++ .../tests/furniture-grid-query.spec.ts | 57 + .../tests/furniture-pricing.spec.ts | 43 + .../src/renovation/tests/mentions.spec.ts | 24 + .../src/renovation/tests/progress.spec.ts | 40 + .../renovation/tests/reminder.service.spec.ts | 143 ++ .../src/renovation/tests/templates.spec.ts | 39 + apps/backend/src/roles/permissions.ts | 1 + apps/backend/src/roles/roles.service.ts | 1 + .../backend/src/users/entities/user.entity.ts | 3 + .../users/repositories/users.repository.ts | 13 + apps/backend/tsconfig.json | 2 +- apps/frontend/.prettierrc | 1 + apps/frontend/package.json | 2 + apps/frontend/src/app/app.config.ts | 10 +- apps/frontend/src/app/app.routes.ts | 40 + .../src/app/core/auth.service.spec.ts | 14 + apps/frontend/src/app/core/auth.service.ts | 5 + .../app/core/session-expiry.interceptor.ts | 21 + .../account/account-security.page.spec.ts | 1 + .../projects/furniture-grid.component.spec.ts | 304 +++ .../projects/furniture-grid.component.ts | 975 ++++++++++ .../furniture-planning.component.spec.ts | 80 + .../projects/furniture-planning.component.ts | 1161 +++++++++++ .../projects/hauspilot-api.service.spec.ts | 47 + .../projects/hauspilot-api.service.ts | 568 ++++++ .../app/features/projects/invitations.page.ts | 225 +++ .../projects/project-calendar.component.ts | 342 ++++ .../features/projects/project-detail.page.ts | 262 +++ .../project-workspace.helpers.spec.ts | 17 + .../projects/project-workspace.helpers.ts | 18 + .../projects/project-workspace.page.ts | 1283 +++++++++++++ .../app/features/projects/projects.page.ts | 150 ++ .../app/features/projects/task-detail.page.ts | 797 ++++++++ apps/frontend/src/app/layout/app-shell.ts | 13 +- docs/hauspilot-auth-integration.md | 123 ++ docs/hauspilot-domain.md | 215 +++ package-lock.json | 210 +- package.json | 1 + packages/api-client/src/api-client.service.ts | 102 + packages/api-client/src/models.ts | 45 +- scripts/generate-api-client.mjs | 145 +- 98 files changed, 17372 insertions(+), 80 deletions(-) create mode 100644 apps/backend/src/database/migrations/1720000002000-AddRoleDescription.spec.ts create mode 100644 apps/backend/src/database/migrations/1720000003000-AddHausPilotProjects.ts create mode 100644 apps/backend/src/database/migrations/1720000004000-AddRenovationDomain.ts create mode 100644 apps/backend/src/database/migrations/1720000005000-AddMentionsAndReminders.ts create mode 100644 apps/backend/src/database/migrations/1720000006000-AddDefaultProjectFloors.ts create mode 100644 apps/backend/src/database/migrations/1720000007000-AddFurniturePlanning.ts create mode 100644 apps/backend/src/database/run-development-seed.ts create mode 100644 apps/backend/src/projects/dto/project.dto.ts create mode 100644 apps/backend/src/projects/entities/project-activity.entity.ts create mode 100644 apps/backend/src/projects/entities/project-invitation.entity.ts create mode 100644 apps/backend/src/projects/entities/project-membership.entity.ts create mode 100644 apps/backend/src/projects/entities/project.entity.ts create mode 100644 apps/backend/src/projects/project-access.service.ts create mode 100644 apps/backend/src/projects/projects.controller.ts create mode 100644 apps/backend/src/projects/projects.module.ts create mode 100644 apps/backend/src/projects/projects.service.ts create mode 100644 apps/backend/src/projects/repositories/projects.repository.ts create mode 100644 apps/backend/src/projects/tests/project-access.service.spec.ts create mode 100644 apps/backend/src/projects/tests/projects.service.spec.ts create mode 100644 apps/backend/src/renovation/development-seed.service.ts create mode 100644 apps/backend/src/renovation/document-storage.service.ts create mode 100644 apps/backend/src/renovation/dto/furniture.dto.ts create mode 100644 apps/backend/src/renovation/dto/renovation.dto.ts create mode 100644 apps/backend/src/renovation/entities/furniture.entities.ts create mode 100644 apps/backend/src/renovation/entities/renovation.entities.ts create mode 100644 apps/backend/src/renovation/furniture-pricing.ts create mode 100644 apps/backend/src/renovation/furniture.controller.ts create mode 100644 apps/backend/src/renovation/furniture.repository.ts create mode 100644 apps/backend/src/renovation/furniture.service.ts create mode 100644 apps/backend/src/renovation/mentions.ts create mode 100644 apps/backend/src/renovation/progress.ts create mode 100644 apps/backend/src/renovation/reminder.service.ts create mode 100644 apps/backend/src/renovation/renovation.controller.ts create mode 100644 apps/backend/src/renovation/renovation.module.ts create mode 100644 apps/backend/src/renovation/renovation.repository.ts create mode 100644 apps/backend/src/renovation/renovation.service.ts create mode 100644 apps/backend/src/renovation/templates.ts create mode 100644 apps/backend/src/renovation/tests/furniture-grid-query.spec.ts create mode 100644 apps/backend/src/renovation/tests/furniture-pricing.spec.ts create mode 100644 apps/backend/src/renovation/tests/mentions.spec.ts create mode 100644 apps/backend/src/renovation/tests/progress.spec.ts create mode 100644 apps/backend/src/renovation/tests/reminder.service.spec.ts create mode 100644 apps/backend/src/renovation/tests/templates.spec.ts create mode 100644 apps/frontend/src/app/core/session-expiry.interceptor.ts create mode 100644 apps/frontend/src/app/features/projects/furniture-grid.component.spec.ts create mode 100644 apps/frontend/src/app/features/projects/furniture-grid.component.ts create mode 100644 apps/frontend/src/app/features/projects/furniture-planning.component.spec.ts create mode 100644 apps/frontend/src/app/features/projects/furniture-planning.component.ts create mode 100644 apps/frontend/src/app/features/projects/hauspilot-api.service.spec.ts create mode 100644 apps/frontend/src/app/features/projects/hauspilot-api.service.ts create mode 100644 apps/frontend/src/app/features/projects/invitations.page.ts create mode 100644 apps/frontend/src/app/features/projects/project-calendar.component.ts create mode 100644 apps/frontend/src/app/features/projects/project-detail.page.ts create mode 100644 apps/frontend/src/app/features/projects/project-workspace.helpers.spec.ts create mode 100644 apps/frontend/src/app/features/projects/project-workspace.helpers.ts create mode 100644 apps/frontend/src/app/features/projects/project-workspace.page.ts create mode 100644 apps/frontend/src/app/features/projects/projects.page.ts create mode 100644 apps/frontend/src/app/features/projects/task-detail.page.ts create mode 100644 docs/hauspilot-auth-integration.md create mode 100644 docs/hauspilot-domain.md diff --git a/.env.example b/.env.example index 5331aa6..8a108b5 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,11 @@ CSRF_HEADER_NAME=X-CSRF-Token LOG_LEVEL=info SWAGGER_ENABLED=true +DOCUMENT_STORAGE_PATH=storage/documents +DOCUMENT_MAX_FILE_SIZE_BYTES=10485760 +REMINDER_INTERVAL_MS=900000 +REMINDER_DUE_SOON_DAYS=3 + RATE_LIMIT_WINDOW_SECONDS=60 RATE_LIMIT_MAX_REQUESTS=300 RATE_LIMIT_SENSITIVE_WINDOW_SECONDS=60 diff --git a/.prettierrc b/.prettierrc index 521e271..ddf3384 100644 --- a/.prettierrc +++ b/.prettierrc @@ -2,5 +2,6 @@ "singleQuote": true, "semi": true, "printWidth": 100, - "trailingComma": "all" + "trailingComma": "all", + "endOfLine": "auto" } diff --git a/apps/backend/.prettierrc b/apps/backend/.prettierrc index a20502b..4510dc5 100644 --- a/apps/backend/.prettierrc +++ b/apps/backend/.prettierrc @@ -1,4 +1,5 @@ { "singleQuote": true, - "trailingComma": "all" + "trailingComma": "all", + "endOfLine": "auto" } diff --git a/apps/backend/package.json b/apps/backend/package.json index 2b57366..3896019 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -11,14 +11,16 @@ "test": "vitest run --config vitest.config.ts", "migration:status": "typeorm-ts-node-commonjs -d src/database/typeorm-cli.datasource.ts migration:show", "migration:run": "typeorm-ts-node-commonjs -d src/database/typeorm-cli.datasource.ts migration:run", - "migration:generate": "typeorm-ts-node-commonjs -d src/database/typeorm-cli.datasource.ts migration:generate src/database/migrations/GeneratedMigration" + "migration:generate": "typeorm-ts-node-commonjs -d src/database/typeorm-cli.datasource.ts migration:generate src/database/migrations/GeneratedMigration", + "seed:development": "ts-node src/database/run-development-seed.ts" }, "dependencies": { "@nestjs/common": "11.1.28", "@nestjs/config": "4.0.4", "@nestjs/core": "11.1.28", "@nestjs/platform-express": "11.1.28", - "@nestjs/swagger": "11.2.3", + "@nestjs/schedule": "^6.1.3", + "@nestjs/swagger": "^11.4.6", "@nestjs/throttler": "6.4.0", "@nestjs/typeorm": "11.0.0", "class-transformer": "0.5.1", @@ -34,10 +36,11 @@ "pino-pretty": "13.1.2", "reflect-metadata": "0.2.2", "rxjs": "7.8.2", - "typeorm": "0.3.27", + "typeorm": "^0.3.31", "zod": "4.1.13" }, "devDependencies": { + "@types/multer": "^2.0.0", "typeorm-ts-node-commonjs": "0.3.20" } } diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index de091a3..89701e2 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -23,7 +23,9 @@ import { DashboardModule } from './dashboard/dashboard.module'; import { HealthModule } from './health/health.module'; import { ItemsModule } from './items/items.module'; import { NotificationsModule } from './notifications/notifications.module'; +import { ProjectsModule } from './projects/projects.module'; import { RolesModule } from './roles/roles.module'; +import { RenovationModule } from './renovation/renovation.module'; import { SessionsModule } from './sessions/sessions.module'; import { UsersModule } from './users/users.module'; @@ -83,6 +85,8 @@ const generateGlobalKey: ThrottlerGenerateKeyFunction = ( RolesModule, SessionsModule, NotificationsModule, + ProjectsModule, + RenovationModule, AuditModule, ItemsModule, HealthModule, diff --git a/apps/backend/src/auth/auth.controller.ts b/apps/backend/src/auth/auth.controller.ts index 7335f4d..5866008 100644 --- a/apps/backend/src/auth/auth.controller.ts +++ b/apps/backend/src/auth/auth.controller.ts @@ -17,8 +17,8 @@ export class AuthController { @Public() @SensitiveRateLimit() @Redirect() - async login() { - return { url: await this.auth.createLoginUrl() }; + async login(@Query('returnTo') returnTo?: string) { + return { url: await this.auth.createLoginUrl(returnTo) }; } @Get('callback') @@ -30,7 +30,7 @@ export class AuthController { @Req() req: AuthenticatedRequest, @Res() res: Response, ) { - const { session, csrfToken } = await this.auth.completeLogin( + const { session, csrfToken, returnPath } = await this.auth.completeLogin( code, state, req.get('user-agent'), @@ -51,7 +51,9 @@ export class AuthController { path: '/', expires: session.absoluteExpiresAt, }); - res.redirect(this.config.frontendBaseUrl); + res.redirect( + new URL(returnPath ?? '/', this.config.frontendBaseUrl).toString(), + ); } @Get('logout') diff --git a/apps/backend/src/auth/auth.service.spec.ts b/apps/backend/src/auth/auth.service.spec.ts index 904a0cf..131e97a 100644 --- a/apps/backend/src/auth/auth.service.spec.ts +++ b/apps/backend/src/auth/auth.service.spec.ts @@ -9,6 +9,50 @@ import type { OidcLoginStateEntity } from './entities/oidc-login-state.entity'; import { AuthService } from './auth.service'; describe('AuthService', () => { + it('stores only an internal return path in the short-lived OIDC login state', async () => { + const saved: OidcLoginStateEntity[] = []; + const service = new AuthService( + { + frontendBaseUrl: 'https://app.example.test', + appBaseUrl: 'https://app.example.test', + oidc: { + issuer: 'https://idp.example.test', + clientId: 'business-app', + clientSecret: 'secret', + scopes: 'openid profile email', + allowedAlgorithms: ['RS256'], + httpTimeoutMs: 5000, + }, + } as AppConfigService, + { + requestJson: () => + Promise.resolve({ + issuer: 'https://idp.example.test', + authorization_endpoint: 'https://idp.example.test/authorize', + token_endpoint: 'https://idp.example.test/token', + jwks_uri: 'https://idp.example.test/jwks', + }), + } as unknown as ExternalHttpClient, + {} as RolesService, + {} as UsersRepository, + {} as SessionsService, + {} as DataSource, + { + delete: () => Promise.resolve({}), + save: (state: OidcLoginStateEntity) => { + saved.push(state); + return Promise.resolve(state); + }, + } as unknown as Repository, + ); + + await service.createLoginUrl('/einladungen/sicher'); + await service.createLoginUrl('//evil.example/path'); + + expect(saved[0]?.returnPath).toBe('/einladungen/sicher'); + expect(saved[1]?.returnPath).toBeNull(); + }); + it('revokes the local session and redirects to the OIDC logout endpoint', async () => { const revoke = vi.fn<() => Promise>(() => Promise.resolve(1)); const getIdTokenForLogout = vi.fn<() => Promise>(() => diff --git a/apps/backend/src/auth/auth.service.ts b/apps/backend/src/auth/auth.service.ts index 4196e4f..64d3d93 100644 --- a/apps/backend/src/auth/auth.service.ts +++ b/apps/backend/src/auth/auth.service.ts @@ -31,7 +31,7 @@ export class AuthService { private readonly loginStates: Repository, ) {} - async createLoginUrl(): Promise { + async createLoginUrl(returnTo?: string): Promise { const discovery = await this.discovery(); const state = randomBytes(32).toString('hex'); const nonce = randomBytes(32).toString('base64url'); @@ -45,6 +45,7 @@ export class AuthService { loginState.state = state; loginState.codeVerifier = codeVerifier; loginState.nonce = nonce; + loginState.returnPath = this.safeReturnPath(returnTo); loginState.expiresAt = new Date(Date.now() + 10 * 60 * 1000); await this.loginStates.save(loginState); @@ -96,7 +97,7 @@ export class AuthService { ); } - return this.sessions.createSession( + const result = await this.sessions.createSession( user, { accessToken: tokens.access_token, @@ -109,6 +110,7 @@ export class AuthService { userAgent, ip, ); + return { ...result, returnPath: loginState.returnPath }; } async logout(sessionId: string | undefined): Promise { @@ -146,6 +148,10 @@ export class AuthService { } user.name = profile.name ?? profile.email ?? profile.sub; user.email = profile.email ?? null; + user.emailVerified = + typeof profile.email_verified === 'boolean' + ? profile.email_verified + : null; user.lastLoginAt = new Date(); const savedUser = await manager.getRepository(UserEntity).save(user); savedUser.settings = await this.ensureUserSettings(manager, savedUser); @@ -296,6 +302,9 @@ export class AuthService { if (typeof payload['email'] === 'string') { fallback.email = payload['email']; } + if (typeof payload['email_verified'] === 'boolean') { + fallback.email_verified = payload['email_verified']; + } return fallback; } const userInfo = await this.http.requestJson( @@ -311,6 +320,21 @@ export class AuthService { 401, ); } + if (!userInfo.name && typeof payload['name'] === 'string') { + userInfo.name = payload['name']; + } + if (!userInfo.email && typeof payload['email'] === 'string') { + userInfo.email = payload['email']; + } + if ( + typeof userInfo.email_verified !== 'boolean' && + typeof payload['email_verified'] === 'boolean' && + typeof payload['email'] === 'string' && + payload['email'].trim().toLowerCase() === + userInfo.email?.trim().toLowerCase() + ) { + userInfo.email_verified = payload['email_verified']; + } return userInfo; } @@ -325,4 +349,18 @@ export class AuthService { private get callbackUrl(): string { return new URL('/api/auth/callback', this.config.appBaseUrl).toString(); } + + private safeReturnPath(value: string | undefined): string | null { + if ( + !value || + value.length > 500 || + !value.startsWith('/') || + value.startsWith('//') || + value.includes('\\') || + /^[a-z][a-z0-9+.-]*:/i.test(value) + ) { + return null; + } + return value; + } } diff --git a/apps/backend/src/auth/entities/oidc-login-state.entity.ts b/apps/backend/src/auth/entities/oidc-login-state.entity.ts index 06300cf..2c1e45a 100644 --- a/apps/backend/src/auth/entities/oidc-login-state.entity.ts +++ b/apps/backend/src/auth/entities/oidc-login-state.entity.ts @@ -11,6 +11,9 @@ export class OidcLoginStateEntity { @Column({ type: 'varchar', length: 160 }) nonce!: string; + @Column({ name: 'return_path', type: 'varchar', length: 500, nullable: true }) + returnPath!: string | null; + @Column({ name: 'expires_at', type: 'datetime', precision: 3 }) expiresAt!: Date; diff --git a/apps/backend/src/auth/oidc.types.ts b/apps/backend/src/auth/oidc.types.ts index 9e37451..bde48a4 100644 --- a/apps/backend/src/auth/oidc.types.ts +++ b/apps/backend/src/auth/oidc.types.ts @@ -20,4 +20,5 @@ export interface OidcUserInfo { sub: string; name?: string; email?: string; + email_verified?: boolean; } diff --git a/apps/backend/src/common/errors/api-exception.filter.ts b/apps/backend/src/common/errors/api-exception.filter.ts index bb4610f..44386ff 100644 --- a/apps/backend/src/common/errors/api-exception.filter.ts +++ b/apps/backend/src/common/errors/api-exception.filter.ts @@ -46,9 +46,18 @@ export class ApiExceptionFilter implements ExceptionFilter { ? message : 'Die Anfrage konnte nicht verarbeitet werden.'; } else if (exception instanceof QueryFailedError) { - status = HttpStatus.CONFLICT; - code = ErrorCode.Conflict; - message = 'Die Aenderung steht im Konflikt mit bestehenden Daten.'; + const driverCode = this.driverCode(exception.driverError as unknown); + if ( + [ + 'ER_DUP_ENTRY', + 'ER_ROW_IS_REFERENCED_2', + 'ER_NO_REFERENCED_ROW_2', + ].includes(driverCode ?? '') + ) { + status = HttpStatus.CONFLICT; + code = ErrorCode.Conflict; + message = 'Die Aenderung steht im Konflikt mit bestehenden Daten.'; + } } if (status >= 500) { @@ -63,4 +72,13 @@ export class ApiExceptionFilter implements ExceptionFilter { ...(validation ? { validation } : {}), } satisfies ApiErrorBody); } + + private driverCode(driverError: unknown): string | undefined { + return typeof driverError === 'object' && + driverError !== null && + 'code' in driverError && + typeof driverError.code === 'string' + ? driverError.code + : undefined; + } } diff --git a/apps/backend/src/common/errors/error-codes.ts b/apps/backend/src/common/errors/error-codes.ts index 77ab47d..8e33fd9 100644 --- a/apps/backend/src/common/errors/error-codes.ts +++ b/apps/backend/src/common/errors/error-codes.ts @@ -25,5 +25,15 @@ export enum ErrorCode { SystemRoleProtected = 'SYSTEM_ROLE_PROTECTED', UnknownPermission = 'UNKNOWN_PERMISSION', SessionNotFound = 'SESSION_NOT_FOUND', + ProjectNotFound = 'PROJECT_NOT_FOUND', + ProjectAccessDenied = 'PROJECT_ACCESS_DENIED', + ProjectMemberNotFound = 'PROJECT_MEMBER_NOT_FOUND', + ProjectMemberAlreadyExists = 'PROJECT_MEMBER_ALREADY_EXISTS', + ProjectOwnerProtected = 'PROJECT_OWNER_PROTECTED', + ProjectInvitationNotFound = 'PROJECT_INVITATION_NOT_FOUND', + ProjectInvitationAlreadyExists = 'PROJECT_INVITATION_ALREADY_EXISTS', + ProjectInvitationExpired = 'PROJECT_INVITATION_EXPIRED', + ProjectInvitationEmailMismatch = 'PROJECT_INVITATION_EMAIL_MISMATCH', + ProjectInvitationEmailUnverified = 'PROJECT_INVITATION_EMAIL_UNVERIFIED', InternalError = 'INTERNAL_ERROR', } diff --git a/apps/backend/src/config/config.service.ts b/apps/backend/src/config/config.service.ts index b93c32e..70010a8 100644 --- a/apps/backend/src/config/config.service.ts +++ b/apps/backend/src/config/config.service.ts @@ -26,6 +26,8 @@ export class AppConfigService { swaggerEnabled: this.nestConfig.getOrThrow('swaggerEnabled', { infer: true, }), + documents: this.nestConfig.getOrThrow('documents', { infer: true }), + reminders: this.nestConfig.getOrThrow('reminders', { infer: true }), rateLimit: this.nestConfig.getOrThrow('rateLimit', { infer: true }), }; } @@ -82,6 +84,14 @@ export class AppConfigService { return this.config.swaggerEnabled; } + get documents(): AppConfig['documents'] { + return this.config.documents; + } + + get reminders(): AppConfig['reminders'] { + return this.config.reminders; + } + get rateLimit(): AppConfig['rateLimit'] { return this.config.rateLimit; } diff --git a/apps/backend/src/config/config.types.ts b/apps/backend/src/config/config.types.ts index 91c8e45..c02e04d 100644 --- a/apps/backend/src/config/config.types.ts +++ b/apps/backend/src/config/config.types.ts @@ -39,6 +39,14 @@ export interface AppConfig { csrfHeaderName: string; logLevel: string; swaggerEnabled: boolean; + documents: { + storagePath: string; + maxFileSizeBytes: number; + }; + reminders: { + intervalMs: number; + dueSoonDays: number; + }; rateLimit: { global: RateLimitRuleConfig; sensitive: RateLimitRuleConfig; diff --git a/apps/backend/src/config/env.ts b/apps/backend/src/config/env.ts index eae66d1..2ecb915 100644 --- a/apps/backend/src/config/env.ts +++ b/apps/backend/src/config/env.ts @@ -71,6 +71,15 @@ const envSchema = z.object({ CSRF_HEADER_NAME: z.string().min(1).default('X-CSRF-Token'), LOG_LEVEL: z.string().min(1).default('info'), SWAGGER_ENABLED: booleanFromString.default(false), + DOCUMENT_STORAGE_PATH: z.string().min(1).default('storage/documents'), + DOCUMENT_MAX_FILE_SIZE_BYTES: z.coerce + .number() + .int() + .min(1024) + .max(50 * 1024 * 1024) + .default(10 * 1024 * 1024), + REMINDER_INTERVAL_MS: z.coerce.number().int().min(60000).default(900000), + REMINDER_DUE_SOON_DAYS: z.coerce.number().int().min(1).max(30).default(3), RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().min(1).default(60), RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().min(1).default(300), RATE_LIMIT_SENSITIVE_WINDOW_SECONDS: z.coerce @@ -138,6 +147,14 @@ export function loadConfigFromEnv(env: Record): AppConfig { csrfHeaderName: value.CSRF_HEADER_NAME, logLevel: value.LOG_LEVEL, swaggerEnabled: value.SWAGGER_ENABLED, + documents: { + storagePath: value.DOCUMENT_STORAGE_PATH, + maxFileSizeBytes: value.DOCUMENT_MAX_FILE_SIZE_BYTES, + }, + reminders: { + intervalMs: value.REMINDER_INTERVAL_MS, + dueSoonDays: value.REMINDER_DUE_SOON_DAYS, + }, rateLimit: { global: { windowSeconds: value.RATE_LIMIT_WINDOW_SECONDS, diff --git a/apps/backend/src/database/entities.ts b/apps/backend/src/database/entities.ts index c9dbebf..dc748b1 100644 --- a/apps/backend/src/database/entities.ts +++ b/apps/backend/src/database/entities.ts @@ -2,20 +2,70 @@ import { AuditLogEntity } from '../audit/entities/audit-log.entity'; import { OidcLoginStateEntity } from '../auth/entities/oidc-login-state.entity'; import { ItemEntity } from '../items/entities/item.entity'; import { NotificationEntity } from '../notifications/entities/notification.entity'; +import { ProjectActivityEntity } from '../projects/entities/project-activity.entity'; +import { ProjectInvitationEntity } from '../projects/entities/project-invitation.entity'; +import { ProjectMembershipEntity } from '../projects/entities/project-membership.entity'; +import { ProjectEntity } from '../projects/entities/project.entity'; import { RoleEntity } from '../roles/entities/role.entity'; import { PermissionEntity } from '../roles/entities/permission.entity'; import { SessionEntity } from '../sessions/entities/session.entity'; import { UserSettingsEntity } from '../users/entities/user-settings.entity'; import { UserEntity } from '../users/entities/user.entity'; +import { + BudgetCategoryEntity, + BuildingEntity, + ChecklistItemEntity, + ExpenseEntity, + FloorEntity, + MilestoneEntity, + ProjectDocumentEntity, + RenovationTaskEntity, + RoomEntity, + TaskCommentEntity, + TaskDependencyEntity, + TaskCommentMentionEntity, + ReminderDeliveryEntity, + AppliedProjectTemplateEntity, +} from '../renovation/entities/renovation.entities'; +import { + FurnitureOptionDocumentEntity, + FurnitureOptionEntity, + FurnitureRequirementEntity, + FurnitureScenarioEntity, + FurnitureScenarioSelectionEntity, +} from '../renovation/entities/furniture.entities'; export const entities = [ AuditLogEntity, OidcLoginStateEntity, ItemEntity, NotificationEntity, + ProjectEntity, + ProjectMembershipEntity, + ProjectInvitationEntity, + ProjectActivityEntity, RoleEntity, PermissionEntity, SessionEntity, UserSettingsEntity, UserEntity, + BuildingEntity, + FloorEntity, + RoomEntity, + RenovationTaskEntity, + ChecklistItemEntity, + TaskDependencyEntity, + TaskCommentEntity, + MilestoneEntity, + BudgetCategoryEntity, + ExpenseEntity, + ProjectDocumentEntity, + TaskCommentMentionEntity, + ReminderDeliveryEntity, + AppliedProjectTemplateEntity, + FurnitureRequirementEntity, + FurnitureOptionEntity, + FurnitureScenarioEntity, + FurnitureScenarioSelectionEntity, + FurnitureOptionDocumentEntity, ]; diff --git a/apps/backend/src/database/migrations/1720000002000-AddRoleDescription.spec.ts b/apps/backend/src/database/migrations/1720000002000-AddRoleDescription.spec.ts new file mode 100644 index 0000000..c37548c --- /dev/null +++ b/apps/backend/src/database/migrations/1720000002000-AddRoleDescription.spec.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { QueryRunner } from 'typeorm'; +import { AddRoleDescription1720000002000 } from './1720000002000-AddRoleDescription'; + +describe('AddRoleDescription1720000002000', () => { + it('does not add the baseline description column a second time', async () => { + const query = vi.fn(); + const runner = { + hasColumn: () => Promise.resolve(true), + query, + } as unknown as QueryRunner; + + await new AddRoleDescription1720000002000().up(runner); + + expect(query).not.toHaveBeenCalled(); + }); + + it('upgrades a legacy roles table that does not have the column', async () => { + const query = vi.fn<(sql: string) => Promise>(() => + Promise.resolve(), + ); + const runner = { + hasColumn: () => Promise.resolve(false), + query, + } as unknown as QueryRunner; + + await new AddRoleDescription1720000002000().up(runner); + + expect(query).toHaveBeenCalledOnce(); + expect(String(query.mock.calls[0]?.[0])).toContain('ADD description'); + }); +}); diff --git a/apps/backend/src/database/migrations/1720000002000-AddRoleDescription.ts b/apps/backend/src/database/migrations/1720000002000-AddRoleDescription.ts index cd1ffcb..cfaf60e 100644 --- a/apps/backend/src/database/migrations/1720000002000-AddRoleDescription.ts +++ b/apps/backend/src/database/migrations/1720000002000-AddRoleDescription.ts @@ -4,13 +4,17 @@ export class AddRoleDescription1720000002000 implements MigrationInterface { name = 'AddRoleDescription1720000002000'; async up(queryRunner: QueryRunner): Promise { + if (await queryRunner.hasColumn('roles', 'description')) { + return; + } await queryRunner.query(` ALTER TABLE roles ADD description varchar(255) NOT NULL DEFAULT '' `); } - async down(queryRunner: QueryRunner): Promise { - await queryRunner.query('ALTER TABLE roles DROP COLUMN description'); + async down(): Promise { + // The current baseline schema already owns this column. Removing it here + // would corrupt databases created by InitialSchema1720000000000. } } diff --git a/apps/backend/src/database/migrations/1720000003000-AddHausPilotProjects.ts b/apps/backend/src/database/migrations/1720000003000-AddHausPilotProjects.ts new file mode 100644 index 0000000..1401d88 --- /dev/null +++ b/apps/backend/src/database/migrations/1720000003000-AddHausPilotProjects.ts @@ -0,0 +1,92 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddHausPilotProjects1720000003000 implements MigrationInterface { + name = 'AddHausPilotProjects1720000003000'; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'ALTER TABLE users ADD email_verified tinyint NULL AFTER email', + ); + await queryRunner.query( + 'ALTER TABLE oidc_login_states ADD return_path varchar(500) NULL AFTER nonce', + ); + await queryRunner.query(` + CREATE TABLE projects ( + id char(36) NOT NULL, + name varchar(160) NOT NULL, + description text NULL, + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + await queryRunner.query(` + CREATE TABLE project_memberships ( + id char(36) NOT NULL, + project_id char(36) NOT NULL, + user_id char(36) NOT NULL, + role enum('owner','administrator','editor','reader') NOT NULL, + active tinyint NOT NULL DEFAULT 1, + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + UNIQUE KEY uq_project_memberships_project_user (project_id, user_id), + KEY idx_project_memberships_project (project_id), + KEY idx_project_memberships_user (user_id), + CONSTRAINT fk_project_memberships_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + CONSTRAINT fk_project_memberships_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT, + PRIMARY KEY (id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + await queryRunner.query(` + CREATE TABLE project_invitations ( + id char(36) NOT NULL, + project_id char(36) NOT NULL, + invited_email varchar(320) NOT NULL, + invited_user_id char(36) NULL, + invited_by_user_id char(36) NOT NULL, + role enum('owner','administrator','editor','reader') NOT NULL, + token_hash char(64) NOT NULL, + status enum('pending','accepted','declined','revoked') NOT NULL, + mail_status enum('not_configured') NOT NULL, + expires_at datetime(3) NOT NULL, + accepted_by_user_id char(36) NULL, + responded_at datetime(3) NULL, + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + UNIQUE KEY uq_project_invitations_token_hash (token_hash), + KEY idx_project_invitations_project (project_id), + KEY idx_project_invitations_email (invited_email), + CONSTRAINT fk_project_invitations_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + CONSTRAINT fk_project_invitations_invited_user FOREIGN KEY (invited_user_id) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_project_invitations_invited_by FOREIGN KEY (invited_by_user_id) REFERENCES users(id) ON DELETE RESTRICT, + CONSTRAINT fk_project_invitations_accepted_by FOREIGN KEY (accepted_by_user_id) REFERENCES users(id) ON DELETE SET NULL, + PRIMARY KEY (id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + await queryRunner.query(` + CREATE TABLE project_activities ( + id char(36) NOT NULL, + project_id char(36) NOT NULL, + actor_user_id char(36) NULL, + action varchar(80) NOT NULL, + metadata json NULL, + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + KEY idx_project_activities_project_created (project_id, created_at), + CONSTRAINT fk_project_activities_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + CONSTRAINT fk_project_activities_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL, + PRIMARY KEY (id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP TABLE project_activities'); + await queryRunner.query('DROP TABLE project_invitations'); + await queryRunner.query('DROP TABLE project_memberships'); + await queryRunner.query('DROP TABLE projects'); + await queryRunner.query( + 'ALTER TABLE oidc_login_states DROP COLUMN return_path', + ); + await queryRunner.query('ALTER TABLE users DROP COLUMN email_verified'); + } +} diff --git a/apps/backend/src/database/migrations/1720000004000-AddRenovationDomain.ts b/apps/backend/src/database/migrations/1720000004000-AddRenovationDomain.ts new file mode 100644 index 0000000..21f858d --- /dev/null +++ b/apps/backend/src/database/migrations/1720000004000-AddRenovationDomain.ts @@ -0,0 +1,64 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddRenovationDomain1720000004000 implements MigrationInterface { + name = 'AddRenovationDomain1720000004000'; + + async up(q: QueryRunner): Promise { + await q.query( + "ALTER TABLE projects ADD status varchar(30) NOT NULL DEFAULT 'planning', ADD total_budget decimal(13,2) NULL, ADD currency char(3) NOT NULL DEFAULT 'EUR'", + ); + await q.query( + `CREATE TABLE buildings (id char(36) NOT NULL, project_id char(36) NOT NULL, name varchar(160) NOT NULL, description text NULL, type varchar(40) NOT NULL, sort_order int NOT NULL DEFAULT 0, version int NOT NULL DEFAULT 1, created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), PRIMARY KEY(id), KEY idx_buildings_project_sort(project_id,sort_order), CONSTRAINT fk_buildings_project FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + ); + await q.query( + `CREATE TABLE floors (id char(36) NOT NULL, project_id char(36) NOT NULL, building_id char(36) NOT NULL, name varchar(160) NOT NULL, description text NULL, sort_order int NOT NULL DEFAULT 0, version int NOT NULL DEFAULT 1, created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), PRIMARY KEY(id), KEY idx_floors_project_building_sort(project_id,building_id,sort_order), CONSTRAINT fk_floors_project FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE, CONSTRAINT fk_floors_building FOREIGN KEY(building_id) REFERENCES buildings(id) ON DELETE RESTRICT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + ); + await q.query( + `CREATE TABLE rooms (id char(36) NOT NULL, project_id char(36) NOT NULL, floor_id char(36) NOT NULL, name varchar(160) NOT NULL, description text NULL, type varchar(40) NOT NULL, area decimal(10,2) NULL, status varchar(30) NOT NULL, planned_budget decimal(13,2) NULL, sort_order int NOT NULL DEFAULT 0, preview_document_id char(36) NULL, version int NOT NULL DEFAULT 1, created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), deleted_at datetime(3) NULL, PRIMARY KEY(id), KEY idx_rooms_project_floor_sort(project_id,floor_id,sort_order), CONSTRAINT fk_rooms_project FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE, CONSTRAINT fk_rooms_floor FOREIGN KEY(floor_id) REFERENCES floors(id) ON DELETE RESTRICT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + ); + await q.query( + `CREATE TABLE renovation_tasks (id char(36) NOT NULL, project_id char(36) NOT NULL, room_id char(36) NULL, title varchar(200) NOT NULL, description text NULL, category varchar(40) NOT NULL, status varchar(30) NOT NULL, priority varchar(20) NOT NULL, assignee_user_id char(36) NULL, planned_start_date date NULL, due_date date NULL, completed_at datetime(3) NULL, estimated_effort_hours decimal(8,2) NULL, estimated_cost decimal(13,2) NULL, actual_cost decimal(13,2) NULL, blocking_reason varchar(1000) NULL, sort_order int NOT NULL DEFAULT 0, weight decimal(6,2) NOT NULL DEFAULT 1, created_by_user_id char(36) NOT NULL, version int NOT NULL DEFAULT 1, created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), deleted_at datetime(3) NULL, PRIMARY KEY(id), KEY idx_tasks_project_status_due(project_id,status,due_date), KEY idx_tasks_assignee(assignee_user_id), CONSTRAINT fk_tasks_project FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE, CONSTRAINT fk_tasks_room FOREIGN KEY(room_id) REFERENCES rooms(id) ON DELETE RESTRICT, CONSTRAINT fk_tasks_assignee FOREIGN KEY(assignee_user_id) REFERENCES users(id) ON DELETE SET NULL, CONSTRAINT fk_tasks_creator FOREIGN KEY(created_by_user_id) REFERENCES users(id) ON DELETE RESTRICT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + ); + await q.query( + `CREATE TABLE task_checklist_items (id char(36) NOT NULL, project_id char(36) NOT NULL, task_id char(36) NOT NULL, text varchar(500) NOT NULL, completed tinyint NOT NULL DEFAULT 0, sort_order int NOT NULL DEFAULT 0, completed_by_user_id char(36) NULL, completed_at datetime(3) NULL, created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), PRIMARY KEY(id), KEY idx_checklist_task_sort(task_id,sort_order), CONSTRAINT fk_checklist_project FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE, CONSTRAINT fk_checklist_task FOREIGN KEY(task_id) REFERENCES renovation_tasks(id) ON DELETE CASCADE, CONSTRAINT fk_checklist_user FOREIGN KEY(completed_by_user_id) REFERENCES users(id) ON DELETE SET NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + ); + await q.query( + `CREATE TABLE task_dependencies (id char(36) NOT NULL, project_id char(36) NOT NULL, predecessor_task_id char(36) NOT NULL, successor_task_id char(36) NOT NULL, type varchar(30) NOT NULL DEFAULT 'finish_to_start', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY(id), UNIQUE KEY uq_task_dependency(predecessor_task_id,successor_task_id), CONSTRAINT fk_dependency_project FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE, CONSTRAINT fk_dependency_predecessor FOREIGN KEY(predecessor_task_id) REFERENCES renovation_tasks(id) ON DELETE CASCADE, CONSTRAINT fk_dependency_successor FOREIGN KEY(successor_task_id) REFERENCES renovation_tasks(id) ON DELETE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + ); + await q.query( + `CREATE TABLE task_comments (id char(36) NOT NULL, project_id char(36) NOT NULL, task_id char(36) NOT NULL, author_user_id char(36) NOT NULL, text varchar(4000) NOT NULL, created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), deleted_at datetime(3) NULL, PRIMARY KEY(id), KEY idx_comments_project_task_created(project_id,task_id,created_at), CONSTRAINT fk_comments_project FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE, CONSTRAINT fk_comments_task FOREIGN KEY(task_id) REFERENCES renovation_tasks(id) ON DELETE CASCADE, CONSTRAINT fk_comments_author FOREIGN KEY(author_user_id) REFERENCES users(id) ON DELETE RESTRICT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + ); + await q.query( + `CREATE TABLE milestones (id char(36) NOT NULL, project_id char(36) NOT NULL, title varchar(200) NOT NULL, description text NULL, date date NOT NULL, status varchar(30) NOT NULL, type varchar(40) NOT NULL, responsible_user_id char(36) NULL, version int NOT NULL DEFAULT 1, created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), PRIMARY KEY(id), KEY idx_milestones_project_date(project_id,date), CONSTRAINT fk_milestones_project FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE, CONSTRAINT fk_milestones_user FOREIGN KEY(responsible_user_id) REFERENCES users(id) ON DELETE SET NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + ); + await q.query( + `CREATE TABLE budget_categories (id char(36) NOT NULL, project_id char(36) NOT NULL, name varchar(120) NOT NULL, planned_budget decimal(13,2) NOT NULL, sort_order int NOT NULL DEFAULT 0, active tinyint NOT NULL DEFAULT 1, version int NOT NULL DEFAULT 1, created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), PRIMARY KEY(id), KEY idx_budget_categories_project_sort(project_id,sort_order), CONSTRAINT fk_budgets_project FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + ); + await q.query( + `CREATE TABLE project_documents (id char(36) NOT NULL, project_id char(36) NOT NULL, room_id char(36) NULL, task_id char(36) NULL, type varchar(40) NOT NULL, title varchar(200) NOT NULL, description text NULL, original_filename varchar(255) NOT NULL, storage_name varchar(100) NOT NULL, mime_type varchar(100) NOT NULL, file_size int unsigned NOT NULL, storage_reference varchar(500) NOT NULL, uploaded_by_user_id char(36) NOT NULL, uploaded_at datetime(3) NOT NULL, version int NOT NULL DEFAULT 1, created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), deleted_at datetime(3) NULL, PRIMARY KEY(id), KEY idx_documents_project_created(project_id,created_at), CONSTRAINT fk_documents_project FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE, CONSTRAINT fk_documents_room FOREIGN KEY(room_id) REFERENCES rooms(id) ON DELETE RESTRICT, CONSTRAINT fk_documents_task FOREIGN KEY(task_id) REFERENCES renovation_tasks(id) ON DELETE RESTRICT, CONSTRAINT fk_documents_uploader FOREIGN KEY(uploaded_by_user_id) REFERENCES users(id) ON DELETE RESTRICT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + ); + await q.query( + `CREATE TABLE expenses (id char(36) NOT NULL, project_id char(36) NOT NULL, budget_category_id char(36) NOT NULL, room_id char(36) NULL, task_id char(36) NULL, title varchar(200) NOT NULL, description text NULL, amount decimal(13,2) NOT NULL, currency char(3) NOT NULL DEFAULT 'EUR', expense_date date NOT NULL, payment_status varchar(20) NOT NULL, due_date date NULL, supplier varchar(200) NULL, invoice_number varchar(100) NULL, document_id char(36) NULL, created_by_user_id char(36) NOT NULL, version int NOT NULL DEFAULT 1, created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), PRIMARY KEY(id), KEY idx_expenses_project_status_date(project_id,payment_status,expense_date), CONSTRAINT fk_expenses_project FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE, CONSTRAINT fk_expenses_budget FOREIGN KEY(budget_category_id) REFERENCES budget_categories(id) ON DELETE RESTRICT, CONSTRAINT fk_expenses_room FOREIGN KEY(room_id) REFERENCES rooms(id) ON DELETE RESTRICT, CONSTRAINT fk_expenses_task FOREIGN KEY(task_id) REFERENCES renovation_tasks(id) ON DELETE RESTRICT, CONSTRAINT fk_expenses_document FOREIGN KEY(document_id) REFERENCES project_documents(id) ON DELETE SET NULL, CONSTRAINT fk_expenses_creator FOREIGN KEY(created_by_user_id) REFERENCES users(id) ON DELETE RESTRICT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + ); + } + + async down(q: QueryRunner): Promise { + for (const table of [ + 'expenses', + 'project_documents', + 'budget_categories', + 'milestones', + 'task_comments', + 'task_dependencies', + 'task_checklist_items', + 'renovation_tasks', + 'rooms', + 'floors', + 'buildings', + ]) + await q.query(`DROP TABLE ${table}`); + await q.query( + 'ALTER TABLE projects DROP COLUMN currency, DROP COLUMN total_budget, DROP COLUMN status', + ); + } +} diff --git a/apps/backend/src/database/migrations/1720000005000-AddMentionsAndReminders.ts b/apps/backend/src/database/migrations/1720000005000-AddMentionsAndReminders.ts new file mode 100644 index 0000000..53a667d --- /dev/null +++ b/apps/backend/src/database/migrations/1720000005000-AddMentionsAndReminders.ts @@ -0,0 +1,25 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddMentionsAndReminders1720000005000 + implements MigrationInterface +{ + name = 'AddMentionsAndReminders1720000005000'; + + async up(q: QueryRunner): Promise { + await q.query( + `CREATE TABLE task_comment_mentions (id char(36) NOT NULL, project_id char(36) NOT NULL, comment_id char(36) NOT NULL, user_id char(36) NOT NULL, notification_created tinyint NOT NULL DEFAULT 0, created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY(id), UNIQUE KEY uq_task_comment_mentions_comment_user(comment_id,user_id), KEY idx_task_comment_mentions_project_user(project_id,user_id), CONSTRAINT fk_mentions_project FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE, CONSTRAINT fk_mentions_comment FOREIGN KEY(comment_id) REFERENCES task_comments(id) ON DELETE CASCADE, CONSTRAINT fk_mentions_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE RESTRICT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + ); + await q.query( + `CREATE TABLE reminder_deliveries (id char(36) NOT NULL, project_id char(36) NOT NULL, user_id char(36) NOT NULL, entity_type varchar(40) NOT NULL, entity_id char(36) NOT NULL, reminder_type varchar(50) NOT NULL, reference_date date NOT NULL, dedupe_key varchar(255) NOT NULL, sent_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY(id), UNIQUE KEY uq_reminder_deliveries_dedupe_key(dedupe_key), KEY idx_reminder_deliveries_project_entity(project_id,entity_type,entity_id), CONSTRAINT fk_reminders_project FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE, CONSTRAINT fk_reminders_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + ); + await q.query( + `CREATE TABLE applied_project_templates (id char(36) NOT NULL, project_id char(36) NOT NULL, template_id varchar(80) NOT NULL, target_key varchar(80) NOT NULL DEFAULT 'project', applied_by_user_id char(36) NOT NULL, applied_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY(id), UNIQUE KEY uq_applied_templates_project_template_target(project_id,template_id,target_key), CONSTRAINT fk_applied_templates_project FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE, CONSTRAINT fk_applied_templates_user FOREIGN KEY(applied_by_user_id) REFERENCES users(id) ON DELETE RESTRICT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + ); + } + + async down(q: QueryRunner): Promise { + await q.query('DROP TABLE applied_project_templates'); + await q.query('DROP TABLE reminder_deliveries'); + await q.query('DROP TABLE task_comment_mentions'); + } +} diff --git a/apps/backend/src/database/migrations/1720000006000-AddDefaultProjectFloors.ts b/apps/backend/src/database/migrations/1720000006000-AddDefaultProjectFloors.ts new file mode 100644 index 0000000..30b2455 --- /dev/null +++ b/apps/backend/src/database/migrations/1720000006000-AddDefaultProjectFloors.ts @@ -0,0 +1,44 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddDefaultProjectFloors1720000006000 + implements MigrationInterface +{ + name = 'AddDefaultProjectFloors1720000006000'; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `INSERT INTO buildings (id, project_id, name, description, type, sort_order, version, created_at, updated_at) + SELECT UUID(), p.id, 'Haus', NULL, 'terraced_house', 0, 1, CURRENT_TIMESTAMP(3), CURRENT_TIMESTAMP(3) + FROM projects p + WHERE NOT EXISTS (SELECT 1 FROM buildings b WHERE b.project_id = p.id)`, + ); + for (const [sortOrder, name] of [ + 'Keller', + 'Erdgeschoss', + '1. Stock', + 'Dachboden', + ].entries()) { + await queryRunner.query( + `INSERT INTO floors (id, project_id, building_id, name, description, sort_order, version, created_at, updated_at) + SELECT UUID(), p.id, + (SELECT b.id FROM buildings b WHERE b.project_id = p.id ORDER BY b.sort_order, b.created_at LIMIT 1), + ?, NULL, ?, 1, CURRENT_TIMESTAMP(3), CURRENT_TIMESTAMP(3) + FROM projects p + WHERE NOT EXISTS (SELECT 1 FROM floors f WHERE f.project_id = p.id AND LOWER(f.name) = LOWER(?))`, + [name, sortOrder, name], + ); + } + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE f FROM floors f + WHERE f.name IN ('Keller','Erdgeschoss','1. Stock','Dachboden') + AND NOT EXISTS (SELECT 1 FROM rooms r WHERE r.floor_id = f.id)`, + ); + await queryRunner.query( + `DELETE b FROM buildings b + WHERE b.name = 'Haus' AND NOT EXISTS (SELECT 1 FROM floors f WHERE f.building_id = b.id)`, + ); + } +} diff --git a/apps/backend/src/database/migrations/1720000007000-AddFurniturePlanning.ts b/apps/backend/src/database/migrations/1720000007000-AddFurniturePlanning.ts new file mode 100644 index 0000000..3961b74 --- /dev/null +++ b/apps/backend/src/database/migrations/1720000007000-AddFurniturePlanning.ts @@ -0,0 +1,95 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddFurniturePlanning1720000007000 implements MigrationInterface { + name = 'AddFurniturePlanning1720000007000'; + async up(q: QueryRunner): Promise { + await q.query(`CREATE TABLE IF NOT EXISTS furniture_requirements ( + id char(36) NOT NULL, project_id char(36) NOT NULL, room_id char(36) NOT NULL, + name varchar(160) NOT NULL, description text NULL, category varchar(30) NOT NULL, + priority varchar(20) NOT NULL, required_quantity int unsigned NOT NULL DEFAULT 1, + status varchar(30) NOT NULL, responsible_user_id char(36) NULL, + maximum_budget decimal(13,2) NULL, sort_order int NOT NULL DEFAULT 0, + version int NOT NULL DEFAULT 1, created_by_user_id char(36) NOT NULL, + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), deleted_at datetime(3) NULL, + PRIMARY KEY(id), KEY idx_furniture_requirements_project_room_sort(project_id,room_id,sort_order), + CONSTRAINT fk_furniture_requirement_project FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE, + CONSTRAINT fk_furniture_requirement_room FOREIGN KEY(room_id) REFERENCES rooms(id) ON DELETE RESTRICT, + CONSTRAINT fk_furniture_requirement_responsible FOREIGN KEY(responsible_user_id) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_furniture_requirement_creator FOREIGN KEY(created_by_user_id) REFERENCES users(id) ON DELETE RESTRICT + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`); + await q.query(`CREATE TABLE furniture_options ( + id char(36) NOT NULL, project_id char(36) NOT NULL, requirement_id char(36) NOT NULL, + name varchar(180) NOT NULL, manufacturer varchar(160) NULL, model varchar(160) NULL, description text NULL, + retailer varchar(180) NULL, product_url varchar(1000) NULL, article_number varchar(120) NULL, + unit_price decimal(13,2) NOT NULL, original_price decimal(13,2) NULL, shipping_cost decimal(13,2) NOT NULL DEFAULT 0, + additional_cost decimal(13,2) NOT NULL DEFAULT 0, discount decimal(13,2) NOT NULL DEFAULT 0, + total_price decimal(13,2) NOT NULL, currency char(3) NOT NULL DEFAULT 'EUR', quantity int unsigned NOT NULL DEFAULT 1, + width decimal(9,2) NULL, height decimal(9,2) NULL, depth decimal(9,2) NULL, weight decimal(9,2) NULL, + color varchar(100) NULL, material varchar(160) NULL, delivery_days int unsigned NULL, + earliest_delivery_date date NULL, expected_delivery_date date NULL, return_deadline date NULL, + availability varchar(30) NOT NULL, favorite tinyint NOT NULL DEFAULT 0, currently_selected tinyint NOT NULL DEFAULT 0, + status varchar(30) NOT NULL, notes text NULL, budget_category_id char(36) NULL, + existing_item tinyint NOT NULL DEFAULT 0, estimated_current_value decimal(13,2) NULL, + moving_cost decimal(13,2) NOT NULL DEFAULT 0, refurbishment_cost decimal(13,2) NOT NULL DEFAULT 0, + current_location varchar(200) NULL, item_condition varchar(30) NULL, + ordered_at datetime(3) NULL, ordered_by_user_id char(36) NULL, order_number varchar(120) NULL, + actual_delivery_date date NULL, delivery_status varchar(30) NOT NULL DEFAULT 'not_ordered', + delivered_quantity int unsigned NOT NULL DEFAULT 0, assembly_date date NULL, assembled_by varchar(160) NULL, + version int NOT NULL DEFAULT 1, created_by_user_id char(36) NOT NULL, + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), deleted_at datetime(3) NULL, + PRIMARY KEY(id), + KEY idx_furniture_options_project_requirement(project_id,requirement_id), + CONSTRAINT fk_furniture_option_project FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE, + CONSTRAINT fk_furniture_option_requirement FOREIGN KEY(requirement_id) REFERENCES furniture_requirements(id) ON DELETE CASCADE, + CONSTRAINT fk_furniture_option_budget FOREIGN KEY(budget_category_id) REFERENCES budget_categories(id) ON DELETE SET NULL, + CONSTRAINT fk_furniture_option_ordered_by FOREIGN KEY(ordered_by_user_id) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_furniture_option_creator FOREIGN KEY(created_by_user_id) REFERENCES users(id) ON DELETE RESTRICT + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`); + await q.query(`CREATE TABLE furniture_scenarios ( + id char(36) NOT NULL, project_id char(36) NOT NULL, name varchar(160) NOT NULL, description text NULL, + type varchar(20) NOT NULL, status varchar(20) NOT NULL, is_default tinyint NOT NULL DEFAULT 0, + version int NOT NULL DEFAULT 1, created_by_user_id char(36) NOT NULL, + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), deleted_at datetime(3) NULL, + PRIMARY KEY(id), KEY idx_furniture_scenarios_project_status(project_id,status), + CONSTRAINT fk_furniture_scenario_project FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE, + CONSTRAINT fk_furniture_scenario_creator FOREIGN KEY(created_by_user_id) REFERENCES users(id) ON DELETE RESTRICT + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`); + await q.query(`CREATE TABLE furniture_scenario_selections ( + id char(36) NOT NULL, project_id char(36) NOT NULL, scenario_id char(36) NOT NULL, + requirement_id char(36) NOT NULL, option_id char(36) NOT NULL, quantity int unsigned NOT NULL DEFAULT 1, + price_override decimal(13,2) NULL, note varchar(1000) NULL, + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), UNIQUE KEY uq_furniture_scenario_requirement(scenario_id,requirement_id), + CONSTRAINT fk_furniture_selection_project FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE, + CONSTRAINT fk_furniture_selection_scenario FOREIGN KEY(scenario_id) REFERENCES furniture_scenarios(id) ON DELETE CASCADE, + CONSTRAINT fk_furniture_selection_requirement FOREIGN KEY(requirement_id) REFERENCES furniture_requirements(id) ON DELETE CASCADE, + CONSTRAINT fk_furniture_selection_option FOREIGN KEY(option_id) REFERENCES furniture_options(id) ON DELETE RESTRICT + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`); + await q.query(`CREATE TABLE furniture_option_documents ( + id char(36) NOT NULL, project_id char(36) NOT NULL, option_id char(36) NOT NULL, document_id char(36) NOT NULL, + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY(id), + UNIQUE KEY uq_furniture_option_document(option_id,document_id), + CONSTRAINT fk_furniture_document_project FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE, + CONSTRAINT fk_furniture_document_option FOREIGN KEY(option_id) REFERENCES furniture_options(id) ON DELETE CASCADE, + CONSTRAINT fk_furniture_document_document FOREIGN KEY(document_id) REFERENCES project_documents(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`); + await q.query( + 'ALTER TABLE expenses ADD furniture_requirement_id char(36) NULL, ADD furniture_option_id char(36) NULL', + ); + await q.query( + 'ALTER TABLE expenses ADD KEY idx_expenses_furniture_option(furniture_option_id), ADD CONSTRAINT fk_expense_furniture_requirement FOREIGN KEY(furniture_requirement_id) REFERENCES furniture_requirements(id) ON DELETE SET NULL, ADD CONSTRAINT fk_expense_furniture_option FOREIGN KEY(furniture_option_id) REFERENCES furniture_options(id) ON DELETE SET NULL', + ); + } + async down(q: QueryRunner): Promise { + await q.query( + 'ALTER TABLE expenses DROP FOREIGN KEY fk_expense_furniture_option, DROP FOREIGN KEY fk_expense_furniture_requirement, DROP INDEX idx_expenses_furniture_option, DROP COLUMN furniture_option_id, DROP COLUMN furniture_requirement_id', + ); + await q.query('DROP TABLE furniture_option_documents'); + await q.query('DROP TABLE furniture_scenario_selections'); + await q.query('DROP TABLE furniture_scenarios'); + await q.query('DROP TABLE furniture_options'); + await q.query('DROP TABLE furniture_requirements'); + } +} diff --git a/apps/backend/src/database/run-development-seed.ts b/apps/backend/src/database/run-development-seed.ts new file mode 100644 index 0000000..b76b2d5 --- /dev/null +++ b/apps/backend/src/database/run-development-seed.ts @@ -0,0 +1,21 @@ +import 'reflect-metadata'; +import { NestFactory } from '@nestjs/core'; +import { AppModule } from '../app.module'; +import { DevelopmentSeedService } from '../renovation/development-seed.service'; + +async function main(): Promise { + const application = await NestFactory.createApplicationContext(AppModule, { + logger: ['error', 'warn'], + }); + try { + const seed = application.get(DevelopmentSeedService); + const result = process.argv.includes('--reset') + ? { reset: await seed.reset() } + : await seed.run(); + process.stdout.write(`${JSON.stringify(result)}\n`); + } finally { + await application.close(); + } +} + +void main(); diff --git a/apps/backend/src/database/typeorm-cli.datasource.ts b/apps/backend/src/database/typeorm-cli.datasource.ts index c6dd351..1afbff5 100644 --- a/apps/backend/src/database/typeorm-cli.datasource.ts +++ b/apps/backend/src/database/typeorm-cli.datasource.ts @@ -5,6 +5,11 @@ import { entities } from './entities'; import { InitialSchema1720000000000 } from './migrations/1720000000000-InitialSchema'; import { AddNotifications1720000001000 } from './migrations/1720000001000-AddNotifications'; import { AddRoleDescription1720000002000 } from './migrations/1720000002000-AddRoleDescription'; +import { AddHausPilotProjects1720000003000 } from './migrations/1720000003000-AddHausPilotProjects'; +import { AddRenovationDomain1720000004000 } from './migrations/1720000004000-AddRenovationDomain'; +import { AddMentionsAndReminders1720000005000 } from './migrations/1720000005000-AddMentionsAndReminders'; +import { AddDefaultProjectFloors1720000006000 } from './migrations/1720000006000-AddDefaultProjectFloors'; +import { AddFurniturePlanning1720000007000 } from './migrations/1720000007000-AddFurniturePlanning'; const config = loadConfigForCli(); @@ -25,5 +30,10 @@ export default new DataSource({ InitialSchema1720000000000, AddNotifications1720000001000, AddRoleDescription1720000002000, + AddHausPilotProjects1720000003000, + AddRenovationDomain1720000004000, + AddMentionsAndReminders1720000005000, + AddDefaultProjectFloors1720000006000, + AddFurniturePlanning1720000007000, ], }); diff --git a/apps/backend/src/database/typeorm-options.ts b/apps/backend/src/database/typeorm-options.ts index ecfb86c..6e0ebc0 100644 --- a/apps/backend/src/database/typeorm-options.ts +++ b/apps/backend/src/database/typeorm-options.ts @@ -4,6 +4,11 @@ import { entities } from './entities'; import { InitialSchema1720000000000 } from './migrations/1720000000000-InitialSchema'; import { AddNotifications1720000001000 } from './migrations/1720000001000-AddNotifications'; import { AddRoleDescription1720000002000 } from './migrations/1720000002000-AddRoleDescription'; +import { AddHausPilotProjects1720000003000 } from './migrations/1720000003000-AddHausPilotProjects'; +import { AddRenovationDomain1720000004000 } from './migrations/1720000004000-AddRenovationDomain'; +import { AddMentionsAndReminders1720000005000 } from './migrations/1720000005000-AddMentionsAndReminders'; +import { AddDefaultProjectFloors1720000006000 } from './migrations/1720000006000-AddDefaultProjectFloors'; +import { AddFurniturePlanning1720000007000 } from './migrations/1720000007000-AddFurniturePlanning'; export function typeOrmOptionsFactory( config: AppConfigService, @@ -25,6 +30,11 @@ export function typeOrmOptionsFactory( InitialSchema1720000000000, AddNotifications1720000001000, AddRoleDescription1720000002000, + AddHausPilotProjects1720000003000, + AddRenovationDomain1720000004000, + AddMentionsAndReminders1720000005000, + AddDefaultProjectFloors1720000006000, + AddFurniturePlanning1720000007000, ], }; } diff --git a/apps/backend/src/notifications/notification-types.ts b/apps/backend/src/notifications/notification-types.ts index abb7073..e4a2fe9 100644 --- a/apps/backend/src/notifications/notification-types.ts +++ b/apps/backend/src/notifications/notification-types.ts @@ -3,6 +3,20 @@ export const NotificationType = { ItemCreated: 'item.created', ItemUpdated: 'item.updated', UserRoleChanged: 'user.role-changed', + ProjectInvitation: 'project.invitation', + TaskAssigned: 'task.assigned', + TaskDueSoon: 'task.due-soon', + TaskOverdue: 'task.overdue', + TaskMention: 'task.mention', + MilestoneAtRisk: 'milestone.at-risk', + BudgetExceeded: 'budget.exceeded', + ExpenseOverdue: 'expense.overdue', + InvitationExpiring: 'project.invitation-expiring', + FurnitureRequirementAssigned: 'furniture.requirement-assigned', + FurnitureStatusChanged: 'furniture.status-changed', + FurnitureDeliveryDue: 'furniture.delivery-due', + FurnitureDeliveryDelayed: 'furniture.delivery-delayed', + FurnitureBudgetExceeded: 'furniture.budget-exceeded', } as const; export type NotificationType = diff --git a/apps/backend/src/projects/dto/project.dto.ts b/apps/backend/src/projects/dto/project.dto.ts new file mode 100644 index 0000000..18aecc6 --- /dev/null +++ b/apps/backend/src/projects/dto/project.dto.ts @@ -0,0 +1,53 @@ +import { + IsEmail, + IsEnum, + IsInt, + IsOptional, + IsString, + IsUUID, + Length, + Max, + Min, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { ProjectRole } from '../entities/project-membership.entity'; + +export class CreateProjectDto { + @IsString() + @Length(1, 160) + name!: string; + + @IsOptional() + @IsString() + @Length(0, 4000) + description?: string; + + @IsOptional() + @IsString() + @Length(1, 30) + status?: string; +} + +export class CreateProjectInvitationDto { + @IsEmail() + @Length(3, 320) + email!: string; + + @IsEnum(ProjectRole) + role!: ProjectRole; +} + +export class UpdateProjectMemberDto { + @IsEnum(ProjectRole) + role!: ProjectRole; +} + +export class ProjectIdDto { + @IsUUID() + projectId!: string; +} + +export class ProjectActivityQueryDto { + @IsOptional() @Type(() => Number) @IsInt() @Min(1) page = 1; + @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) pageSize = 25; +} diff --git a/apps/backend/src/projects/entities/project-activity.entity.ts b/apps/backend/src/projects/entities/project-activity.entity.ts new file mode 100644 index 0000000..b4e4028 --- /dev/null +++ b/apps/backend/src/projects/entities/project-activity.entity.ts @@ -0,0 +1,43 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { UserEntity } from '../../users/entities/user.entity'; +import { ProjectEntity } from './project.entity'; + +@Entity('project_activities') +export class ProjectActivityEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @ManyToOne(() => ProjectEntity, (project) => project.activities, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'project_id' }) + project!: ProjectEntity; + + @Index('idx_project_activities_project_created') + @Column({ name: 'project_id', type: 'char', length: 36 }) + projectId!: string; + + @ManyToOne(() => UserEntity, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'actor_user_id' }) + actorUser!: UserEntity | null; + + @Column({ name: 'actor_user_id', type: 'char', length: 36, nullable: true }) + actorUserId!: string | null; + + @Column({ type: 'varchar', length: 80 }) + action!: string; + + @Column({ type: 'json', nullable: true }) + metadata!: Record | null; + + @CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 3 }) + createdAt!: Date; +} diff --git a/apps/backend/src/projects/entities/project-invitation.entity.ts b/apps/backend/src/projects/entities/project-invitation.entity.ts new file mode 100644 index 0000000..35d9887 --- /dev/null +++ b/apps/backend/src/projects/entities/project-invitation.entity.ts @@ -0,0 +1,101 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + Unique, + UpdateDateColumn, +} from 'typeorm'; +import { UserEntity } from '../../users/entities/user.entity'; +import { ProjectEntity } from './project.entity'; +import { ProjectRole } from './project-membership.entity'; + +export enum InvitationStatus { + Pending = 'pending', + Accepted = 'accepted', + Declined = 'declined', + Revoked = 'revoked', +} + +export enum InvitationMailStatus { + NotConfigured = 'not_configured', +} + +@Entity('project_invitations') +@Unique('uq_project_invitations_token_hash', ['tokenHash']) +export class ProjectInvitationEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @ManyToOne(() => ProjectEntity, (project) => project.invitations, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'project_id' }) + project!: ProjectEntity; + + @Index('idx_project_invitations_project') + @Column({ name: 'project_id', type: 'char', length: 36 }) + projectId!: string; + + @Index('idx_project_invitations_email') + @Column({ name: 'invited_email', type: 'varchar', length: 320 }) + invitedEmail!: string; + + @ManyToOne(() => UserEntity, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'invited_user_id' }) + invitedUser!: UserEntity | null; + + @Column({ name: 'invited_user_id', type: 'char', length: 36, nullable: true }) + invitedUserId!: string | null; + + @ManyToOne(() => UserEntity, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'invited_by_user_id' }) + invitedByUser!: UserEntity; + + @Column({ name: 'invited_by_user_id', type: 'char', length: 36 }) + invitedByUserId!: string; + + @Column({ type: 'enum', enum: ProjectRole }) + role!: ProjectRole; + + @Column({ name: 'token_hash', type: 'char', length: 64 }) + tokenHash!: string; + + @Column({ type: 'enum', enum: InvitationStatus }) + status!: InvitationStatus; + + @Column({ name: 'mail_status', type: 'enum', enum: InvitationMailStatus }) + mailStatus!: InvitationMailStatus; + + @Column({ name: 'expires_at', type: 'datetime', precision: 3 }) + expiresAt!: Date; + + @ManyToOne(() => UserEntity, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'accepted_by_user_id' }) + acceptedByUser!: UserEntity | null; + + @Column({ + name: 'accepted_by_user_id', + type: 'char', + length: 36, + nullable: true, + }) + acceptedByUserId!: string | null; + + @Column({ + name: 'responded_at', + type: 'datetime', + precision: 3, + nullable: true, + }) + respondedAt!: Date | null; + + @CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 3 }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'datetime', precision: 3 }) + updatedAt!: Date; +} diff --git a/apps/backend/src/projects/entities/project-membership.entity.ts b/apps/backend/src/projects/entities/project-membership.entity.ts new file mode 100644 index 0000000..8d44d0f --- /dev/null +++ b/apps/backend/src/projects/entities/project-membership.entity.ts @@ -0,0 +1,57 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + Unique, + UpdateDateColumn, +} from 'typeorm'; +import { UserEntity } from '../../users/entities/user.entity'; +import { ProjectEntity } from './project.entity'; + +export enum ProjectRole { + Owner = 'owner', + Administrator = 'administrator', + Editor = 'editor', + Reader = 'reader', +} + +@Entity('project_memberships') +@Unique('uq_project_memberships_project_user', ['projectId', 'userId']) +export class ProjectMembershipEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @ManyToOne(() => ProjectEntity, (project) => project.memberships, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'project_id' }) + project!: ProjectEntity; + + @Index('idx_project_memberships_project') + @Column({ name: 'project_id', type: 'char', length: 36 }) + projectId!: string; + + @ManyToOne(() => UserEntity, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'user_id' }) + user!: UserEntity; + + @Index('idx_project_memberships_user') + @Column({ name: 'user_id', type: 'char', length: 36 }) + userId!: string; + + @Column({ type: 'enum', enum: ProjectRole }) + role!: ProjectRole; + + @Column({ type: 'boolean', default: true }) + active!: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 3 }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'datetime', precision: 3 }) + updatedAt!: Date; +} diff --git a/apps/backend/src/projects/entities/project.entity.ts b/apps/backend/src/projects/entities/project.entity.ts new file mode 100644 index 0000000..5c01898 --- /dev/null +++ b/apps/backend/src/projects/entities/project.entity.ts @@ -0,0 +1,53 @@ +import { + Column, + CreateDateColumn, + Entity, + OneToMany, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { ProjectActivityEntity } from './project-activity.entity'; +import { ProjectInvitationEntity } from './project-invitation.entity'; +import { ProjectMembershipEntity } from './project-membership.entity'; + +@Entity('projects') +export class ProjectEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ type: 'varchar', length: 160 }) + name!: string; + + @Column({ type: 'text', nullable: true }) + description!: string | null; + + @Column({ type: 'varchar', length: 30, default: 'planning' }) + status!: string; + + @Column({ + name: 'total_budget', + type: 'decimal', + precision: 13, + scale: 2, + nullable: true, + }) + totalBudget!: string | null; + + @Column({ type: 'char', length: 3, default: 'EUR' }) + currency!: string; + + @OneToMany(() => ProjectMembershipEntity, (membership) => membership.project) + memberships!: ProjectMembershipEntity[]; + + @OneToMany(() => ProjectInvitationEntity, (invitation) => invitation.project) + invitations!: ProjectInvitationEntity[]; + + @OneToMany(() => ProjectActivityEntity, (activity) => activity.project) + activities!: ProjectActivityEntity[]; + + @CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 3 }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'datetime', precision: 3 }) + updatedAt!: Date; +} diff --git a/apps/backend/src/projects/project-access.service.ts b/apps/backend/src/projects/project-access.service.ts new file mode 100644 index 0000000..ef95058 --- /dev/null +++ b/apps/backend/src/projects/project-access.service.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@nestjs/common'; +import { ApiError } from '../common/errors/api-error'; +import { ErrorCode } from '../common/errors/error-codes'; +import { ProjectRole } from './entities/project-membership.entity'; +import { ProjectsRepository } from './repositories/projects.repository'; + +export type ProjectAction = 'read' | 'edit' | 'manageMembers'; + +const allowedRoles: Record = { + read: Object.values(ProjectRole), + edit: [ProjectRole.Owner, ProjectRole.Administrator, ProjectRole.Editor], + manageMembers: [ProjectRole.Owner, ProjectRole.Administrator], +}; + +@Injectable() +export class ProjectAccessService { + constructor(private readonly projects: ProjectsRepository) {} + + async require(projectId: string, userId: string, action: ProjectAction) { + const membership = await this.projects.findMembership(projectId, userId); + if ( + !membership?.active || + !membership.user.active || + !allowedRoles[action].includes(membership.role) + ) { + throw new ApiError( + ErrorCode.ProjectAccessDenied, + 'Das Projekt wurde nicht gefunden oder der Zugriff ist nicht erlaubt.', + 404, + ); + } + return membership; + } +} diff --git a/apps/backend/src/projects/projects.controller.ts b/apps/backend/src/projects/projects.controller.ts new file mode 100644 index 0000000..43e4863 --- /dev/null +++ b/apps/backend/src/projects/projects.controller.ts @@ -0,0 +1,178 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + Req, +} from '@nestjs/common'; +import type { + AuthenticatedRequest, + AuthenticatedUser, +} from '../auth/authenticated-request'; +import { RequirePermissions } from '../auth/guards/require-permissions.decorator'; +import { ApiError } from '../common/errors/api-error'; +import { ErrorCode } from '../common/errors/error-codes'; +import { Permission } from '../roles/permissions'; +import { + CreateProjectDto, + CreateProjectInvitationDto, + ProjectActivityQueryDto, + UpdateProjectMemberDto, +} from './dto/project.dto'; +import { ProjectsService } from './projects.service'; + +@Controller() +@RequirePermissions(Permission.ProjectsUse) +export class ProjectsController { + constructor(private readonly projects: ProjectsService) {} + + @Get('projects') + list(@Req() request: AuthenticatedRequest) { + return this.projects.list(this.user(request).id); + } + + @Post('projects') + create(@Req() request: AuthenticatedRequest, @Body() dto: CreateProjectDto) { + return this.projects.create(this.user(request).id, dto); + } + + @Get('projects/:projectId') + get( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + ) { + return this.projects.get(projectId, this.user(request).id); + } + + @Get('projects/:projectId/members') + listMembers( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + ) { + return this.projects.listMembers(projectId, this.user(request).id); + } + + @Get('projects/:projectId/activities') + activities( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Query() query: ProjectActivityQueryDto, + ) { + return this.projects.pageActivities( + projectId, + this.user(request).id, + query.page, + query.pageSize, + ); + } + + @Patch('projects/:projectId/members/:userId') + updateMember( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Param('userId') userId: string, + @Body() dto: UpdateProjectMemberDto, + ) { + return this.projects.updateMember( + projectId, + userId, + this.user(request).id, + dto, + ); + } + + @Delete('projects/:projectId/members/:userId') + removeMember( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Param('userId') userId: string, + ) { + return this.projects.removeMember(projectId, userId, this.user(request).id); + } + + @Post('projects/:projectId/invitations') + invite( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Body() dto: CreateProjectInvitationDto, + ) { + return this.projects.createInvitation( + projectId, + this.user(request).id, + dto, + ); + } + + @Delete('projects/:projectId/invitations/:invitationId') + revokeInvitation( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Param('invitationId') invitationId: string, + ) { + return this.projects.revokeInvitation( + projectId, + invitationId, + this.user(request).id, + ); + } + + @Get('invitations') + invitations(@Req() request: AuthenticatedRequest) { + return this.projects.listPendingInvitations(this.user(request).id); + } + + @Get('invitations/:token') + invitation( + @Req() request: AuthenticatedRequest, + @Param('token') token: string, + ) { + return this.projects.getInvitation(token, this.user(request).id); + } + + @Post('invitations/:token/accept') + accept(@Req() request: AuthenticatedRequest, @Param('token') token: string) { + return this.projects.acceptInvitation(token, this.user(request).id); + } + + @Post('invitations/:token/decline') + decline(@Req() request: AuthenticatedRequest, @Param('token') token: string) { + return this.projects.declineInvitation(token, this.user(request).id); + } + + @Post('invitations/by-id/:invitationId/accept') + acceptById( + @Req() request: AuthenticatedRequest, + @Param('invitationId') invitationId: string, + ) { + return this.projects.acceptInvitationById( + invitationId, + this.user(request).id, + ); + } + + @Post('invitations/by-id/:invitationId/decline') + declineById( + @Req() request: AuthenticatedRequest, + @Param('invitationId') invitationId: string, + ) { + return this.projects.declineInvitationById( + invitationId, + this.user(request).id, + ); + } + + private user(request: AuthenticatedRequest): AuthenticatedUser { + if (!request.user) { + throw new ApiError( + ErrorCode.Unauthorized, + 'Bitte melden Sie sich an.', + 401, + ); + } + return request.user; + } +} diff --git a/apps/backend/src/projects/projects.module.ts b/apps/backend/src/projects/projects.module.ts new file mode 100644 index 0000000..1721c96 --- /dev/null +++ b/apps/backend/src/projects/projects.module.ts @@ -0,0 +1,34 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { UserEntity } from '../users/entities/user.entity'; +import { UsersRepository } from '../users/repositories/users.repository'; +import { ProjectActivityEntity } from './entities/project-activity.entity'; +import { ProjectInvitationEntity } from './entities/project-invitation.entity'; +import { ProjectMembershipEntity } from './entities/project-membership.entity'; +import { ProjectEntity } from './entities/project.entity'; +import { ProjectAccessService } from './project-access.service'; +import { ProjectsController } from './projects.controller'; +import { ProjectsService } from './projects.service'; +import { ProjectsRepository } from './repositories/projects.repository'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + ProjectEntity, + ProjectMembershipEntity, + ProjectInvitationEntity, + ProjectActivityEntity, + UserEntity, + ]), + NotificationsModule, + ], + controllers: [ProjectsController], + providers: [ + ProjectsService, + ProjectAccessService, + ProjectsRepository, + UsersRepository, + ], +}) +export class ProjectsModule {} diff --git a/apps/backend/src/projects/projects.service.ts b/apps/backend/src/projects/projects.service.ts new file mode 100644 index 0000000..eb37e2a --- /dev/null +++ b/apps/backend/src/projects/projects.service.ts @@ -0,0 +1,732 @@ +import { createHash, randomBytes } from 'node:crypto'; +import { Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource, EntityManager } from 'typeorm'; +import { ApiError } from '../common/errors/api-error'; +import { ErrorCode } from '../common/errors/error-codes'; +import { NotificationType } from '../notifications/notification-types'; +import { NotificationsService } from '../notifications/notifications.service'; +import { UserEntity } from '../users/entities/user.entity'; +import { UsersRepository } from '../users/repositories/users.repository'; +import type { + CreateProjectDto, + CreateProjectInvitationDto, + UpdateProjectMemberDto, +} from './dto/project.dto'; +import { ProjectActivityEntity } from './entities/project-activity.entity'; +import { + InvitationMailStatus, + InvitationStatus, + ProjectInvitationEntity, +} from './entities/project-invitation.entity'; +import { + ProjectMembershipEntity, + ProjectRole, +} from './entities/project-membership.entity'; +import { ProjectEntity } from './entities/project.entity'; +import { ProjectAccessService } from './project-access.service'; +import { ProjectsRepository } from './repositories/projects.repository'; +import { + BuildingEntity, + BuildingType, + FloorEntity, + RenovationTaskEntity, + TaskStatus, +} from '../renovation/entities/renovation.entities'; + +const invitationLifetimeMs = 7 * 24 * 60 * 60 * 1000; + +@Injectable() +export class ProjectsService { + constructor( + private readonly projects: ProjectsRepository, + private readonly access: ProjectAccessService, + private readonly users: UsersRepository, + private readonly notifications: NotificationsService, + @InjectDataSource() private readonly dataSource: DataSource, + ) {} + + async list(userId: string) { + const projects = await this.projects.listForUser(userId); + return Promise.all( + projects.map(async (project) => { + const membership = await this.projects.findMembership( + project.id, + userId, + ); + return this.toProjectDto( + project, + membership?.role ?? ProjectRole.Reader, + ); + }), + ); + } + + async get(projectId: string, userId: string) { + const membership = await this.access.require(projectId, userId, 'read'); + const project = await this.requireProject(projectId); + return this.toProjectDto(project, membership.role); + } + + create(userId: string, dto: CreateProjectDto) { + return this.dataSource.transaction(async (manager) => { + const actor = await this.requireActiveUser(userId, manager); + const project = new ProjectEntity(); + project.name = dto.name.trim(); + project.description = dto.description?.trim() || null; + project.status = dto.status?.trim() || 'planning'; + project.totalBudget = null; + project.currency = 'EUR'; + const savedProject = await this.projects.saveProject(project, manager); + + const membership = new ProjectMembershipEntity(); + membership.projectId = savedProject.id; + membership.project = savedProject; + membership.userId = actor.id; + membership.user = actor; + membership.role = ProjectRole.Owner; + membership.active = true; + await this.projects.saveMembership(membership, manager); + + const buildingRepository = manager.getRepository(BuildingEntity); + const building = await buildingRepository.save( + buildingRepository.create({ + projectId: savedProject.id, + name: 'Haus', + description: null, + type: BuildingType.TerracedHouse, + sortOrder: 0, + }), + ); + await manager.getRepository(FloorEntity).save( + ['Keller', 'Erdgeschoss', '1. Stock', 'Dachboden'].map( + (name, sortOrder) => + manager.getRepository(FloorEntity).create({ + projectId: savedProject.id, + buildingId: building.id, + name, + description: null, + sortOrder, + }), + ), + ); + + await this.recordActivity( + savedProject.id, + actor.id, + 'project.created', + { projectName: savedProject.name }, + manager, + ); + return this.toProjectDto(savedProject, ProjectRole.Owner); + }); + } + + async listMembers(projectId: string, userId: string) { + await this.access.require(projectId, userId, 'read'); + const memberships = await this.projects.listMembers(projectId); + return memberships.map((membership) => this.toMemberDto(membership)); + } + + async listActivities(projectId: string, userId: string) { + await this.access.require(projectId, userId, 'read'); + const activities = await this.projects.listActivities(projectId); + return activities.map((activity) => ({ + id: activity.id, + action: activity.action, + actorName: activity.actorUser?.name ?? 'Ehemaliges Projektmitglied', + metadata: activity.metadata, + createdAt: activity.createdAt.toISOString(), + })); + } + + async pageActivities( + projectId: string, + userId: string, + page: number, + pageSize: number, + ) { + await this.access.require(projectId, userId, 'read'); + const result = await this.projects.pageActivities( + projectId, + page, + pageSize, + ); + return { + ...result, + items: result.items.map((activity) => ({ + id: activity.id, + action: activity.action, + actorName: activity.actorUser?.name ?? 'Ehemaliges Projektmitglied', + metadata: activity.metadata, + createdAt: activity.createdAt.toISOString(), + })), + }; + } + + async updateMember( + projectId: string, + memberUserId: string, + actorUserId: string, + dto: UpdateProjectMemberDto, + ) { + await this.access.require(projectId, actorUserId, 'manageMembers'); + if (dto.role === ProjectRole.Owner) { + throw new ApiError( + ErrorCode.ProjectOwnerProtected, + 'Die Eigentuemerrolle kann nur durch eine sichere Eigentumsuebertragung geaendert werden.', + 409, + ); + } + const membership = await this.projects.findMembership( + projectId, + memberUserId, + ); + if (!membership?.active || !membership.user.active) { + throw new ApiError( + ErrorCode.ProjectMemberNotFound, + 'Das Projektmitglied wurde nicht gefunden.', + 404, + ); + } + if (membership.role === ProjectRole.Owner) { + throw new ApiError( + ErrorCode.ProjectOwnerProtected, + 'Der Projekteigentuemer kann nicht geaendert werden.', + 409, + ); + } + membership.role = dto.role; + const saved = await this.projects.saveMembership(membership); + await this.recordActivity(projectId, actorUserId, 'member.role-changed', { + memberUserId, + role: dto.role, + }); + return this.toMemberDto(saved); + } + + async removeMember( + projectId: string, + memberUserId: string, + actorUserId: string, + ): Promise { + await this.access.require(projectId, actorUserId, 'manageMembers'); + await this.dataSource.transaction(async (manager) => { + const membership = await this.projects.findMembership( + projectId, + memberUserId, + manager, + ); + if (!membership?.active) { + throw new ApiError( + ErrorCode.ProjectMemberNotFound, + 'Das Projektmitglied wurde nicht gefunden.', + 404, + ); + } + if (membership.role === ProjectRole.Owner) { + throw new ApiError( + ErrorCode.ProjectOwnerProtected, + 'Der Projekteigentuemer kann nicht entfernt werden.', + 409, + ); + } + membership.active = false; + await this.projects.saveMembership(membership, manager); + const reassigned = await manager + .getRepository(RenovationTaskEntity) + .createQueryBuilder() + .update() + .set({ assigneeUserId: null, version: () => 'version + 1' }) + .where( + 'project_id = :projectId AND assignee_user_id = :memberUserId AND status NOT IN (:...closed)', + { + projectId, + memberUserId, + closed: [TaskStatus.Done, TaskStatus.Omitted], + }, + ) + .execute(); + await this.recordActivity( + projectId, + actorUserId, + 'member.removed', + { + memberUserId, + reassignedOpenTasks: String(reassigned.affected ?? 0), + }, + manager, + ); + }); + } + + async createInvitation( + projectId: string, + actorUserId: string, + dto: CreateProjectInvitationDto, + ) { + await this.access.require(projectId, actorUserId, 'manageMembers'); + if (dto.role === ProjectRole.Owner) { + throw new ApiError( + ErrorCode.ProjectOwnerProtected, + 'Einladungen duerfen keine Eigentuemerrolle vergeben.', + 400, + ); + } + const normalizedEmail = this.normalizeEmail(dto.email); + const existingInvitation = await this.projects.findPendingInvitation( + projectId, + normalizedEmail, + ); + if (existingInvitation && existingInvitation.expiresAt > new Date()) { + throw new ApiError( + ErrorCode.ProjectInvitationAlreadyExists, + 'Fuer diese Adresse besteht bereits eine offene Einladung.', + 409, + ); + } + + const knownUser = + await this.users.findActiveByNormalizedEmail(normalizedEmail); + if (knownUser) { + const membership = await this.projects.findMembership( + projectId, + knownUser.id, + ); + if (membership?.active) { + throw new ApiError( + ErrorCode.ProjectMemberAlreadyExists, + 'Der Benutzer ist bereits Projektmitglied.', + 409, + ); + } + } + + const token = randomBytes(32).toString('base64url'); + const invitation = await this.dataSource.transaction(async (manager) => { + const entity = new ProjectInvitationEntity(); + entity.projectId = projectId; + entity.invitedEmail = normalizedEmail; + entity.invitedUserId = knownUser?.id ?? null; + entity.invitedUser = knownUser; + entity.invitedByUserId = actorUserId; + entity.role = dto.role; + entity.tokenHash = this.hashToken(token); + entity.status = InvitationStatus.Pending; + entity.mailStatus = InvitationMailStatus.NotConfigured; + entity.expiresAt = new Date(Date.now() + invitationLifetimeMs); + entity.acceptedByUserId = null; + entity.acceptedByUser = null; + entity.respondedAt = null; + const saved = await this.projects.saveInvitation(entity, manager); + await this.recordActivity( + projectId, + actorUserId, + 'invitation.created', + { + invitationId: saved.id, + role: saved.role, + }, + manager, + ); + if (knownUser) { + await this.notifications.createForUser( + { + userId: knownUser.id, + type: NotificationType.ProjectInvitation, + title: 'Projekteinladung', + message: 'Sie wurden zu einem HausPilot-Projekt eingeladen.', + link: '/einladungen', + metadata: { invitationId: saved.id, projectId }, + }, + manager, + ); + } + return saved; + }); + + return { + ...this.toInvitationDto(invitation, null, false), + invitationPath: `/einladungen/${token}`, + }; + } + + async listPendingInvitations(userId: string) { + const user = await this.requireActiveUser(userId); + if (!user.email) { + return []; + } + const invitations = await this.projects.listPendingInvitations( + user.id, + this.normalizeEmail(user.email), + ); + return invitations.map((invitation) => + this.toInvitationDto( + invitation, + invitation.project.name, + user.emailVerified === true, + ), + ); + } + + async getInvitation(token: string, userId: string) { + const user = await this.requireActiveUser(userId); + const invitation = await this.requireInvitation(token); + const matches = this.invitationMatchesUser(invitation, user); + if (!matches) { + return this.toInvitationDto(invitation, null, false, 'email_mismatch'); + } + if (user.emailVerified !== true) { + return this.toInvitationDto( + invitation, + invitation.project.name, + false, + 'email_unverified', + ); + } + return this.toInvitationDto(invitation, invitation.project.name, true); + } + + acceptInvitation(token: string, userId: string) { + return this.dataSource.transaction(async (manager) => { + const user = await this.requireActiveUser(userId, manager); + const invitation = await this.requireInvitation(token, manager, true); + this.assertInvitationCanBeAnswered(invitation, user); + const membership = await this.projects.findMembership( + invitation.projectId, + user.id, + manager, + ); + if (membership?.active) { + throw new ApiError( + ErrorCode.ProjectMemberAlreadyExists, + 'Sie sind bereits Projektmitglied.', + 409, + ); + } + const target = membership ?? new ProjectMembershipEntity(); + target.projectId = invitation.projectId; + target.project = invitation.project; + target.userId = user.id; + target.user = user; + target.role = invitation.role; + target.active = true; + await this.projects.saveMembership(target, manager); + invitation.status = InvitationStatus.Accepted; + invitation.acceptedByUserId = user.id; + invitation.acceptedByUser = user; + invitation.respondedAt = new Date(); + await this.projects.saveInvitation(invitation, manager); + await this.recordActivity( + invitation.projectId, + user.id, + 'invitation.accepted', + { + invitationId: invitation.id, + }, + manager, + ); + return this.toProjectDto(invitation.project, invitation.role); + }); + } + + acceptInvitationById(invitationId: string, userId: string) { + return this.dataSource.transaction(async (manager) => { + const user = await this.requireActiveUser(userId, manager); + const invitation = await this.projects.findInvitationById( + invitationId, + manager, + true, + ); + if (!invitation) { + throw new ApiError( + ErrorCode.ProjectInvitationNotFound, + 'Die Einladung wurde nicht gefunden.', + 404, + ); + } + this.assertInvitationCanBeAnswered(invitation, user); + const membership = await this.projects.findMembership( + invitation.projectId, + user.id, + manager, + ); + if (membership?.active) { + throw new ApiError( + ErrorCode.ProjectMemberAlreadyExists, + 'Sie sind bereits Projektmitglied.', + 409, + ); + } + const target = membership ?? new ProjectMembershipEntity(); + target.projectId = invitation.projectId; + target.project = invitation.project; + target.userId = user.id; + target.user = user; + target.role = invitation.role; + target.active = true; + await this.projects.saveMembership(target, manager); + invitation.status = InvitationStatus.Accepted; + invitation.acceptedByUserId = user.id; + invitation.acceptedByUser = user; + invitation.respondedAt = new Date(); + await this.projects.saveInvitation(invitation, manager); + await this.recordActivity( + invitation.projectId, + user.id, + 'invitation.accepted', + { + invitationId: invitation.id, + }, + manager, + ); + return this.toProjectDto(invitation.project, invitation.role); + }); + } + + declineInvitation(token: string, userId: string) { + return this.dataSource.transaction(async (manager) => { + const user = await this.requireActiveUser(userId, manager); + const invitation = await this.requireInvitation(token, manager, true); + this.assertInvitationCanBeAnswered(invitation, user); + invitation.status = InvitationStatus.Declined; + invitation.respondedAt = new Date(); + await this.projects.saveInvitation(invitation, manager); + await this.recordActivity( + invitation.projectId, + user.id, + 'invitation.declined', + { + invitationId: invitation.id, + }, + manager, + ); + }); + } + + declineInvitationById(invitationId: string, userId: string) { + return this.dataSource.transaction(async (manager) => { + const user = await this.requireActiveUser(userId, manager); + const invitation = await this.projects.findInvitationById( + invitationId, + manager, + true, + ); + if (!invitation) { + throw new ApiError( + ErrorCode.ProjectInvitationNotFound, + 'Die Einladung wurde nicht gefunden.', + 404, + ); + } + this.assertInvitationCanBeAnswered(invitation, user); + invitation.status = InvitationStatus.Declined; + invitation.respondedAt = new Date(); + await this.projects.saveInvitation(invitation, manager); + await this.recordActivity( + invitation.projectId, + user.id, + 'invitation.declined', + { + invitationId: invitation.id, + }, + manager, + ); + }); + } + + async revokeInvitation( + projectId: string, + invitationId: string, + actorUserId: string, + ): Promise { + await this.access.require(projectId, actorUserId, 'manageMembers'); + const invitation = await this.projects.findInvitationById(invitationId); + if ( + !invitation || + invitation.projectId !== projectId || + invitation.status !== InvitationStatus.Pending + ) { + throw new ApiError( + ErrorCode.ProjectInvitationNotFound, + 'Die Einladung wurde nicht gefunden.', + 404, + ); + } + invitation.status = InvitationStatus.Revoked; + invitation.respondedAt = new Date(); + await this.projects.saveInvitation(invitation); + await this.recordActivity(projectId, actorUserId, 'invitation.revoked', { + invitationId, + }); + } + + private async requireProject(projectId: string): Promise { + const project = await this.projects.findProject(projectId); + if (!project) { + throw new ApiError( + ErrorCode.ProjectNotFound, + 'Das Projekt wurde nicht gefunden.', + 404, + ); + } + return project; + } + + private async requireActiveUser( + userId: string, + manager?: EntityManager, + ): Promise { + const user = await this.users.findById(userId, manager); + if (!user?.active) { + throw new ApiError( + ErrorCode.UserDisabled, + 'Dieser Benutzer ist deaktiviert.', + 403, + ); + } + return user; + } + + private async requireInvitation( + token: string, + manager?: EntityManager, + lock = false, + ): Promise { + if (!/^[A-Za-z0-9_-]{43}$/.test(token)) { + throw new ApiError( + ErrorCode.ProjectInvitationNotFound, + 'Die Einladung ist ungueltig oder abgelaufen.', + 404, + ); + } + const invitation = await this.projects.findInvitationByTokenHash( + this.hashToken(token), + manager, + lock, + ); + if (!invitation) { + throw new ApiError( + ErrorCode.ProjectInvitationNotFound, + 'Die Einladung ist ungueltig oder abgelaufen.', + 404, + ); + } + return invitation; + } + + private assertInvitationCanBeAnswered( + invitation: ProjectInvitationEntity, + user: UserEntity, + ): void { + if ( + invitation.status !== InvitationStatus.Pending || + invitation.expiresAt <= new Date() + ) { + throw new ApiError( + ErrorCode.ProjectInvitationExpired, + 'Die Einladung ist ungueltig oder abgelaufen.', + 410, + ); + } + if (!this.invitationMatchesUser(invitation, user)) { + throw new ApiError( + ErrorCode.ProjectInvitationEmailMismatch, + 'Diese Einladung wurde an eine andere E-Mail-Adresse gesendet. Melden Sie sich mit der eingeladenen Adresse an oder bitten Sie um eine neue Einladung.', + 403, + ); + } + if (user.emailVerified !== true) { + throw new ApiError( + ErrorCode.ProjectInvitationEmailUnverified, + 'Die E-Mail-Adresse muss zuerst beim Identity Provider verifiziert werden.', + 403, + ); + } + } + + private invitationMatchesUser( + invitation: ProjectInvitationEntity, + user: UserEntity, + ): boolean { + return ( + Boolean(user.email) && + this.normalizeEmail(user.email ?? '') === invitation.invitedEmail + ); + } + + private normalizeEmail(email: string): string { + return email.trim().toLocaleLowerCase('en-US'); + } + + private hashToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); + } + + private maskEmail(email: string): string { + const [local, domain] = email.split('@'); + return `${local?.slice(0, 1) ?? '*'}***@${domain ?? '***'}`; + } + + private toProjectDto(project: ProjectEntity, role: ProjectRole) { + return { + id: project.id, + name: project.name, + description: project.description, + role, + createdAt: project.createdAt.toISOString(), + updatedAt: project.updatedAt.toISOString(), + }; + } + + private toMemberDto(membership: ProjectMembershipEntity) { + return { + userId: membership.userId, + name: membership.user.name, + email: membership.user.email, + role: membership.role, + active: membership.active && membership.user.active, + joinedAt: membership.createdAt.toISOString(), + }; + } + + private toInvitationDto( + invitation: ProjectInvitationEntity, + projectName: string | null, + canRespond: boolean, + reason: 'email_mismatch' | 'email_unverified' | null = null, + ) { + const effectiveStatus = + invitation.status === InvitationStatus.Pending && + invitation.expiresAt <= new Date() + ? 'expired' + : invitation.status; + return { + id: invitation.id, + projectId: projectName ? invitation.projectId : null, + projectName, + role: invitation.role, + status: effectiveStatus, + invitedEmailMasked: this.maskEmail(invitation.invitedEmail), + expiresAt: invitation.expiresAt.toISOString(), + mailStatus: invitation.mailStatus, + canRespond: canRespond && effectiveStatus === InvitationStatus.Pending, + reason, + }; + } + + private recordActivity( + projectId: string, + actorUserId: string, + action: string, + metadata: Record, + manager?: EntityManager, + ) { + const activity = new ProjectActivityEntity(); + activity.projectId = projectId; + activity.actorUserId = actorUserId; + activity.action = action; + activity.metadata = metadata; + return this.projects.saveActivity(activity, manager); + } +} diff --git a/apps/backend/src/projects/repositories/projects.repository.ts b/apps/backend/src/projects/repositories/projects.repository.ts new file mode 100644 index 0000000..7f723f3 --- /dev/null +++ b/apps/backend/src/projects/repositories/projects.repository.ts @@ -0,0 +1,201 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { EntityManager, Repository } from 'typeorm'; +import { ProjectActivityEntity } from '../entities/project-activity.entity'; +import { + InvitationStatus, + ProjectInvitationEntity, +} from '../entities/project-invitation.entity'; +import { ProjectMembershipEntity } from '../entities/project-membership.entity'; +import { ProjectEntity } from '../entities/project.entity'; + +@Injectable() +export class ProjectsRepository { + constructor( + @InjectRepository(ProjectEntity) + private readonly projects: Repository, + @InjectRepository(ProjectMembershipEntity) + private readonly memberships: Repository, + @InjectRepository(ProjectInvitationEntity) + private readonly invitations: Repository, + @InjectRepository(ProjectActivityEntity) + private readonly activities: Repository, + ) {} + + listForUser(userId: string): Promise { + return this.projects + .createQueryBuilder('project') + .innerJoin( + 'project.memberships', + 'membership', + 'membership.userId = :userId AND membership.active = :active', + { userId, active: true }, + ) + .orderBy('project.updatedAt', 'DESC') + .getMany(); + } + + findProject( + id: string, + manager?: EntityManager, + ): Promise { + return (manager?.getRepository(ProjectEntity) ?? this.projects).findOne({ + where: { id }, + }); + } + + findMembership( + projectId: string, + userId: string, + manager?: EntityManager, + ): Promise { + return ( + manager?.getRepository(ProjectMembershipEntity) ?? this.memberships + ).findOne({ + where: { projectId, userId }, + relations: { user: true }, + }); + } + + listMembers(projectId: string): Promise { + return this.memberships.find({ + where: { projectId, active: true }, + relations: { user: true }, + order: { createdAt: 'ASC' }, + }); + } + + findPendingInvitation( + projectId: string, + email: string, + ): Promise { + return this.invitations.findOne({ + where: { + projectId, + invitedEmail: email, + status: InvitationStatus.Pending, + }, + }); + } + + findInvitationById( + id: string, + manager?: EntityManager, + lock = false, + ): Promise { + const repository = + manager?.getRepository(ProjectInvitationEntity) ?? this.invitations; + const query = repository + .createQueryBuilder('invitation') + .leftJoinAndSelect('invitation.project', 'project') + .where('invitation.id = :id', { id }); + if (lock && manager) { + query.setLock('pessimistic_write'); + } + return query.getOne(); + } + + findInvitationByTokenHash( + tokenHash: string, + manager?: EntityManager, + lock = false, + ): Promise { + const repository = + manager?.getRepository(ProjectInvitationEntity) ?? this.invitations; + const query = repository + .createQueryBuilder('invitation') + .leftJoinAndSelect('invitation.project', 'project') + .where('invitation.tokenHash = :tokenHash', { tokenHash }); + if (lock && manager) { + query.setLock('pessimistic_write'); + } + return query.getOne(); + } + + listPendingInvitations( + userId: string, + email: string, + ): Promise { + return this.invitations + .createQueryBuilder('invitation') + .leftJoinAndSelect('invitation.project', 'project') + .where('invitation.status = :status', { + status: InvitationStatus.Pending, + }) + .andWhere('invitation.expiresAt > :now', { now: new Date() }) + .andWhere( + '(invitation.invitedUserId = :userId OR invitation.invitedEmail = :email)', + { + userId, + email, + }, + ) + .orderBy('invitation.createdAt', 'DESC') + .getMany(); + } + + saveProject( + project: ProjectEntity, + manager?: EntityManager, + ): Promise { + return (manager?.getRepository(ProjectEntity) ?? this.projects).save( + project, + ); + } + + saveMembership( + membership: ProjectMembershipEntity, + manager?: EntityManager, + ): Promise { + return ( + manager?.getRepository(ProjectMembershipEntity) ?? this.memberships + ).save(membership); + } + + saveInvitation( + invitation: ProjectInvitationEntity, + manager?: EntityManager, + ): Promise { + return ( + manager?.getRepository(ProjectInvitationEntity) ?? this.invitations + ).save(invitation); + } + + saveActivity( + activity: ProjectActivityEntity, + manager?: EntityManager, + ): Promise { + return ( + manager?.getRepository(ProjectActivityEntity) ?? this.activities + ).save(activity); + } + + listActivities( + projectId: string, + limit = 50, + ): Promise { + return this.activities.find({ + where: { projectId }, + relations: { actorUser: true }, + order: { createdAt: 'DESC' }, + take: Math.min(limit, 100), + }); + } + + async pageActivities(projectId: string, page: number, pageSize: number) { + const [items, totalItems] = await this.activities.findAndCount({ + where: { projectId }, + relations: { actorUser: true }, + order: { createdAt: 'DESC' }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + return { + items, + page, + pageSize, + totalItems, + totalPages: Math.ceil(totalItems / pageSize), + }; + } +} diff --git a/apps/backend/src/projects/tests/project-access.service.spec.ts b/apps/backend/src/projects/tests/project-access.service.spec.ts new file mode 100644 index 0000000..e4158bd --- /dev/null +++ b/apps/backend/src/projects/tests/project-access.service.spec.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { ErrorCode } from '../../common/errors/error-codes'; +import type { ProjectsRepository } from '../repositories/projects.repository'; +import { ProjectAccessService } from '../project-access.service'; +import { ProjectRole } from '../entities/project-membership.entity'; + +describe('ProjectAccessService', () => { + it('does not grant a global administrator access without project membership', async () => { + const projects = { + findMembership: () => Promise.resolve(null), + } as unknown as ProjectsRepository; + const access = new ProjectAccessService(projects); + + await expect( + access.require('private-project', 'global-admin', 'read'), + ).rejects.toMatchObject({ + code: ErrorCode.ProjectAccessDenied, + status: 404, + }); + }); + + it('keeps access stable when a project member changes the profile email', async () => { + const projects = { + findMembership: () => + Promise.resolve({ + active: true, + role: ProjectRole.Reader, + userId: 'stable-user-id', + user: { active: true, email: 'new-address@example.test' }, + }), + } as unknown as ProjectsRepository; + const access = new ProjectAccessService(projects); + + const membership = await access.require( + 'project-1', + 'stable-user-id', + 'read', + ); + + expect(membership.userId).toBe('stable-user-id'); + }); +}); diff --git a/apps/backend/src/projects/tests/projects.service.spec.ts b/apps/backend/src/projects/tests/projects.service.spec.ts new file mode 100644 index 0000000..8d50483 --- /dev/null +++ b/apps/backend/src/projects/tests/projects.service.spec.ts @@ -0,0 +1,236 @@ +import { createHash } from 'node:crypto'; +import { describe, expect, it, vi } from 'vitest'; +import type { DataSource, EntityManager } from 'typeorm'; +import { ErrorCode } from '../../common/errors/error-codes'; +import type { NotificationsService } from '../../notifications/notifications.service'; +import type { UsersRepository } from '../../users/repositories/users.repository'; +import { UserEntity } from '../../users/entities/user.entity'; +import { + InvitationMailStatus, + InvitationStatus, + ProjectInvitationEntity, +} from '../entities/project-invitation.entity'; +import { ProjectRole } from '../entities/project-membership.entity'; +import type { ProjectAccessService } from '../project-access.service'; +import type { ProjectsRepository } from '../repositories/projects.repository'; +import { ProjectsService } from '../projects.service'; + +function user(overrides: Partial = {}): UserEntity { + return Object.assign(new UserEntity(), { + id: 'session-user', + active: true, + email: 'invited@example.test', + emailVerified: true, + name: 'Invited User', + ...overrides, + }); +} + +function dataSource(): DataSource { + const repository = { + create: (value: T) => value, + save: (value: T | T[]) => + Promise.resolve( + Array.isArray(value) ? value : { ...value, id: 'generated-id' }, + ), + }; + return { + transaction: (action: (manager: EntityManager) => Promise) => + action({ getRepository: () => repository } as unknown as EntityManager), + } as DataSource; +} + +function invitation( + token: string, + overrides: Partial = {}, +) { + const entity = Object.assign(new ProjectInvitationEntity(), { + id: 'invitation-1', + projectId: 'project-1', + project: { + id: 'project-1', + name: 'Privates Projekt', + description: null, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + }, + invitedEmail: 'invited@example.test', + role: ProjectRole.Editor, + tokenHash: createHash('sha256').update(token).digest('hex'), + status: InvitationStatus.Pending, + mailStatus: InvitationMailStatus.NotConfigured, + expiresAt: new Date(Date.now() + 60_000), + acceptedByUserId: null, + acceptedByUser: null, + respondedAt: null, + ...overrides, + }); + return entity; +} + +describe('ProjectsService', () => { + it('creates project, owner membership and activity atomically for the session user', async () => { + const savedMemberships: { userId: string; role: ProjectRole }[] = []; + const saveActivity = vi.fn(() => Promise.resolve({})); + const projects = { + saveProject: (project: { name: string; description: string | null }) => + Promise.resolve({ + ...project, + id: 'project-1', + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + }), + saveMembership: (membership: { userId: string; role: ProjectRole }) => { + savedMemberships.push(membership); + return Promise.resolve(membership); + }, + saveActivity, + } as unknown as ProjectsRepository; + const users = { + findById: () => Promise.resolve(user()), + } as unknown as UsersRepository; + const service = new ProjectsService( + projects, + {} as ProjectAccessService, + users, + {} as NotificationsService, + dataSource(), + ); + + const result = await service.create('session-user', { name: 'Hausbau' }); + + expect(result.role).toBe(ProjectRole.Owner); + expect(savedMemberships).toMatchObject([ + { userId: 'session-user', role: ProjectRole.Owner }, + ]); + expect(saveActivity).toHaveBeenCalledOnce(); + }); + + it('never accepts an invitation for a different current-user email', async () => { + const token = 'A'.repeat(43); + const projects = { + findInvitationByTokenHash: () => Promise.resolve(invitation(token)), + } as unknown as ProjectsRepository; + const users = { + findById: () => Promise.resolve(user({ email: 'other@example.test' })), + } as unknown as UsersRepository; + const service = new ProjectsService( + projects, + {} as ProjectAccessService, + users, + {} as NotificationsService, + dataSource(), + ); + + await expect( + service.acceptInvitation(token, 'session-user'), + ).rejects.toMatchObject({ + code: ErrorCode.ProjectInvitationEmailMismatch, + status: 403, + }); + }); + + it('requires the existing OIDC email verification claim for acceptance', async () => { + const token = 'B'.repeat(43); + const projects = { + findInvitationByTokenHash: () => Promise.resolve(invitation(token)), + } as unknown as ProjectsRepository; + const users = { + findById: () => Promise.resolve(user({ emailVerified: false })), + } as unknown as UsersRepository; + const service = new ProjectsService( + projects, + {} as ProjectAccessService, + users, + {} as NotificationsService, + dataSource(), + ); + + await expect( + service.acceptInvitation(token, 'session-user'), + ).rejects.toMatchObject({ + code: ErrorCode.ProjectInvitationEmailUnverified, + status: 403, + }); + }); + + it('rejects globally disabled users before invitation acceptance', async () => { + const token = 'E'.repeat(43); + const projects = { + findInvitationByTokenHash: () => Promise.resolve(invitation(token)), + } as unknown as ProjectsRepository; + const users = { + findById: () => Promise.resolve(user({ active: false })), + } as unknown as UsersRepository; + const service = new ProjectsService( + projects, + {} as ProjectAccessService, + users, + {} as NotificationsService, + dataSource(), + ); + + await expect( + service.acceptInvitation(token, 'session-user'), + ).rejects.toMatchObject({ + code: ErrorCode.UserDisabled, + status: 403, + }); + }); + + it('uses the internal session user id when an invitation is accepted', async () => { + const token = 'C'.repeat(43); + const savedMemberships: { userId: string; role: ProjectRole }[] = []; + const projects = { + findInvitationByTokenHash: () => Promise.resolve(invitation(token)), + findMembership: () => Promise.resolve(null), + saveMembership: (membership: { userId: string; role: ProjectRole }) => { + savedMemberships.push(membership); + return Promise.resolve(membership); + }, + saveInvitation: (entity: ProjectInvitationEntity) => + Promise.resolve(entity), + saveActivity: () => Promise.resolve({}), + } as unknown as ProjectsRepository; + const users = { + findById: () => Promise.resolve(user()), + } as unknown as UsersRepository; + const service = new ProjectsService( + projects, + {} as ProjectAccessService, + users, + {} as NotificationsService, + dataSource(), + ); + + await service.acceptInvitation(token, 'session-user'); + + expect(savedMemberships).toMatchObject([ + { userId: 'session-user', role: ProjectRole.Editor }, + ]); + }); + + it('lists first-login invitations without automatically accepting them', async () => { + const saveMembership = vi.fn(); + const projects = { + listPendingInvitations: () => + Promise.resolve([invitation('D'.repeat(43))]), + saveMembership, + } as unknown as ProjectsRepository; + const users = { + findById: () => Promise.resolve(user()), + } as unknown as UsersRepository; + const service = new ProjectsService( + projects, + {} as ProjectAccessService, + users, + {} as NotificationsService, + dataSource(), + ); + + const result = await service.listPendingInvitations('session-user'); + + expect(result).toHaveLength(1); + expect(saveMembership).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/renovation/development-seed.service.ts b/apps/backend/src/renovation/development-seed.service.ts new file mode 100644 index 0000000..3688fea --- /dev/null +++ b/apps/backend/src/renovation/development-seed.service.ts @@ -0,0 +1,1044 @@ +import { createHash } from 'node:crypto'; +import { Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { AppConfigService } from '../config/config.service'; +import { NotificationType } from '../notifications/notification-types'; +import { NotificationsService } from '../notifications/notifications.service'; +import { ProjectActivityEntity } from '../projects/entities/project-activity.entity'; +import { + InvitationMailStatus, + InvitationStatus, + ProjectInvitationEntity, +} from '../projects/entities/project-invitation.entity'; +import { + ProjectMembershipEntity, + ProjectRole, +} from '../projects/entities/project-membership.entity'; +import { ProjectEntity } from '../projects/entities/project.entity'; +import { UserEntity } from '../users/entities/user.entity'; +import { + BudgetCategoryEntity, + BuildingEntity, + BuildingType, + ChecklistItemEntity, + ExpenseEntity, + FloorEntity, + MilestoneEntity, + ProjectDocumentEntity, + RenovationTaskEntity, + RoomEntity, + RoomStatus, + TaskCommentEntity, + TaskDependencyEntity, + TaskPriority, + TaskStatus, +} from './entities/renovation.entities'; +import { + FurnitureAvailability, + FurnitureCondition, + FurnitureDeliveryStatus, + FurnitureOptionDocumentEntity, + FurnitureOptionEntity, + FurnitureOptionStatus, + FurnitureRequirementCategory, + FurnitureRequirementEntity, + FurnitureRequirementPriority, + FurnitureRequirementStatus, + FurnitureScenarioEntity, + FurnitureScenarioSelectionEntity, + FurnitureScenarioStatus, + FurnitureScenarioType, +} from './entities/furniture.entities'; +import { calculateFurnitureTotal } from './furniture-pricing'; + +interface SeedTask { + title: string; + room?: string; + status: TaskStatus; + priority?: TaskPriority; + category: string; + dueOffset: number; + assigned?: number; +} + +const seedMarker = '[hauspilot-development-seed:v1]'; +const floorRooms: Record = { + Keller: ['Kellerraum', 'Hauswirtschaftsraum'], + Erdgeschoss: [ + 'Küche', + 'Wohnzimmer', + 'Essbereich', + 'Gäste-WC', + 'Flur Erdgeschoss', + 'Terrasse', + 'Garten', + 'Garage', + ], + '1. Stock': ['Schlafzimmer', 'Kinderzimmer', 'Badezimmer'], + Dachboden: ['Arbeitszimmer', 'Abstellraum'], +}; +const seedTasks: SeedTask[] = [ + { + title: 'Zählerstände dokumentieren', + status: TaskStatus.Done, + category: 'administration', + dueOffset: -18, + assigned: 0, + }, + { + title: 'Schlösser austauschen', + status: TaskStatus.Done, + category: 'general', + dueOffset: -15, + assigned: 1, + }, + { + title: 'Alten Boden entfernen', + room: 'Wohnzimmer', + status: TaskStatus.Done, + category: 'flooring', + dueOffset: -12, + assigned: 1, + }, + { + title: 'Elektrik prüfen', + room: 'Wohnzimmer', + status: TaskStatus.InProgress, + priority: TaskPriority.Critical, + category: 'electrical', + dueOffset: -4, + assigned: 2, + }, + { + title: 'Wände spachteln', + room: 'Wohnzimmer', + status: TaskStatus.Blocked, + priority: TaskPriority.High, + category: 'painting', + dueOffset: 2, + assigned: 1, + }, + { + title: 'Wände streichen', + room: 'Wohnzimmer', + status: TaskStatus.Planned, + category: 'painting', + dueOffset: 7, + assigned: 1, + }, + { + title: 'Boden verlegen', + room: 'Wohnzimmer', + status: TaskStatus.Planned, + category: 'flooring', + dueOffset: 11, + assigned: 2, + }, + { + title: 'Sockelleisten montieren', + room: 'Wohnzimmer', + status: TaskStatus.Planned, + category: 'flooring', + dueOffset: 13, + assigned: 2, + }, + { + title: 'Endreinigung Wohnzimmer', + room: 'Wohnzimmer', + status: TaskStatus.Planned, + category: 'cleaning', + dueOffset: 15, + }, + { + title: 'Raumabnahme Wohnzimmer', + room: 'Wohnzimmer', + status: TaskStatus.Planned, + category: 'general', + dueOffset: 17, + assigned: 0, + }, + { + title: 'Küchenaufmaß durchführen', + room: 'Küche', + status: TaskStatus.Done, + category: 'kitchen', + dueOffset: -20, + assigned: 0, + }, + { + title: 'Küche bestellen', + room: 'Küche', + status: TaskStatus.Commissioned, + priority: TaskPriority.High, + category: 'kitchen', + dueOffset: -2, + assigned: 0, + }, + { + title: 'Küchenanschlüsse vorbereiten', + room: 'Küche', + status: TaskStatus.InProgress, + category: 'plumbing', + dueOffset: 5, + assigned: 2, + }, + { + title: 'Küche montieren', + room: 'Küche', + status: TaskStatus.Planned, + category: 'kitchen', + dueOffset: 24, + assigned: 1, + }, + { + title: 'Badarmaturen austauschen', + room: 'Badezimmer', + status: TaskStatus.Commissioned, + category: 'plumbing', + dueOffset: 8, + assigned: 2, + }, + { + title: 'Fliesenfugen erneuern', + room: 'Badezimmer', + status: TaskStatus.InProgress, + category: 'plumbing', + dueOffset: 4, + assigned: 1, + }, + { + title: 'Kinderzimmer tapezieren', + room: 'Kinderzimmer', + status: TaskStatus.Planned, + category: 'painting', + dueOffset: 12, + assigned: 1, + }, + { + title: 'Schlafzimmer streichen', + room: 'Schlafzimmer', + status: TaskStatus.Acceptance, + category: 'painting', + dueOffset: -1, + assigned: 1, + }, + { + title: 'Arbeitszimmer ausmessen', + room: 'Arbeitszimmer', + status: TaskStatus.Done, + category: 'planning', + dueOffset: -10, + assigned: 0, + }, + { + title: 'Terrasse reinigen', + room: 'Terrasse', + status: TaskStatus.Idea, + priority: TaskPriority.Low, + category: 'outdoor', + dueOffset: 30, + }, + { + title: 'Garten zurückschneiden', + room: 'Garten', + status: TaskStatus.Planned, + category: 'outdoor', + dueOffset: 21, + assigned: 2, + }, + { + title: 'Garage entrümpeln', + room: 'Garage', + status: TaskStatus.Blocked, + category: 'cleaning', + dueOffset: 3, + }, + { + title: 'Internetanschluss beauftragen', + status: TaskStatus.Commissioned, + priority: TaskPriority.Critical, + category: 'administration', + dueOffset: -3, + assigned: 0, + }, + { + title: 'Umzugsunternehmen auswählen', + status: TaskStatus.InProgress, + priority: TaskPriority.High, + category: 'moving', + dueOffset: 6, + assigned: 0, + }, + { + title: 'Kartons beschaffen', + status: TaskStatus.Planned, + category: 'moving', + dueOffset: 10, + assigned: 1, + }, + { + title: 'Nachsendeauftrag einrichten', + status: TaskStatus.Planned, + category: 'moving', + dueOffset: 18, + assigned: 0, + }, + { + title: 'Alte Wohnung kündigungsbereit machen', + status: TaskStatus.Planned, + category: 'moving', + dueOffset: 28, + assigned: 2, + }, + { + title: 'Versicherungen informieren', + status: TaskStatus.Idea, + category: 'administration', + dueOffset: 20, + }, + { + title: 'Umzugshelfer organisieren', + status: TaskStatus.Planned, + category: 'moving', + dueOffset: 14, + assigned: 1, + }, + { + title: 'Endreinigung durchführen', + status: TaskStatus.Planned, + priority: TaskPriority.High, + category: 'cleaning', + dueOffset: 35, + }, +]; + +@Injectable() +export class DevelopmentSeedService { + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + private readonly config: AppConfigService, + private readonly notifications: NotificationsService, + ) {} + + async run(): Promise<{ created: boolean; projectId: string }> { + if (this.config.nodeEnv === 'production') + throw new Error('Development-Seed ist in Produktion gesperrt.'); + const existing = await this.dataSource + .getRepository(ProjectEntity) + .findOne({ where: { description: seedMarker } }); + if (existing) return { created: false, projectId: existing.id }; + const users = await this.dataSource + .getRepository(UserEntity) + .find({ where: { active: true }, order: { createdAt: 'ASC' }, take: 4 }); + if (!users.length) + throw new Error( + 'Der Development-Seed benötigt mindestens einen bestehenden aktiven SSO-Benutzer.', + ); + const primaryUser = this.at(users, 0, 'Seed-Benutzer'); + + return this.dataSource.transaction(async (manager) => { + const now = new Date(); + const project = await manager.save( + ProjectEntity, + manager.create(ProjectEntity, { + name: 'Umzug Reihenhaus', + description: seedMarker, + status: 'renovation', + totalBudget: '45000.00', + currency: 'EUR', + }), + ); + const roles = [ + ProjectRole.Owner, + ProjectRole.Administrator, + ProjectRole.Editor, + ProjectRole.Reader, + ]; + await manager.save( + ProjectMembershipEntity, + users.map((user, index) => + manager.create(ProjectMembershipEntity, { + projectId: project.id, + userId: user.id, + role: roles[index] ?? ProjectRole.Editor, + active: true, + }), + ), + ); + const building = await manager.save( + BuildingEntity, + manager.create(BuildingEntity, { + projectId: project.id, + name: 'Reihenhaus', + description: 'Development-Demogebäude', + type: BuildingType.TerracedHouse, + sortOrder: 0, + }), + ); + const roomMap = new Map(); + let floorIndex = 0; + for (const [floorName, roomNames] of Object.entries(floorRooms)) { + const floor = await manager.save( + FloorEntity, + manager.create(FloorEntity, { + projectId: project.id, + buildingId: building.id, + name: floorName, + description: null, + sortOrder: floorIndex++, + }), + ); + const rooms = await manager.save( + RoomEntity, + roomNames.map((name, index) => + manager.create(RoomEntity, { + projectId: project.id, + floorId: floor.id, + name, + description: null, + type: this.roomType(name), + area: String(8 + index * 4), + status: + index % 4 === 0 ? RoomStatus.Renovating : RoomStatus.Planning, + plannedBudget: String(1200 + index * 700), + sortOrder: index, + previewDocumentId: null, + }), + ), + ); + rooms.forEach((room) => roomMap.set(room.name, room)); + } + const assignees = users.filter( + (_, index) => roles[index] !== ProjectRole.Reader, + ); + const tasks = await manager.save( + RenovationTaskEntity, + seedTasks.map((seed, index) => + manager.create(RenovationTaskEntity, { + projectId: project.id, + roomId: seed.room ? (roomMap.get(seed.room)?.id ?? null) : null, + title: seed.title, + description: `Realistische Seed-Aufgabe: ${seed.title}`, + category: seed.category, + status: seed.status, + priority: seed.priority ?? TaskPriority.Normal, + assigneeUserId: + seed.assigned === undefined + ? null + : (assignees[seed.assigned % assignees.length]?.id ?? null), + plannedStartDate: this.date(now, seed.dueOffset - 4), + dueDate: this.date(now, seed.dueOffset), + completedAt: + seed.status === TaskStatus.Done + ? this.dateObject(now, seed.dueOffset) + : null, + estimatedEffortHours: String(2 + (index % 12)), + estimatedCost: String(100 + (index % 8) * 275), + actualCost: + seed.status === TaskStatus.Done + ? String(90 + (index % 8) * 260) + : null, + blockingReason: + seed.status === TaskStatus.Blocked + ? 'Eine notwendige Vorarbeit ist noch nicht abgeschlossen.' + : null, + sortOrder: index, + weight: seed.priority === TaskPriority.Critical ? '2.00' : '1.00', + createdByUserId: primaryUser.id, + }), + ), + ); + const taskMap = new Map(tasks.map((task) => [task.title, task])); + const chain = [ + 'Alten Boden entfernen', + 'Elektrik prüfen', + 'Wände spachteln', + 'Wände streichen', + 'Boden verlegen', + 'Sockelleisten montieren', + 'Endreinigung Wohnzimmer', + 'Raumabnahme Wohnzimmer', + ]; + const dependencyPairs: [string, string][] = chain + .slice(1) + .map((title, index) => [ + this.at(chain, index, 'Abhängigkeitskette'), + title, + ]); + dependencyPairs.push( + ['Küchenaufmaß durchführen', 'Küche bestellen'], + ['Küche bestellen', 'Küche montieren'], + ['Umzugsunternehmen auswählen', 'Umzugshelfer organisieren'], + ); + await manager.save( + TaskDependencyEntity, + dependencyPairs.map(([from, to]) => + manager.create(TaskDependencyEntity, { + projectId: project.id, + predecessorTaskId: this.fromMap(taskMap, from).id, + successorTaskId: this.fromMap(taskMap, to).id, + type: 'finish_to_start', + }), + ), + ); + await manager.save( + ChecklistItemEntity, + tasks.slice(0, 10).flatMap((task, index) => + [0, 1].map((part) => + manager.create(ChecklistItemEntity, { + projectId: project.id, + taskId: task.id, + text: part + ? 'Ergebnis dokumentieren' + : 'Material und Werkzeug prüfen', + completed: index % 3 === 0 && part === 0, + sortOrder: part, + completedByUserId: + index % 3 === 0 && part === 0 ? primaryUser.id : null, + completedAt: index % 3 === 0 && part === 0 ? now : null, + }), + ), + ), + ); + await manager.save( + TaskCommentEntity, + tasks.slice(0, 10).map((task, index) => + manager.create(TaskCommentEntity, { + projectId: project.id, + taskId: task.id, + authorUserId: this.at(users, index % users.length, 'Kommentarautor') + .id, + text: + index % 2 + ? 'Termin und Material sind abgestimmt.' + : 'Bitte den aktuellen Stand vor dem nächsten Termin prüfen.', + }), + ), + ); + const milestoneSpecs: [string, string, number][] = [ + ['Schlüsselübergabe', 'key_handover', -20], + ['Renovierungsbeginn', 'renovation_start', -17], + ['Elektrik-Abnahme', 'acceptance', 5], + ['Küchenlieferung', 'material_delivery', 20], + ['Bad-Abnahme', 'acceptance', 24], + ['Renovierungsabschluss', 'renovation_end', 32], + ['Umzug', 'move', 38], + ['Übergabe alte Wohnung', 'old_home_handover', 45], + ]; + await manager.save( + MilestoneEntity, + milestoneSpecs.map(([title, type, offset], index) => + manager.create(MilestoneEntity, { + projectId: project.id, + title, + description: null, + date: this.date(now, offset), + status: offset < 0 ? (index < 2 ? 'done' : 'at_risk') : 'planned', + type, + responsibleUserId: assignees[index % assignees.length]?.id ?? null, + }), + ), + ); + const budgetNames = [ + 'Elektrik', + 'Sanitär', + 'Heizung', + 'Malerarbeiten', + 'Boden', + 'Küche', + 'Möbel', + 'Außenbereich', + 'Umzug', + 'Werkzeuge', + 'Gebühren', + 'Sonstiges', + 'Reserve', + ]; + const budgets = await manager.save( + BudgetCategoryEntity, + budgetNames.map((name, index) => + manager.create(BudgetCategoryEntity, { + projectId: project.id, + name, + plannedBudget: + name === 'Küche' + ? '12000.00' + : name === 'Reserve' + ? '4500.00' + : String(1800 + index * 180), + sortOrder: index, + active: true, + }), + ), + ); + const roomValues = Array.from(roomMap.values()); + const expensePrefixes = [ + 'Materialeinkauf', + 'Handwerkerrechnung', + 'Mietgerät', + ]; + await manager.save( + ExpenseEntity, + Array.from({ length: 15 }, (_, index) => + manager.create(ExpenseEntity, { + projectId: project.id, + budgetCategoryId: this.at( + budgets, + index % budgets.length, + 'Budgetkategorie', + ).id, + roomId: + index % 3 === 0 + ? this.at(roomValues, index % roomValues.length, 'Raum').id + : null, + taskId: + index % 2 === 0 ? this.at(tasks, index, 'Aufgabe').id : null, + title: `${this.at(expensePrefixes, index % expensePrefixes.length, 'Ausgabenart')} ${index + 1}`, + description: 'Realistische Entwicklungsausgabe', + amount: String(180 + index * 145), + currency: 'EUR', + expenseDate: this.date(now, index - 12), + paymentStatus: + index % 3 === 0 ? 'paid' : index % 3 === 1 ? 'open' : 'planned', + dueDate: + index === 1 ? this.date(now, -8) : this.date(now, index + 2), + supplier: this.at( + ['Baumarkt Nord', 'Elektro Beispiel GmbH', 'Sanitär Muster'], + index % 3, + 'Lieferant', + ), + invoiceNumber: `DEV-${String(index + 1).padStart(3, '0')}`, + documentId: null, + createdByUserId: primaryUser.id, + }), + ), + ); + const documentSpecs: [string, string, string, string][] = [ + ['Bestandsfoto Wohnzimmer', 'photo', 'wohnzimmer.jpg', 'image/jpeg'], + [ + 'Angebot Elektrik', + 'offer', + 'angebot-elektrik.pdf', + 'application/pdf', + ], + [ + 'Rechnung Baumarkt', + 'invoice', + 'rechnung-baumarkt.pdf', + 'application/pdf', + ], + [ + 'Grundriss Erdgeschoss', + 'floor_plan', + 'grundriss-eg.png', + 'image/png', + ], + ]; + const documents = await manager.save( + ProjectDocumentEntity, + documentSpecs.map(([title, type, filename, mime], index) => + manager.create(ProjectDocumentEntity, { + projectId: project.id, + roomId: + index === 0 ? (roomMap.get('Wohnzimmer')?.id ?? null) : null, + taskId: + index < 2 + ? this.at(tasks, index + 3, 'Dokumentaufgabe').id + : null, + type, + title, + description: + 'Nur Metadaten im Development-Seed; kein produktiver Dateispeicherinhalt.', + originalFilename: filename, + storageName: `seed-${index}-${filename}`, + mimeType: mime, + fileSize: 1000 + index * 500, + storageReference: `development-seed/${filename}`, + uploadedByUserId: primaryUser.id, + uploadedAt: now, + }), + ), + ); + const furnitureSpecs: Array< + [string, string, FurnitureRequirementCategory, number] + > = [ + ['Wohnzimmer', 'Sofa', FurnitureRequirementCategory.Seating, 1], + ['Wohnzimmer', 'Couchtisch', FurnitureRequirementCategory.Tables, 1], + ['Wohnzimmer', 'TV-Schrank', FurnitureRequirementCategory.Cabinets, 1], + ['Wohnzimmer', 'Teppich', FurnitureRequirementCategory.Textiles, 1], + ['Wohnzimmer', 'Stehleuchte', FurnitureRequirementCategory.Lighting, 1], + ['Essbereich', 'Esstisch', FurnitureRequirementCategory.Tables, 1], + [ + 'Essbereich', + 'Esszimmerstühle', + FurnitureRequirementCategory.Chairs, + 6, + ], + [ + 'Essbereich', + 'Pendelleuchte', + FurnitureRequirementCategory.Lighting, + 1, + ], + ['Schlafzimmer', 'Doppelbett', FurnitureRequirementCategory.Beds, 1], + [ + 'Schlafzimmer', + 'Kleiderschrank', + FurnitureRequirementCategory.Cabinets, + 1, + ], + ['Schlafzimmer', 'Nachttische', FurnitureRequirementCategory.Tables, 2], + [ + 'Schlafzimmer', + 'Deckenleuchte', + FurnitureRequirementCategory.Lighting, + 1, + ], + [ + 'Arbeitszimmer', + 'Schreibtisch', + FurnitureRequirementCategory.Office, + 1, + ], + ['Arbeitszimmer', 'Bürostuhl', FurnitureRequirementCategory.Office, 1], + ['Arbeitszimmer', 'Regal', FurnitureRequirementCategory.Shelves, 1], + [ + 'Arbeitszimmer', + 'Schreibtischleuchte', + FurnitureRequirementCategory.Lighting, + 1, + ], + ]; + const furnitureRequirements = await manager.save( + FurnitureRequirementEntity, + furnitureSpecs.map(([roomName, name, category, quantity], index) => + manager.create(FurnitureRequirementEntity, { + projectId: project.id, + roomId: this.fromMap(roomMap, roomName).id, + name, + description: `Einrichtungsbedarf ${name} für ${roomName}`, + category, + priority: + index % 5 === 0 + ? FurnitureRequirementPriority.Essential + : FurnitureRequirementPriority.Normal, + requiredQuantity: quantity, + status: + index < 8 + ? FurnitureRequirementStatus.Selected + : FurnitureRequirementStatus.HasOptions, + responsibleUserId: this.at( + assignees, + index % assignees.length, + 'Möbelverantwortlicher', + ).id, + maximumBudget: String(350 + index * 175), + sortOrder: index, + createdByUserId: primaryUser.id, + }), + ), + ); + const optionDrafts: FurnitureOptionEntity[] = []; + for (const [ + requirementIndex, + requirement, + ] of furnitureRequirements.entries()) { + for (let variant = 0; variant < 3; variant++) { + const existingItem = requirementIndex === 10 && variant === 0; + const unitPrice = existingItem + ? 0 + : 190 + requirementIndex * 85 + variant * 310; + const shippingCost = existingItem ? 45 : 20 + variant * 15; + const additionalCost = variant === 2 ? 60 : 0; + const movingCost = existingItem ? 80 : 0; + const refurbishmentCost = existingItem ? 35 : 0; + const totalPrice = calculateFurnitureTotal({ + unitPrice, + quantity: requirement.requiredQuantity, + shippingCost, + additionalCost, + discount: variant === 1 ? 25 : 0, + existingItem, + movingCost, + refurbishmentCost, + }); + const selected = variant === 1 && requirementIndex < 8; + optionDrafts.push( + manager.create(FurnitureOptionEntity, { + projectId: project.id, + requirementId: requirement.id, + name: `${requirement.name} ${this.at(['Budget', 'Wunsch', 'Premium'], variant, 'Möbelvariante')}`, + manufacturer: this.at( + ['Nordform', 'Wohnwert', 'Manufaktur'], + variant, + 'Hersteller', + ), + model: `HP-${requirementIndex + 1}-${variant + 1}`, + description: + 'Realistische Möbelalternative aus dem Development-Seed.', + retailer: this.at( + ['Möbelmarkt', 'Einrichtungshaus', 'Designstudio'], + variant, + 'Händler', + ), + productUrl: `https://example.invalid/moebel/${requirementIndex + 1}/${variant + 1}`, + articleNumber: `HP-${requirementIndex + 1}${variant + 1}`, + unitPrice: unitPrice.toFixed(2), + originalPrice: variant === 1 ? String(unitPrice + 50) : null, + shippingCost: shippingCost.toFixed(2), + additionalCost: additionalCost.toFixed(2), + discount: variant === 1 ? '25.00' : '0.00', + totalPrice, + currency: 'EUR', + quantity: requirement.requiredQuantity, + width: String(80 + variant * 30), + height: String(70 + variant * 5), + depth: String(50 + variant * 20), + weight: null, + color: this.at(['Grau', 'Beige', 'Grün'], variant, 'Farbe'), + material: this.at( + ['Stoff', 'Holz', 'Massivholz'], + variant, + 'Material', + ), + deliveryDays: 7 + variant * 14, + earliestDeliveryDate: this.date(now, 5 + variant * 7), + expectedDeliveryDate: + requirementIndex === 5 && variant === 1 + ? this.date(now, -3) + : this.date(now, 14 + variant * 14), + returnDeadline: null, + availability: FurnitureAvailability.Available, + favorite: variant === 1, + currentlySelected: selected, + status: selected + ? FurnitureOptionStatus.Selected + : variant === 1 + ? FurnitureOptionStatus.Favorite + : FurnitureOptionStatus.Reviewing, + notes: null, + budgetCategoryId: + budgets.find((budget) => budget.name === 'Möbel')?.id ?? null, + existingItem, + estimatedCurrentValue: existingItem ? '120.00' : null, + movingCost: movingCost.toFixed(2), + refurbishmentCost: refurbishmentCost.toFixed(2), + currentLocation: existingItem ? 'Alte Wohnung' : null, + condition: existingItem + ? FurnitureCondition.Good + : FurnitureCondition.New, + orderedAt: + requirementIndex < 3 && variant === 1 + ? this.dateObject(now, -5) + : null, + orderedByUserId: + requirementIndex < 3 && variant === 1 ? primaryUser.id : null, + orderNumber: + requirementIndex < 3 && variant === 1 + ? `ORDER-${requirementIndex + 1}` + : null, + actualDeliveryDate: + requirementIndex === 0 && variant === 1 + ? this.date(now, -1) + : null, + deliveryStatus: + requirementIndex === 0 && variant === 1 + ? FurnitureDeliveryStatus.Delivered + : requirementIndex < 3 && variant === 1 + ? FurnitureDeliveryStatus.Ordered + : FurnitureDeliveryStatus.NotOrdered, + deliveredQuantity: + requirementIndex === 0 && variant === 1 + ? requirement.requiredQuantity + : 0, + assemblyDate: null, + assembledBy: null, + createdByUserId: primaryUser.id, + }), + ); + } + } + const furnitureOptions = await manager.save( + FurnitureOptionEntity, + optionDrafts, + ); + const scenarioSpecs: Array<[string, FurnitureScenarioType, boolean]> = [ + ['Budget', FurnitureScenarioType.Budget, false], + ['Wunsch', FurnitureScenarioType.Preferred, true], + ['Premium', FurnitureScenarioType.Premium, false], + ]; + const scenarios = await manager.save( + FurnitureScenarioEntity, + scenarioSpecs.map(([name, type, isDefault]) => + manager.create(FurnitureScenarioEntity, { + projectId: project.id, + name, + description: `${name}-Einrichtung`, + type, + status: FurnitureScenarioStatus.Active, + isDefault, + createdByUserId: primaryUser.id, + }), + ), + ); + await manager.save( + FurnitureScenarioSelectionEntity, + scenarios.flatMap((scenario) => + furnitureRequirements.map((requirement) => { + const candidates = furnitureOptions + .filter((option) => option.requirementId === requirement.id) + .sort((a, b) => Number(a.totalPrice) - Number(b.totalPrice)); + const option = + scenario.type === FurnitureScenarioType.Premium + ? this.at(candidates, 2, 'Premiumalternative') + : scenario.type === FurnitureScenarioType.Preferred + ? this.at(candidates, 1, 'Wunschalternative') + : this.at(candidates, 0, 'Budgetalternative'); + return manager.create(FurnitureScenarioSelectionEntity, { + projectId: project.id, + scenarioId: scenario.id, + requirementId: requirement.id, + optionId: option.id, + quantity: option.quantity, + priceOverride: null, + note: null, + }); + }), + ), + ); + const existingExpenses = await manager.find(ExpenseEntity, { + where: { projectId: project.id }, + order: { createdAt: 'ASC' }, + take: 3, + }); + for (const [index, expense] of existingExpenses.entries()) { + const requirement = this.at( + furnitureRequirements, + index, + 'Möbelausgabenbedarf', + ); + const option = furnitureOptions.find( + (entry) => + entry.requirementId === requirement.id && entry.currentlySelected, + ); + if (option) { + expense.furnitureRequirementId = requirement.id; + expense.furnitureOptionId = option.id; + expense.roomId = requirement.roomId; + await manager.save(expense); + } + } + if (documents[0] && furnitureOptions[0]) { + await manager.save( + FurnitureOptionDocumentEntity, + manager.create(FurnitureOptionDocumentEntity, { + projectId: project.id, + optionId: furnitureOptions[0].id, + documentId: documents[0].id, + }), + ); + } + await manager.save( + ProjectActivityEntity, + Array.from({ length: 20 }, (_, index) => + manager.create(ProjectActivityEntity, { + projectId: project.id, + actorUserId: this.at(users, index % users.length, 'Aktivitätsautor') + .id, + action: + index % 3 === 0 + ? 'task.updated' + : index % 3 === 1 + ? 'room.status-changed' + : 'expense.created', + metadata: { + taskId: this.at(tasks, index % tasks.length, 'Aktivitätsaufgabe') + .id, + seed: true, + sequence: index + 1, + }, + }), + ), + ); + const invitation = manager.create(ProjectInvitationEntity, { + projectId: project.id, + invitedEmail: 'hauspilot-gast@example.invalid', + invitedUserId: null, + invitedByUserId: primaryUser.id, + role: ProjectRole.Editor, + tokenHash: createHash('sha256') + .update(`${seedMarker}:${project.id}`) + .digest('hex'), + status: InvitationStatus.Pending, + mailStatus: InvitationMailStatus.NotConfigured, + expiresAt: this.dateObject(now, 14), + acceptedByUserId: null, + respondedAt: null, + }); + await manager.save(invitation); + for (const user of users.slice(0, 3)) + await this.notifications.createForUser( + { + userId: user.id, + type: NotificationType.System, + title: 'HausPilot-Demoprojekt', + message: 'Im Projekt „Umzug Reihenhaus“ warten neue Aufgaben.', + link: `/projekte/${project.id}/uebersicht`, + metadata: { projectId: project.id, seed: true }, + }, + manager, + ); + return { created: true, projectId: project.id }; + }); + } + + async reset(): Promise { + if (this.config.nodeEnv === 'production') + throw new Error('Development-Seed-Reset ist in Produktion gesperrt.'); + const project = await this.dataSource + .getRepository(ProjectEntity) + .findOne({ where: { description: seedMarker } }); + if (!project) return false; + await this.dataSource + .getRepository(ProjectEntity) + .delete({ id: project.id }); + return true; + } + + private date(base: Date, offset: number) { + return this.dateObject(base, offset).toISOString().slice(0, 10); + } + private dateObject(base: Date, offset: number) { + const date = new Date(base); + date.setDate(date.getDate() + offset); + return date; + } + private roomType(name: string) { + const match: Record = { + Küche: 'kitchen', + Wohnzimmer: 'living_room', + Schlafzimmer: 'bedroom', + Kinderzimmer: 'children_room', + Badezimmer: 'bathroom', + Garten: 'garden', + Terrasse: 'terrace', + Garage: 'garage', + Arbeitszimmer: 'office', + }; + return match[name] ?? 'other'; + } + + private at(items: readonly T[], index: number, label: string): T { + const item = items[index]; + if (item === undefined) + throw new Error(`${label} fehlt im Development-Seed.`); + return item; + } + + private fromMap(items: ReadonlyMap, key: string): T { + const item = items.get(key); + if (item === undefined) throw new Error(`Seed-Referenz „${key}“ fehlt.`); + return item; + } +} diff --git a/apps/backend/src/renovation/document-storage.service.ts b/apps/backend/src/renovation/document-storage.service.ts new file mode 100644 index 0000000..e5caa85 --- /dev/null +++ b/apps/backend/src/renovation/document-storage.service.ts @@ -0,0 +1,101 @@ +import { randomUUID } from 'node:crypto'; +import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'; +import { extname, resolve, sep } from 'node:path'; +import { Injectable } from '@nestjs/common'; +import { ApiError } from '../common/errors/api-error'; +import { ErrorCode } from '../common/errors/error-codes'; +import { AppConfigService } from '../config/config.service'; + +const allowed: Record = { + 'image/jpeg': ['.jpg', '.jpeg'], + 'image/png': ['.png'], + 'image/webp': ['.webp'], + 'application/pdf': ['.pdf'], +}; + +@Injectable() +export class DocumentStorageService { + private readonly root: string; + private readonly maxFileSizeBytes: number; + + constructor(config: AppConfigService) { + this.root = resolve(config.documents.storagePath); + this.maxFileSizeBytes = config.documents.maxFileSizeBytes; + } + + async store(file: Express.Multer.File) { + if (file.size > this.maxFileSizeBytes) { + throw new ApiError( + ErrorCode.ValidationFailed, + 'Die Datei überschreitet das konfigurierte Größenlimit.', + 400, + ); + } + const extension = extname(file.originalname).toLowerCase(); + if ( + !allowed[file.mimetype]?.includes(extension) || + !this.signatureMatches(file.mimetype, file.buffer) + ) { + throw new ApiError( + ErrorCode.ValidationFailed, + 'Erlaubt sind PDF-, JPEG-, PNG- und WebP-Dateien mit gültigem Dateiinhaltsformat.', + 400, + ); + } + const storageName = `${randomUUID()}${extension}`; + await mkdir(this.root, { recursive: true }); + const target = this.path(storageName); + await writeFile(target, file.buffer, { flag: 'wx' }); + return { storageName, storageReference: storageName }; + } + + read(reference: string) { + return readFile(this.path(reference)); + } + async remove(reference: string) { + try { + await unlink(this.path(reference)); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + } + + private path(reference: string) { + if (!/^[0-9a-f-]{36}\.(?:pdf|png|webp|jpe?g)$/i.test(reference)) + throw new ApiError( + ErrorCode.NotFound, + 'Das Dokument wurde nicht gefunden.', + 404, + ); + const result = resolve(this.root, reference); + if (!result.startsWith(`${this.root}${sep}`) && result !== this.root) + throw new ApiError( + ErrorCode.NotFound, + 'Das Dokument wurde nicht gefunden.', + 404, + ); + return result; + } + + private signatureMatches(mime: string, value: Buffer) { + if (mime === 'application/pdf') + return value.subarray(0, 5).toString('ascii') === '%PDF-'; + if (mime === 'image/png') + return value + .subarray(0, 8) + .equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); + if (mime === 'image/jpeg') + return ( + value[0] === 0xff && + value[1] === 0xd8 && + value.at(-2) === 0xff && + value.at(-1) === 0xd9 + ); + if (mime === 'image/webp') + return ( + value.subarray(0, 4).toString('ascii') === 'RIFF' && + value.subarray(8, 12).toString('ascii') === 'WEBP' + ); + return false; + } +} diff --git a/apps/backend/src/renovation/dto/furniture.dto.ts b/apps/backend/src/renovation/dto/furniture.dto.ts new file mode 100644 index 0000000..c7fda7e --- /dev/null +++ b/apps/backend/src/renovation/dto/furniture.dto.ts @@ -0,0 +1,231 @@ +import { Transform, Type } from 'class-transformer'; +import { + ArrayUnique, + IsArray, + IsBoolean, + IsDateString, + IsEnum, + IsIn, + IsInt, + IsNumber, + IsOptional, + IsString, + IsUUID, + IsUrl, + Length, + Max, + Min, +} from 'class-validator'; +import { + FurnitureAvailability, + FurnitureCondition, + FurnitureDeliveryStatus, + FurnitureOptionStatus, + FurnitureRequirementCategory, + FurnitureRequirementPriority, + FurnitureRequirementStatus, + FurnitureScenarioStatus, + FurnitureScenarioType, +} from '../entities/furniture.entities'; + +export class FurnitureListQueryDto { + @IsOptional() @Type(() => Number) @IsInt() @Min(1) page = 1; + @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) pageSize = 25; + @IsOptional() @IsString() @Length(0, 160) search?: string; + @IsOptional() @IsUUID() roomId?: string; + @IsOptional() + @IsEnum(FurnitureRequirementCategory) + category?: FurnitureRequirementCategory; + @IsOptional() + @IsEnum(FurnitureRequirementStatus) + status?: FurnitureRequirementStatus; + @IsOptional() + @IsEnum(FurnitureRequirementPriority) + priority?: FurnitureRequirementPriority; + @IsOptional() @IsUUID() responsibleUserId?: string; + @IsOptional() + @Transform(({ value }) => value === 'true') + @IsBoolean() + withoutOption?: boolean; + @IsOptional() + @Transform(({ value }) => value === 'true') + @IsBoolean() + selected?: boolean; + @IsOptional() + @Transform(({ value }) => value === 'true') + @IsBoolean() + favorite?: boolean; + @IsOptional() + @Transform(({ value }) => value === 'true') + @IsBoolean() + ordered?: boolean; + @IsOptional() + @Transform(({ value }) => value === 'true') + @IsBoolean() + delivered?: boolean; + @IsOptional() + @Transform(({ value }) => value === 'true') + @IsBoolean() + delayed?: boolean; + @IsOptional() + @Transform(({ value }) => value === 'true') + @IsBoolean() + overBudget?: boolean; + @IsOptional() + @Transform(({ value }) => value === 'true') + @IsBoolean() + openDecision?: boolean; + @IsOptional() @IsString() sortBy = 'sortOrder'; + @IsOptional() @IsIn(['ASC', 'DESC']) sortDirection: 'ASC' | 'DESC' = 'ASC'; +} + +export class FurnitureOptionListQueryDto { + @IsOptional() @Type(() => Number) @IsInt() @Min(1) page = 1; + @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) pageSize = 50; + @IsOptional() @IsString() @Length(0, 160) search?: string; + @IsOptional() @IsUUID() roomId?: string; + @IsOptional() @IsUUID() requirementId?: string; + @IsOptional() @IsEnum(FurnitureOptionStatus) status?: FurnitureOptionStatus; + @IsOptional() + @IsEnum(FurnitureAvailability) + availability?: FurnitureAvailability; + @IsOptional() + @Transform(({ value }) => value === 'true') + @IsBoolean() + favorite?: boolean; + @IsOptional() + @Transform(({ value }) => value === 'true') + @IsBoolean() + selected?: boolean; + @IsOptional() + @Transform(({ value }) => value === 'true') + @IsBoolean() + ordered?: boolean; + @IsOptional() + @Transform(({ value }) => value === 'true') + @IsBoolean() + delayed?: boolean; + @IsOptional() @IsString() sortBy = 'updatedAt'; + @IsOptional() @IsIn(['ASC', 'DESC']) sortDirection: 'ASC' | 'DESC' = 'DESC'; +} + +export class CreateFurnitureRequirementDto { + @IsUUID() roomId!: string; + @IsString() @Length(1, 160) name!: string; + @IsOptional() @IsString() @Length(0, 5000) description?: string; + @IsEnum(FurnitureRequirementCategory) category!: FurnitureRequirementCategory; + @IsEnum(FurnitureRequirementPriority) priority = + FurnitureRequirementPriority.Normal; + @Type(() => Number) @IsInt() @Min(1) @Max(10000) requiredQuantity = 1; + @IsEnum(FurnitureRequirementStatus) status = + FurnitureRequirementStatus.Identified; + @IsOptional() @IsUUID() responsibleUserId?: string; + @IsOptional() @Type(() => Number) @IsNumber() @Min(0) maximumBudget?: number; + @IsOptional() @Type(() => Number) @IsInt() sortOrder?: number; +} +export class UpdateFurnitureRequirementDto extends CreateFurnitureRequirementDto { + @Type(() => Number) @IsInt() @Min(1) version!: number; +} + +export class CreateFurnitureOptionDto { + @IsString() @Length(1, 180) name!: string; + @IsOptional() @IsString() @Length(0, 160) manufacturer?: string; + @IsOptional() @IsString() @Length(0, 160) model?: string; + @IsOptional() @IsString() @Length(0, 5000) description?: string; + @IsOptional() @IsString() @Length(0, 180) retailer?: string; + @IsOptional() + @IsUrl({ require_protocol: true, protocols: ['http', 'https'] }) + @Length(0, 1000) + productUrl?: string; + @IsOptional() @IsString() @Length(0, 120) articleNumber?: string; + @Type(() => Number) @IsNumber() @Min(0) unitPrice!: number; + @IsOptional() @Type(() => Number) @IsNumber() @Min(0) originalPrice?: number; + @IsOptional() @Type(() => Number) @IsNumber() @Min(0) shippingCost = 0; + @IsOptional() @Type(() => Number) @IsNumber() @Min(0) additionalCost = 0; + @IsOptional() @Type(() => Number) @IsNumber() @Min(0) discount = 0; + @IsString() @Length(3, 3) currency = 'EUR'; + @Type(() => Number) @IsInt() @Min(1) @Max(10000) quantity = 1; + @IsOptional() @Type(() => Number) @IsNumber() @Min(0) width?: number; + @IsOptional() @Type(() => Number) @IsNumber() @Min(0) height?: number; + @IsOptional() @Type(() => Number) @IsNumber() @Min(0) depth?: number; + @IsOptional() @Type(() => Number) @IsNumber() @Min(0) weight?: number; + @IsOptional() @IsString() @Length(0, 100) color?: string; + @IsOptional() @IsString() @Length(0, 160) material?: string; + @IsOptional() @Type(() => Number) @IsInt() @Min(0) deliveryDays?: number; + @IsOptional() @IsDateString() earliestDeliveryDate?: string; + @IsOptional() @IsDateString() expectedDeliveryDate?: string; + @IsOptional() @IsDateString() returnDeadline?: string; + @IsEnum(FurnitureAvailability) availability = FurnitureAvailability.Unknown; + @IsOptional() @IsBoolean() favorite = false; + @IsEnum(FurnitureOptionStatus) status = FurnitureOptionStatus.Idea; + @IsOptional() @IsString() @Length(0, 5000) notes?: string; + @IsOptional() @IsUUID() budgetCategoryId?: string; + @IsOptional() @IsBoolean() existingItem = false; + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0) + estimatedCurrentValue?: number; + @IsOptional() @Type(() => Number) @IsNumber() @Min(0) movingCost = 0; + @IsOptional() @Type(() => Number) @IsNumber() @Min(0) refurbishmentCost = 0; + @IsOptional() @IsString() @Length(0, 200) currentLocation?: string; + @IsOptional() @IsEnum(FurnitureCondition) condition?: FurnitureCondition; +} +export class UpdateFurnitureOptionDto extends CreateFurnitureOptionDto { + @Type(() => Number) @IsInt() @Min(1) version!: number; +} + +export class FurnitureOrderDto { + @Type(() => Number) @IsInt() @Min(1) version!: number; + @IsOptional() @IsString() @Length(0, 120) orderNumber?: string; + @IsOptional() @IsDateString() expectedDeliveryDate?: string; + @IsEnum(FurnitureDeliveryStatus) deliveryStatus = + FurnitureDeliveryStatus.Ordered; +} +export class FurnitureDeliveryDto { + @Type(() => Number) @IsInt() @Min(1) version!: number; + @Type(() => Number) @IsInt() @Min(0) deliveredQuantity!: number; + @IsOptional() @IsDateString() actualDeliveryDate?: string; +} + +export class CreateFurnitureScenarioDto { + @IsString() @Length(1, 160) name!: string; + @IsOptional() @IsString() @Length(0, 5000) description?: string; + @IsEnum(FurnitureScenarioType) type = FurnitureScenarioType.Custom; + @IsEnum(FurnitureScenarioStatus) status = FurnitureScenarioStatus.Draft; + @IsOptional() @IsBoolean() isDefault = false; + @IsOptional() @IsUUID() copyFromScenarioId?: string; + @IsOptional() + @IsEnum(FurnitureScenarioType) + automaticSelection?: FurnitureScenarioType; +} +export class UpdateFurnitureScenarioDto extends CreateFurnitureScenarioDto { + @Type(() => Number) @IsInt() @Min(1) version!: number; +} +export class FurnitureScenarioSelectionDto { + @IsUUID() requirementId!: string; + @IsUUID() optionId!: string; + @Type(() => Number) @IsInt() @Min(1) quantity = 1; + @IsOptional() @Type(() => Number) @IsNumber() @Min(0) priceOverride?: number; + @IsOptional() @IsString() @Length(0, 1000) note?: string; +} +export class UpdateFurnitureScenarioSelectionsDto { + @Type(() => Number) @IsInt() @Min(1) version!: number; + @IsArray() + @ArrayUnique( + (selection: FurnitureScenarioSelectionDto) => selection.requirementId, + ) + @Type(() => FurnitureScenarioSelectionDto) + selections!: FurnitureScenarioSelectionDto[]; +} +export class FurnitureDocumentLinkDto { + @IsUUID() documentId!: string; +} +export class FurnitureExpenseDto { + @IsUUID() budgetCategoryId!: string; + @IsOptional() @Type(() => Number) @IsNumber() @Min(0) amount?: number; + @IsString() @Length(1, 200) title!: string; + @IsOptional() @IsString() @Length(0, 200) supplier?: string; + @IsOptional() @IsDateString() dueDate?: string; + @IsString() @Length(1, 20) paymentStatus = 'planned'; +} diff --git a/apps/backend/src/renovation/dto/renovation.dto.ts b/apps/backend/src/renovation/dto/renovation.dto.ts new file mode 100644 index 0000000..8bfb428 --- /dev/null +++ b/apps/backend/src/renovation/dto/renovation.dto.ts @@ -0,0 +1,296 @@ +import { Transform, Type } from 'class-transformer'; +import type { TransformFnParams } from 'class-transformer'; +import { + ArrayUnique, + IsArray, + IsBoolean, + IsDateString, + IsEnum, + IsInt, + IsIn, + IsNumber, + IsOptional, + IsString, + IsUUID, + Length, + Max, + Min, +} from 'class-validator'; +import { + BuildingType, + RoomStatus, + TaskPriority, + TaskStatus, +} from '../entities/renovation.entities'; + +function commaSeparated(value: unknown): unknown { + return typeof value === 'string' ? value.split(',').filter(Boolean) : value; +} + +export class VersionDto { + @Type(() => Number) @IsInt() @Min(1) version!: number; +} + +export class CreateBuildingDto { + @IsString() @Length(1, 160) name!: string; + @IsOptional() @IsString() @Length(0, 4000) description?: string; + @IsEnum(BuildingType) type!: BuildingType; + @IsOptional() @Type(() => Number) @IsInt() sortOrder?: number; +} +export class UpdateBuildingDto extends CreateBuildingDto { + @Type(() => Number) @IsInt() @Min(1) version!: number; +} + +export class CreateFloorDto { + @IsUUID() buildingId!: string; + @IsString() @Length(1, 160) name!: string; + @IsOptional() @IsString() @Length(0, 4000) description?: string; + @IsOptional() @Type(() => Number) @IsInt() sortOrder?: number; +} +export class UpdateFloorDto extends CreateFloorDto { + @Type(() => Number) @IsInt() @Min(1) version!: number; +} + +export class CreateRoomDto { + @IsUUID() floorId!: string; + @IsString() @Length(1, 160) name!: string; + @IsOptional() @IsString() @Length(0, 4000) description?: string; + @IsString() @Length(1, 40) type!: string; + @IsEnum(RoomStatus) status: RoomStatus = RoomStatus.Unplanned; + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0) + @Max(100000) + area?: number; + @IsOptional() @Type(() => Number) @IsNumber() @Min(0) plannedBudget?: number; + @IsOptional() @Type(() => Number) @IsInt() sortOrder?: number; +} +export class UpdateRoomDto extends CreateRoomDto { + @Type(() => Number) @IsInt() @Min(1) version!: number; +} + +export class CreateTaskDto { + @IsOptional() @IsUUID() roomId?: string; + @IsString() @Length(1, 200) title!: string; + @IsOptional() @IsString() @Length(0, 10000) description?: string; + @IsString() @Length(1, 40) category!: string; + @IsEnum(TaskStatus) status: TaskStatus = TaskStatus.Planned; + @IsEnum(TaskPriority) priority: TaskPriority = TaskPriority.Normal; + @IsOptional() @IsUUID() assigneeUserId?: string; + @IsOptional() @IsDateString() plannedStartDate?: string; + @IsOptional() @IsDateString() dueDate?: string; + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0) + estimatedEffortHours?: number; + @IsOptional() @Type(() => Number) @IsNumber() @Min(0) estimatedCost?: number; + @IsOptional() @Type(() => Number) @IsNumber() @Min(0) actualCost?: number; + @IsOptional() @IsString() @Length(0, 1000) blockingReason?: string; + @IsOptional() @Type(() => Number) @IsNumber() @Min(0.01) weight?: number; +} +export class UpdateTaskDto extends CreateTaskDto { + @Type(() => Number) @IsInt() @Min(1) version!: number; +} + +export class CreateChecklistItemDto { + @IsString() @Length(1, 500) text!: string; + @IsOptional() @Type(() => Number) @IsInt() sortOrder?: number; +} +export class UpdateChecklistItemDto { + @IsOptional() @IsString() @Length(1, 500) text?: string; + @IsOptional() @IsBoolean() completed?: boolean; + @IsOptional() @Type(() => Number) @IsInt() sortOrder?: number; +} +export class CreateDependencyDto { + @IsUUID() predecessorTaskId!: string; +} +export class CreateCommentDto { + @IsString() @Length(1, 4000) text!: string; + + @IsOptional() + @IsArray() + @ArrayUnique() + @IsUUID('4', { each: true }) + mentionedUserIds?: string[]; +} + +export class CreateMilestoneDto { + @IsString() @Length(1, 200) title!: string; + @IsOptional() @IsString() @Length(0, 4000) description?: string; + @IsDateString() date!: string; + @IsString() @Length(1, 30) status!: string; + @IsString() @Length(1, 40) type!: string; + @IsOptional() @IsUUID() responsibleUserId?: string; +} +export class UpdateMilestoneDto extends CreateMilestoneDto { + @Type(() => Number) @IsInt() @Min(1) version!: number; +} + +export class CreateBudgetCategoryDto { + @IsString() @Length(1, 120) name!: string; + @Type(() => Number) @IsNumber() @Min(0) plannedBudget!: number; + @IsOptional() @Type(() => Number) @IsInt() sortOrder?: number; +} +export class UpdateBudgetCategoryDto extends CreateBudgetCategoryDto { + @Type(() => Number) @IsInt() @Min(1) version!: number; +} + +export class CreateExpenseDto { + @IsUUID() budgetCategoryId!: string; + @IsOptional() @IsUUID() roomId?: string; + @IsOptional() @IsUUID() taskId?: string; + @IsString() @Length(1, 200) title!: string; + @IsOptional() @IsString() @Length(0, 4000) description?: string; + @Type(() => Number) @IsNumber() @Min(0) amount!: number; + @IsOptional() @IsString() @Length(3, 3) currency?: string; + @IsDateString() expenseDate!: string; + @IsString() @Length(1, 20) paymentStatus!: string; + @IsOptional() @IsDateString() dueDate?: string; + @IsOptional() @IsString() @Length(0, 200) supplier?: string; + @IsOptional() @IsString() @Length(0, 100) invoiceNumber?: string; + @IsOptional() @IsUUID() documentId?: string; +} +export class UpdateExpenseDto extends CreateExpenseDto { + @Type(() => Number) @IsInt() @Min(1) version!: number; +} + +export class ApplyTemplateDto { + @IsOptional() @IsUUID() roomId?: string; + @IsOptional() @IsBoolean() confirmDuplicate?: boolean; +} + +export class DocumentMetadataDto { + @IsString() @Length(1, 200) title!: string; + @IsString() @Length(1, 40) type!: string; + @IsOptional() @IsString() @Length(0, 4000) description?: string; + @IsOptional() @IsUUID() roomId?: string; + @IsOptional() @IsUUID() taskId?: string; +} +export class UpdateDocumentMetadataDto extends DocumentMetadataDto { + @Type(() => Number) @IsInt() @Min(1) version!: number; +} + +export class FachListQueryDto { + @IsOptional() @Type(() => Number) @IsInt() @Min(1) page = 1; + @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) pageSize = 25; + @IsOptional() @IsString() @Length(0, 200) search?: string; + @IsOptional() @IsIn(['ASC', 'DESC']) sortDirection: 'ASC' | 'DESC' = 'ASC'; +} + +export class RoomListQueryDto extends FachListQueryDto { + @IsOptional() + @IsIn(['name', 'status', 'sortOrder', 'createdAt', 'updatedAt']) + sortBy = 'sortOrder'; + @IsOptional() @IsUUID() floorId?: string; + @IsOptional() @IsEnum(RoomStatus) status?: RoomStatus; +} + +export class TaskListQueryDto extends FachListQueryDto { + @IsOptional() + @IsIn([ + 'title', + 'priority', + 'status', + 'plannedStartDate', + 'dueDate', + 'roomId', + 'assigneeUserId', + 'createdAt', + 'updatedAt', + ]) + sortBy = 'dueDate'; + @IsOptional() + @Transform(({ value }: TransformFnParams) => commaSeparated(value)) + @IsArray() + @IsEnum(TaskStatus, { each: true }) + statuses?: TaskStatus[]; + @IsOptional() @IsEnum(TaskPriority) priority?: TaskPriority; + @IsOptional() @IsUUID() roomId?: string; + @IsOptional() @IsUUID() assigneeUserId?: string; + @IsOptional() @IsString() @Length(1, 40) category?: string; + @IsOptional() @IsDateString() startFrom?: string; + @IsOptional() @IsDateString() dueFrom?: string; + @IsOptional() @IsDateString() dueTo?: string; + @IsOptional() + @Transform(({ value }) => value === true || value === 'true') + @IsBoolean() + overdue?: boolean; + @IsOptional() + @Transform(({ value }) => value === true || value === 'true') + @IsBoolean() + blocked?: boolean; + @IsOptional() + @Transform(({ value }) => value === true || value === 'true') + @IsBoolean() + unassigned?: boolean; + @IsOptional() + @Transform(({ value }) => value === true || value === 'true') + @IsBoolean() + mine?: boolean; +} + +export class MilestoneListQueryDto extends FachListQueryDto { + @IsOptional() + @IsIn(['title', 'date', 'status', 'type', 'createdAt', 'updatedAt']) + sortBy = 'date'; + @IsOptional() @IsString() status?: string; + @IsOptional() @IsDateString() from?: string; + @IsOptional() @IsDateString() to?: string; +} + +export class ExpenseListQueryDto extends FachListQueryDto { + @IsOptional() + @IsIn([ + 'title', + 'amount', + 'expenseDate', + 'dueDate', + 'paymentStatus', + 'supplier', + 'createdAt', + ]) + sortBy = 'expenseDate'; + @IsOptional() @IsUUID() categoryId?: string; + @IsOptional() @IsUUID() roomId?: string; + @IsOptional() @IsUUID() taskId?: string; + @IsOptional() @IsString() paymentStatus?: string; + @IsOptional() @IsString() @Length(1, 200) supplier?: string; + @IsOptional() @IsUUID() createdByUserId?: string; + @IsOptional() @IsDateString() from?: string; + @IsOptional() @IsDateString() to?: string; + @IsOptional() @IsDateString() dueFrom?: string; + @IsOptional() @IsDateString() dueTo?: string; +} + +export class DocumentListQueryDto extends FachListQueryDto { + @IsOptional() + @IsIn([ + 'title', + 'type', + 'originalFilename', + 'uploadedAt', + 'fileSize', + 'createdAt', + ]) + sortBy = 'uploadedAt'; + @IsOptional() @IsString() type?: string; + @IsOptional() @IsUUID() roomId?: string; + @IsOptional() @IsUUID() taskId?: string; + @IsOptional() @IsUUID() uploadedByUserId?: string; + @IsOptional() @IsDateString() from?: string; + @IsOptional() @IsDateString() to?: string; +} + +export class CalendarQueryDto { + @IsDateString() from!: string; + @IsDateString() to!: string; + @IsOptional() @IsUUID() roomId?: string; + @IsOptional() @IsUUID() assigneeUserId?: string; + @IsOptional() + @Transform(({ value }: TransformFnParams) => commaSeparated(value)) + @IsArray() + @IsIn(['task_start', 'task_due', 'milestone', 'expense_due'], { each: true }) + types?: string[]; +} diff --git a/apps/backend/src/renovation/entities/furniture.entities.ts b/apps/backend/src/renovation/entities/furniture.entities.ts new file mode 100644 index 0000000..bdc2b88 --- /dev/null +++ b/apps/backend/src/renovation/entities/furniture.entities.ts @@ -0,0 +1,426 @@ +import { + Column, + CreateDateColumn, + DeleteDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + Unique, + UpdateDateColumn, + VersionColumn, +} from 'typeorm'; + +abstract class FurnitureVersionedEntity { + @PrimaryGeneratedColumn('uuid') id!: string; + @VersionColumn() version!: number; + @CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 3 }) + createdAt!: Date; + @UpdateDateColumn({ name: 'updated_at', type: 'datetime', precision: 3 }) + updatedAt!: Date; +} + +export enum FurnitureRequirementCategory { + Seating = 'seating', + Tables = 'tables', + Chairs = 'chairs', + Beds = 'beds', + Cabinets = 'cabinets', + Shelves = 'shelves', + Office = 'office', + Lighting = 'lighting', + Textiles = 'textiles', + Decoration = 'decoration', + Appliances = 'appliances', + Kitchen = 'kitchen', + Bathroom = 'bathroom', + Garden = 'garden', + Other = 'other', +} +export enum FurnitureRequirementPriority { + Optional = 'optional', + Low = 'low', + Normal = 'normal', + High = 'high', + Essential = 'essential', +} +export enum FurnitureRequirementStatus { + Identified = 'identified', + Research = 'research', + HasOptions = 'has_options', + DecisionOpen = 'decision_open', + Selected = 'selected', + Ordered = 'ordered', + PartiallyDelivered = 'partially_delivered', + Delivered = 'delivered', + Assembled = 'assembled', + Omitted = 'omitted', +} +export enum FurnitureOptionStatus { + Idea = 'idea', + Reviewing = 'reviewing', + Favorite = 'favorite', + Selected = 'selected', + Rejected = 'rejected', + Unavailable = 'unavailable', + Ordered = 'ordered', + Delivered = 'delivered', + Returned = 'returned', + Archived = 'archived', +} +export enum FurnitureAvailability { + Unknown = 'unknown', + Available = 'available', + Limited = 'limited', + Unavailable = 'unavailable', + Discontinued = 'discontinued', +} +export enum FurnitureCondition { + New = 'new', + VeryGood = 'very_good', + Good = 'good', + Used = 'used', + RepairRequired = 'repair_required', + Replace = 'replace', +} +export enum FurnitureDeliveryStatus { + NotOrdered = 'not_ordered', + Planned = 'planned', + Ordered = 'ordered', + Shipped = 'shipped', + PartiallyDelivered = 'partially_delivered', + Delivered = 'delivered', + Delayed = 'delayed', + Cancelled = 'cancelled', + Returned = 'returned', +} +export enum FurnitureScenarioType { + Custom = 'custom', + Budget = 'budget', + Preferred = 'preferred', + Premium = 'premium', + Existing = 'existing', + Other = 'other', +} +export enum FurnitureScenarioStatus { + Draft = 'draft', + Active = 'active', + Archived = 'archived', +} + +@Entity('furniture_requirements') +@Index('idx_furniture_requirements_project_room_sort', [ + 'projectId', + 'roomId', + 'sortOrder', +]) +export class FurnitureRequirementEntity extends FurnitureVersionedEntity { + @Column({ name: 'project_id', type: 'char', length: 36 }) projectId!: string; + @Column({ name: 'room_id', type: 'char', length: 36 }) roomId!: string; + @Column({ type: 'varchar', length: 160 }) name!: string; + @Column({ type: 'text', nullable: true }) description!: string | null; + @Column({ type: 'varchar', length: 30 }) + category!: FurnitureRequirementCategory; + @Column({ type: 'varchar', length: 20 }) + priority!: FurnitureRequirementPriority; + @Column({ + name: 'required_quantity', + type: 'int', + unsigned: true, + default: 1, + }) + requiredQuantity!: number; + @Column({ type: 'varchar', length: 30 }) status!: FurnitureRequirementStatus; + @Column({ + name: 'responsible_user_id', + type: 'char', + length: 36, + nullable: true, + }) + responsibleUserId!: string | null; + @Column({ + name: 'maximum_budget', + type: 'decimal', + precision: 13, + scale: 2, + nullable: true, + }) + maximumBudget!: string | null; + @Column({ name: 'sort_order', type: 'int', default: 0 }) sortOrder!: number; + @Column({ name: 'created_by_user_id', type: 'char', length: 36 }) + createdByUserId!: string; + @DeleteDateColumn({ + name: 'deleted_at', + type: 'datetime', + precision: 3, + nullable: true, + }) + deletedAt!: Date | null; +} + +@Entity('furniture_options') +@Index('idx_furniture_options_project_requirement', [ + 'projectId', + 'requirementId', +]) +export class FurnitureOptionEntity extends FurnitureVersionedEntity { + @Column({ name: 'project_id', type: 'char', length: 36 }) projectId!: string; + @Column({ name: 'requirement_id', type: 'char', length: 36 }) + requirementId!: string; + @Column({ type: 'varchar', length: 180 }) name!: string; + @Column({ type: 'varchar', length: 160, nullable: true }) manufacturer!: + | string + | null; + @Column({ type: 'varchar', length: 160, nullable: true }) model!: + | string + | null; + @Column({ type: 'text', nullable: true }) description!: string | null; + @Column({ type: 'varchar', length: 180, nullable: true }) retailer!: + | string + | null; + @Column({ + name: 'product_url', + type: 'varchar', + length: 1000, + nullable: true, + }) + productUrl!: string | null; + @Column({ + name: 'article_number', + type: 'varchar', + length: 120, + nullable: true, + }) + articleNumber!: string | null; + @Column({ name: 'unit_price', type: 'decimal', precision: 13, scale: 2 }) + unitPrice!: string; + @Column({ + name: 'original_price', + type: 'decimal', + precision: 13, + scale: 2, + nullable: true, + }) + originalPrice!: string | null; + @Column({ + name: 'shipping_cost', + type: 'decimal', + precision: 13, + scale: 2, + default: 0, + }) + shippingCost!: string; + @Column({ + name: 'additional_cost', + type: 'decimal', + precision: 13, + scale: 2, + default: 0, + }) + additionalCost!: string; + @Column({ type: 'decimal', precision: 13, scale: 2, default: 0 }) + discount!: string; + @Column({ name: 'total_price', type: 'decimal', precision: 13, scale: 2 }) + totalPrice!: string; + @Column({ type: 'char', length: 3, default: 'EUR' }) currency!: string; + @Column({ type: 'int', unsigned: true, default: 1 }) quantity!: number; + @Column({ type: 'decimal', precision: 9, scale: 2, nullable: true }) width!: + | string + | null; + @Column({ type: 'decimal', precision: 9, scale: 2, nullable: true }) height!: + | string + | null; + @Column({ type: 'decimal', precision: 9, scale: 2, nullable: true }) depth!: + | string + | null; + @Column({ type: 'decimal', precision: 9, scale: 2, nullable: true }) weight!: + | string + | null; + @Column({ type: 'varchar', length: 100, nullable: true }) color!: + | string + | null; + @Column({ type: 'varchar', length: 160, nullable: true }) material!: + | string + | null; + @Column({ + name: 'delivery_days', + type: 'int', + unsigned: true, + nullable: true, + }) + deliveryDays!: number | null; + @Column({ name: 'earliest_delivery_date', type: 'date', nullable: true }) + earliestDeliveryDate!: string | null; + @Column({ name: 'expected_delivery_date', type: 'date', nullable: true }) + expectedDeliveryDate!: string | null; + @Column({ name: 'return_deadline', type: 'date', nullable: true }) + returnDeadline!: string | null; + @Column({ type: 'varchar', length: 30 }) availability!: FurnitureAvailability; + @Column({ type: 'boolean', default: false }) favorite!: boolean; + @Column({ name: 'currently_selected', type: 'boolean', default: false }) + currentlySelected!: boolean; + @Column({ type: 'varchar', length: 30 }) status!: FurnitureOptionStatus; + @Column({ type: 'text', nullable: true }) notes!: string | null; + @Column({ + name: 'budget_category_id', + type: 'char', + length: 36, + nullable: true, + }) + budgetCategoryId!: string | null; + @Column({ name: 'existing_item', type: 'boolean', default: false }) + existingItem!: boolean; + @Column({ + name: 'estimated_current_value', + type: 'decimal', + precision: 13, + scale: 2, + nullable: true, + }) + estimatedCurrentValue!: string | null; + @Column({ + name: 'moving_cost', + type: 'decimal', + precision: 13, + scale: 2, + default: 0, + }) + movingCost!: string; + @Column({ + name: 'refurbishment_cost', + type: 'decimal', + precision: 13, + scale: 2, + default: 0, + }) + refurbishmentCost!: string; + @Column({ + name: 'current_location', + type: 'varchar', + length: 200, + nullable: true, + }) + currentLocation!: string | null; + @Column({ + name: 'item_condition', + type: 'varchar', + length: 30, + nullable: true, + }) + condition!: FurnitureCondition | null; + @Column({ + name: 'ordered_at', + type: 'datetime', + precision: 3, + nullable: true, + }) + orderedAt!: Date | null; + @Column({ + name: 'ordered_by_user_id', + type: 'char', + length: 36, + nullable: true, + }) + orderedByUserId!: string | null; + @Column({ + name: 'order_number', + type: 'varchar', + length: 120, + nullable: true, + }) + orderNumber!: string | null; + @Column({ name: 'actual_delivery_date', type: 'date', nullable: true }) + actualDeliveryDate!: string | null; + @Column({ + name: 'delivery_status', + type: 'varchar', + length: 30, + default: FurnitureDeliveryStatus.NotOrdered, + }) + deliveryStatus!: FurnitureDeliveryStatus; + @Column({ + name: 'delivered_quantity', + type: 'int', + unsigned: true, + default: 0, + }) + deliveredQuantity!: number; + @Column({ name: 'assembly_date', type: 'date', nullable: true }) + assemblyDate!: string | null; + @Column({ + name: 'assembled_by', + type: 'varchar', + length: 160, + nullable: true, + }) + assembledBy!: string | null; + @Column({ name: 'created_by_user_id', type: 'char', length: 36 }) + createdByUserId!: string; + @DeleteDateColumn({ + name: 'deleted_at', + type: 'datetime', + precision: 3, + nullable: true, + }) + deletedAt!: Date | null; +} + +@Entity('furniture_scenarios') +@Index('idx_furniture_scenarios_project_status', ['projectId', 'status']) +export class FurnitureScenarioEntity extends FurnitureVersionedEntity { + @Column({ name: 'project_id', type: 'char', length: 36 }) projectId!: string; + @Column({ type: 'varchar', length: 160 }) name!: string; + @Column({ type: 'text', nullable: true }) description!: string | null; + @Column({ type: 'varchar', length: 20 }) type!: FurnitureScenarioType; + @Column({ type: 'varchar', length: 20 }) status!: FurnitureScenarioStatus; + @Column({ name: 'is_default', type: 'boolean', default: false }) + isDefault!: boolean; + @Column({ name: 'created_by_user_id', type: 'char', length: 36 }) + createdByUserId!: string; + @DeleteDateColumn({ + name: 'deleted_at', + type: 'datetime', + precision: 3, + nullable: true, + }) + deletedAt!: Date | null; +} + +@Entity('furniture_scenario_selections') +@Unique('uq_furniture_scenario_requirement', ['scenarioId', 'requirementId']) +export class FurnitureScenarioSelectionEntity { + @PrimaryGeneratedColumn('uuid') id!: string; + @Column({ name: 'project_id', type: 'char', length: 36 }) projectId!: string; + @Column({ name: 'scenario_id', type: 'char', length: 36 }) + scenarioId!: string; + @Column({ name: 'requirement_id', type: 'char', length: 36 }) + requirementId!: string; + @Column({ name: 'option_id', type: 'char', length: 36 }) optionId!: string; + @Column({ type: 'int', unsigned: true, default: 1 }) quantity!: number; + @Column({ + name: 'price_override', + type: 'decimal', + precision: 13, + scale: 2, + nullable: true, + }) + priceOverride!: string | null; + @Column({ type: 'varchar', length: 1000, nullable: true }) note!: + | string + | null; + @CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 3 }) + createdAt!: Date; + @UpdateDateColumn({ name: 'updated_at', type: 'datetime', precision: 3 }) + updatedAt!: Date; +} + +@Entity('furniture_option_documents') +@Unique('uq_furniture_option_document', ['optionId', 'documentId']) +export class FurnitureOptionDocumentEntity { + @PrimaryGeneratedColumn('uuid') id!: string; + @Column({ name: 'project_id', type: 'char', length: 36 }) projectId!: string; + @Column({ name: 'option_id', type: 'char', length: 36 }) optionId!: string; + @Column({ name: 'document_id', type: 'char', length: 36 }) + documentId!: string; + @CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 3 }) + createdAt!: Date; +} diff --git a/apps/backend/src/renovation/entities/renovation.entities.ts b/apps/backend/src/renovation/entities/renovation.entities.ts new file mode 100644 index 0000000..a9ab137 --- /dev/null +++ b/apps/backend/src/renovation/entities/renovation.entities.ts @@ -0,0 +1,445 @@ +import { + Column, + CreateDateColumn, + DeleteDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + Unique, + UpdateDateColumn, + VersionColumn, +} from 'typeorm'; + +abstract class VersionedEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @VersionColumn() + version!: number; + + @CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 3 }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'datetime', precision: 3 }) + updatedAt!: Date; +} + +export enum BuildingType { + TerracedHouse = 'terraced_house', + DetachedHouse = 'detached_house', + Apartment = 'apartment', + SemiDetachedHouse = 'semi_detached_house', + MultiFamilyHouse = 'multi_family_house', + Other = 'other', +} + +@Entity('buildings') +@Index('idx_buildings_project_sort', ['projectId', 'sortOrder']) +export class BuildingEntity extends VersionedEntity { + @Column({ name: 'project_id', type: 'char', length: 36 }) projectId!: string; + @Column({ type: 'varchar', length: 160 }) name!: string; + @Column({ type: 'text', nullable: true }) description!: string | null; + @Column({ type: 'varchar', length: 40 }) type!: BuildingType; + @Column({ name: 'sort_order', type: 'int', default: 0 }) sortOrder!: number; +} + +@Entity('floors') +@Index('idx_floors_project_building_sort', [ + 'projectId', + 'buildingId', + 'sortOrder', +]) +export class FloorEntity extends VersionedEntity { + @Column({ name: 'project_id', type: 'char', length: 36 }) projectId!: string; + @Column({ name: 'building_id', type: 'char', length: 36 }) + buildingId!: string; + @Column({ type: 'varchar', length: 160 }) name!: string; + @Column({ type: 'text', nullable: true }) description!: string | null; + @Column({ name: 'sort_order', type: 'int', default: 0 }) sortOrder!: number; +} + +export enum RoomStatus { + Unplanned = 'unplanned', + Planning = 'planning', + Preparation = 'preparation', + Renovating = 'renovating', + Acceptance = 'acceptance', + Done = 'done', + Omitted = 'omitted', +} + +@Entity('rooms') +@Index('idx_rooms_project_floor_sort', ['projectId', 'floorId', 'sortOrder']) +export class RoomEntity extends VersionedEntity { + @Column({ name: 'project_id', type: 'char', length: 36 }) projectId!: string; + @Column({ name: 'floor_id', type: 'char', length: 36 }) floorId!: string; + @Column({ type: 'varchar', length: 160 }) name!: string; + @Column({ type: 'text', nullable: true }) description!: string | null; + @Column({ type: 'varchar', length: 40 }) type!: string; + @Column({ type: 'decimal', precision: 10, scale: 2, nullable: true }) area!: + | string + | null; + @Column({ type: 'varchar', length: 30 }) status!: RoomStatus; + @Column({ + name: 'planned_budget', + type: 'decimal', + precision: 13, + scale: 2, + nullable: true, + }) + plannedBudget!: string | null; + @Column({ name: 'sort_order', type: 'int', default: 0 }) sortOrder!: number; + @Column({ + name: 'preview_document_id', + type: 'char', + length: 36, + nullable: true, + }) + previewDocumentId!: string | null; + @DeleteDateColumn({ + name: 'deleted_at', + type: 'datetime', + precision: 3, + nullable: true, + }) + deletedAt!: Date | null; +} + +export enum TaskStatus { + Idea = 'idea', + Planned = 'planned', + Commissioned = 'commissioned', + InProgress = 'in_progress', + Blocked = 'blocked', + Acceptance = 'acceptance', + Done = 'done', + Omitted = 'omitted', +} +export enum TaskPriority { + Low = 'low', + Normal = 'normal', + High = 'high', + Critical = 'critical', +} + +@Entity('renovation_tasks') +@Index('idx_tasks_project_status_due', ['projectId', 'status', 'dueDate']) +export class RenovationTaskEntity extends VersionedEntity { + @Column({ name: 'project_id', type: 'char', length: 36 }) projectId!: string; + @Column({ name: 'room_id', type: 'char', length: 36, nullable: true }) + roomId!: string | null; + @Column({ type: 'varchar', length: 200 }) title!: string; + @Column({ type: 'text', nullable: true }) description!: string | null; + @Column({ type: 'varchar', length: 40 }) category!: string; + @Column({ type: 'varchar', length: 30 }) status!: TaskStatus; + @Column({ type: 'varchar', length: 20 }) priority!: TaskPriority; + @Column({ + name: 'assignee_user_id', + type: 'char', + length: 36, + nullable: true, + }) + assigneeUserId!: string | null; + @Column({ name: 'planned_start_date', type: 'date', nullable: true }) + plannedStartDate!: string | null; + @Column({ name: 'due_date', type: 'date', nullable: true }) dueDate!: + | string + | null; + @Column({ + name: 'completed_at', + type: 'datetime', + precision: 3, + nullable: true, + }) + completedAt!: Date | null; + @Column({ + name: 'estimated_effort_hours', + type: 'decimal', + precision: 8, + scale: 2, + nullable: true, + }) + estimatedEffortHours!: string | null; + @Column({ + name: 'estimated_cost', + type: 'decimal', + precision: 13, + scale: 2, + nullable: true, + }) + estimatedCost!: string | null; + @Column({ + name: 'actual_cost', + type: 'decimal', + precision: 13, + scale: 2, + nullable: true, + }) + actualCost!: string | null; + @Column({ + name: 'blocking_reason', + type: 'varchar', + length: 1000, + nullable: true, + }) + blockingReason!: string | null; + @Column({ name: 'sort_order', type: 'int', default: 0 }) sortOrder!: number; + @Column({ type: 'decimal', precision: 6, scale: 2, default: 1 }) + weight!: string; + @Column({ name: 'created_by_user_id', type: 'char', length: 36 }) + createdByUserId!: string; + @DeleteDateColumn({ + name: 'deleted_at', + type: 'datetime', + precision: 3, + nullable: true, + }) + deletedAt!: Date | null; +} + +@Entity('task_checklist_items') +@Index('idx_checklist_task_sort', ['taskId', 'sortOrder']) +export class ChecklistItemEntity { + @PrimaryGeneratedColumn('uuid') id!: string; + @Column({ name: 'project_id', type: 'char', length: 36 }) projectId!: string; + @Column({ name: 'task_id', type: 'char', length: 36 }) taskId!: string; + @Column({ type: 'varchar', length: 500 }) text!: string; + @Column({ type: 'boolean', default: false }) completed!: boolean; + @Column({ name: 'sort_order', type: 'int', default: 0 }) sortOrder!: number; + @Column({ + name: 'completed_by_user_id', + type: 'char', + length: 36, + nullable: true, + }) + completedByUserId!: string | null; + @Column({ + name: 'completed_at', + type: 'datetime', + precision: 3, + nullable: true, + }) + completedAt!: Date | null; + @CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 3 }) + createdAt!: Date; + @UpdateDateColumn({ name: 'updated_at', type: 'datetime', precision: 3 }) + updatedAt!: Date; +} + +@Entity('task_dependencies') +@Unique('uq_task_dependency', ['predecessorTaskId', 'successorTaskId']) +export class TaskDependencyEntity { + @PrimaryGeneratedColumn('uuid') id!: string; + @Column({ name: 'project_id', type: 'char', length: 36 }) projectId!: string; + @Column({ name: 'predecessor_task_id', type: 'char', length: 36 }) + predecessorTaskId!: string; + @Column({ name: 'successor_task_id', type: 'char', length: 36 }) + successorTaskId!: string; + @Column({ type: 'varchar', length: 30, default: 'finish_to_start' }) + type!: string; + @CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 3 }) + createdAt!: Date; +} + +@Entity('task_comments') +@Index('idx_comments_project_task_created', [ + 'projectId', + 'taskId', + 'createdAt', +]) +export class TaskCommentEntity { + @PrimaryGeneratedColumn('uuid') id!: string; + @Column({ name: 'project_id', type: 'char', length: 36 }) projectId!: string; + @Column({ name: 'task_id', type: 'char', length: 36 }) taskId!: string; + @Column({ name: 'author_user_id', type: 'char', length: 36 }) + authorUserId!: string; + @Column({ type: 'varchar', length: 4000 }) text!: string; + @CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 3 }) + createdAt!: Date; + @UpdateDateColumn({ name: 'updated_at', type: 'datetime', precision: 3 }) + updatedAt!: Date; + @DeleteDateColumn({ + name: 'deleted_at', + type: 'datetime', + precision: 3, + nullable: true, + }) + deletedAt!: Date | null; +} + +@Entity('milestones') +@Index('idx_milestones_project_date', ['projectId', 'date']) +export class MilestoneEntity extends VersionedEntity { + @Column({ name: 'project_id', type: 'char', length: 36 }) projectId!: string; + @Column({ type: 'varchar', length: 200 }) title!: string; + @Column({ type: 'text', nullable: true }) description!: string | null; + @Column({ type: 'date' }) date!: string; + @Column({ type: 'varchar', length: 30 }) status!: string; + @Column({ type: 'varchar', length: 40 }) type!: string; + @Column({ + name: 'responsible_user_id', + type: 'char', + length: 36, + nullable: true, + }) + responsibleUserId!: string | null; +} + +@Entity('budget_categories') +@Index('idx_budget_categories_project_sort', ['projectId', 'sortOrder']) +export class BudgetCategoryEntity extends VersionedEntity { + @Column({ name: 'project_id', type: 'char', length: 36 }) projectId!: string; + @Column({ type: 'varchar', length: 120 }) name!: string; + @Column({ name: 'planned_budget', type: 'decimal', precision: 13, scale: 2 }) + plannedBudget!: string; + @Column({ name: 'sort_order', type: 'int', default: 0 }) sortOrder!: number; + @Column({ type: 'boolean', default: true }) active!: boolean; +} + +@Entity('expenses') +@Index('idx_expenses_project_status_date', [ + 'projectId', + 'paymentStatus', + 'expenseDate', +]) +export class ExpenseEntity extends VersionedEntity { + @Column({ name: 'project_id', type: 'char', length: 36 }) projectId!: string; + @Column({ name: 'budget_category_id', type: 'char', length: 36 }) + budgetCategoryId!: string; + @Column({ name: 'room_id', type: 'char', length: 36, nullable: true }) + roomId!: string | null; + @Column({ name: 'task_id', type: 'char', length: 36, nullable: true }) + taskId!: string | null; + @Column({ type: 'varchar', length: 200 }) title!: string; + @Column({ type: 'text', nullable: true }) description!: string | null; + @Column({ type: 'decimal', precision: 13, scale: 2 }) amount!: string; + @Column({ type: 'char', length: 3, default: 'EUR' }) currency!: string; + @Column({ name: 'expense_date', type: 'date' }) expenseDate!: string; + @Column({ name: 'payment_status', type: 'varchar', length: 20 }) + paymentStatus!: string; + @Column({ name: 'due_date', type: 'date', nullable: true }) dueDate!: + | string + | null; + @Column({ type: 'varchar', length: 200, nullable: true }) supplier!: + | string + | null; + @Column({ + name: 'invoice_number', + type: 'varchar', + length: 100, + nullable: true, + }) + invoiceNumber!: string | null; + @Column({ name: 'document_id', type: 'char', length: 36, nullable: true }) + documentId!: string | null; + @Column({ + name: 'furniture_requirement_id', + type: 'char', + length: 36, + nullable: true, + }) + furnitureRequirementId!: string | null; + @Column({ + name: 'furniture_option_id', + type: 'char', + length: 36, + nullable: true, + }) + furnitureOptionId!: string | null; + @Column({ name: 'created_by_user_id', type: 'char', length: 36 }) + createdByUserId!: string; +} + +@Entity('project_documents') +@Index('idx_documents_project_created', ['projectId', 'createdAt']) +export class ProjectDocumentEntity extends VersionedEntity { + @Column({ name: 'project_id', type: 'char', length: 36 }) projectId!: string; + @Column({ name: 'room_id', type: 'char', length: 36, nullable: true }) + roomId!: string | null; + @Column({ name: 'task_id', type: 'char', length: 36, nullable: true }) + taskId!: string | null; + @Column({ type: 'varchar', length: 40 }) type!: string; + @Column({ type: 'varchar', length: 200 }) title!: string; + @Column({ type: 'text', nullable: true }) description!: string | null; + @Column({ name: 'original_filename', type: 'varchar', length: 255 }) + originalFilename!: string; + @Column({ name: 'storage_name', type: 'varchar', length: 100 }) + storageName!: string; + @Column({ name: 'mime_type', type: 'varchar', length: 100 }) + mimeType!: string; + @Column({ name: 'file_size', type: 'int', unsigned: true }) fileSize!: number; + @Column({ name: 'storage_reference', type: 'varchar', length: 500 }) + storageReference!: string; + @Column({ name: 'uploaded_by_user_id', type: 'char', length: 36 }) + uploadedByUserId!: string; + @Column({ name: 'uploaded_at', type: 'datetime', precision: 3 }) + uploadedAt!: Date; + @DeleteDateColumn({ + name: 'deleted_at', + type: 'datetime', + precision: 3, + nullable: true, + }) + deletedAt!: Date | null; +} + +@Entity('task_comment_mentions') +@Unique('uq_task_comment_mentions_comment_user', ['commentId', 'userId']) +@Index('idx_task_comment_mentions_project_user', ['projectId', 'userId']) +export class TaskCommentMentionEntity { + @PrimaryGeneratedColumn('uuid') id!: string; + @Column({ name: 'project_id', type: 'char', length: 36 }) projectId!: string; + @Column({ name: 'comment_id', type: 'char', length: 36 }) commentId!: string; + @Column({ name: 'user_id', type: 'char', length: 36 }) userId!: string; + @Column({ name: 'notification_created', type: 'boolean', default: false }) + notificationCreated!: boolean; + @CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 3 }) + createdAt!: Date; +} + +@Entity('reminder_deliveries') +@Unique('uq_reminder_deliveries_dedupe_key', ['dedupeKey']) +@Index('idx_reminder_deliveries_project_entity', [ + 'projectId', + 'entityType', + 'entityId', +]) +export class ReminderDeliveryEntity { + @PrimaryGeneratedColumn('uuid') id!: string; + @Column({ name: 'project_id', type: 'char', length: 36 }) projectId!: string; + @Column({ name: 'user_id', type: 'char', length: 36 }) userId!: string; + @Column({ name: 'entity_type', type: 'varchar', length: 40 }) + entityType!: string; + @Column({ name: 'entity_id', type: 'char', length: 36 }) entityId!: string; + @Column({ name: 'reminder_type', type: 'varchar', length: 50 }) + reminderType!: string; + @Column({ name: 'reference_date', type: 'date' }) referenceDate!: string; + @Column({ name: 'dedupe_key', type: 'varchar', length: 255 }) + dedupeKey!: string; + @CreateDateColumn({ name: 'sent_at', type: 'datetime', precision: 3 }) + sentAt!: Date; +} + +@Entity('applied_project_templates') +@Unique('uq_applied_templates_project_template_target', [ + 'projectId', + 'templateId', + 'targetKey', +]) +export class AppliedProjectTemplateEntity { + @PrimaryGeneratedColumn('uuid') id!: string; + @Column({ name: 'project_id', type: 'char', length: 36 }) projectId!: string; + @Column({ name: 'template_id', type: 'varchar', length: 80 }) + templateId!: string; + @Column({ + name: 'target_key', + type: 'varchar', + length: 80, + default: 'project', + }) + targetKey!: string; + @Column({ name: 'applied_by_user_id', type: 'char', length: 36 }) + appliedByUserId!: string; + @CreateDateColumn({ name: 'applied_at', type: 'datetime', precision: 3 }) + appliedAt!: Date; +} diff --git a/apps/backend/src/renovation/furniture-pricing.ts b/apps/backend/src/renovation/furniture-pricing.ts new file mode 100644 index 0000000..8d2b6c8 --- /dev/null +++ b/apps/backend/src/renovation/furniture-pricing.ts @@ -0,0 +1,38 @@ +export interface FurniturePriceParts { + unitPrice: string | number; + quantity: number; + shippingCost?: string | number | null; + additionalCost?: string | number | null; + discount?: string | number | null; + existingItem?: boolean; + movingCost?: string | number | null; + refurbishmentCost?: string | number | null; +} + +function cents(value: string | number | null | undefined): number { + if (value === null || value === undefined || value === '') return 0; + const normalized = String(value).trim(); + if (!/^\d+(\.\d{1,2})?$/.test(normalized)) throw new Error('INVALID_MONEY'); + const [euros, fraction = ''] = normalized.split('.'); + return Number(euros) * 100 + Number(fraction.padEnd(2, '0')); +} + +export function calculateFurnitureTotal(parts: FurniturePriceParts): string { + const acquisition = parts.existingItem + ? 0 + : cents(parts.unitPrice) * parts.quantity; + const total = + acquisition + + cents(parts.shippingCost) + + cents(parts.additionalCost) + + cents(parts.movingCost) + + cents(parts.refurbishmentCost) - + cents(parts.discount); + if (total < 0) throw new Error('DISCOUNT_EXCEEDS_COST'); + return `${Math.floor(total / 100)}.${String(total % 100).padStart(2, '0')}`; +} + +export function sumMoney(values: readonly (string | number)[]): string { + const total = values.reduce((sum, value) => sum + cents(value), 0); + return `${Math.floor(total / 100)}.${String(total % 100).padStart(2, '0')}`; +} diff --git a/apps/backend/src/renovation/furniture.controller.ts b/apps/backend/src/renovation/furniture.controller.ts new file mode 100644 index 0000000..96a9cdd --- /dev/null +++ b/apps/backend/src/renovation/furniture.controller.ts @@ -0,0 +1,299 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Put, + Query, + Req, +} from '@nestjs/common'; +import type { AuthenticatedRequest } from '../auth/authenticated-request'; +import { RequirePermissions } from '../auth/guards/require-permissions.decorator'; +import { ApiError } from '../common/errors/api-error'; +import { ErrorCode } from '../common/errors/error-codes'; +import { Permission } from '../roles/permissions'; +import { + CreateFurnitureOptionDto, + CreateFurnitureRequirementDto, + CreateFurnitureScenarioDto, + FurnitureDeliveryDto, + FurnitureDocumentLinkDto, + FurnitureExpenseDto, + FurnitureListQueryDto, + FurnitureOptionListQueryDto, + FurnitureOrderDto, + UpdateFurnitureOptionDto, + UpdateFurnitureRequirementDto, + UpdateFurnitureScenarioDto, + UpdateFurnitureScenarioSelectionsDto, +} from './dto/furniture.dto'; +import { FurnitureService } from './furniture.service'; + +@Controller('projects/:projectId') +@RequirePermissions(Permission.ProjectsUse) +export class FurnitureController { + constructor(private readonly furniture: FurnitureService) {} + private user(request: AuthenticatedRequest) { + if (!request.user) + throw new ApiError( + ErrorCode.Unauthorized, + 'Bitte melden Sie sich an.', + 401, + ); + return request.user.id; + } + + @Get('furniture-requirements') requirements( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Query() query: FurnitureListQueryDto, + ) { + return this.furniture.requirements(projectId, this.user(request), query); + } + @Post('furniture-requirements') createRequirement( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Body() dto: CreateFurnitureRequirementDto, + ) { + return this.furniture.createRequirement(projectId, this.user(request), dto); + } + @Get('furniture-requirements/:requirementId') requirement( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Param('requirementId') id: string, + ) { + return this.furniture.requirement(projectId, id, this.user(request)); + } + @Patch('furniture-requirements/:requirementId') updateRequirement( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Param('requirementId') id: string, + @Body() dto: UpdateFurnitureRequirementDto, + ) { + return this.furniture.updateRequirement( + projectId, + id, + this.user(request), + dto, + ); + } + @Delete('furniture-requirements/:requirementId') deleteRequirement( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Param('requirementId') id: string, + ) { + return this.furniture.deleteRequirement(projectId, id, this.user(request)); + } + @Post('furniture-requirements/:requirementId/copy') copyRequirement( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Param('requirementId') id: string, + ) { + return this.furniture.copyRequirement(projectId, id, this.user(request)); + } + @Get('furniture-requirements/:requirementId/options') options( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Param('requirementId') requirementId: string, + ) { + return this.furniture.options(projectId, requirementId, this.user(request)); + } + @Post('furniture-requirements/:requirementId/options') createOption( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Param('requirementId') requirementId: string, + @Body() dto: CreateFurnitureOptionDto, + ) { + return this.furniture.createOption( + projectId, + requirementId, + this.user(request), + dto, + ); + } + @Get('furniture-requirements/:requirementId/compare') compareOptions( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Param('requirementId') requirementId: string, + ) { + return this.furniture.compareOptions( + projectId, + requirementId, + this.user(request), + ); + } + @Get('furniture-options') projectOptions( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Query() query: FurnitureOptionListQueryDto, + ) { + return this.furniture.projectOptions(projectId, this.user(request), query); + } + @Get('furniture-options/:optionId') option( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Param('optionId') id: string, + ) { + return this.furniture.option(projectId, id, this.user(request)); + } + @Patch('furniture-options/:optionId') updateOption( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Param('optionId') id: string, + @Body() dto: UpdateFurnitureOptionDto, + ) { + return this.furniture.updateOption(projectId, id, this.user(request), dto); + } + @Delete('furniture-options/:optionId') deleteOption( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Param('optionId') id: string, + ) { + return this.furniture.deleteOption(projectId, id, this.user(request)); + } + @Post('furniture-options/:optionId/copy') copyOption( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Param('optionId') id: string, + ) { + return this.furniture.copyOption(projectId, id, this.user(request)); + } + @Post('furniture-options/:optionId/favorite') favorite( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Param('optionId') id: string, + ) { + return this.furniture.setFavorite(projectId, id, this.user(request)); + } + @Post('furniture-options/:optionId/select') select( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Param('optionId') id: string, + @Body('version') version: number, + ) { + return this.furniture.select(projectId, id, this.user(request), version); + } + @Post('furniture-options/:optionId/order') order( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Param('optionId') id: string, + @Body() dto: FurnitureOrderDto, + ) { + return this.furniture.order(projectId, id, this.user(request), dto); + } + @Post('furniture-options/:optionId/deliver') deliver( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Param('optionId') id: string, + @Body() dto: FurnitureDeliveryDto, + ) { + return this.furniture.deliver(projectId, id, this.user(request), dto); + } + @Post('furniture-options/:optionId/documents') linkDocument( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Param('optionId') id: string, + @Body() dto: FurnitureDocumentLinkDto, + ) { + return this.furniture.linkDocument(projectId, id, this.user(request), dto); + } + @Post('furniture-options/:optionId/expenses') createExpense( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Param('optionId') id: string, + @Body() dto: FurnitureExpenseDto, + ) { + return this.furniture.createExpense(projectId, id, this.user(request), dto); + } + @Get('furniture-scenarios') scenarios( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + ) { + return this.furniture.scenarios(projectId, this.user(request)); + } + @Post('furniture-scenarios') createScenario( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Body() dto: CreateFurnitureScenarioDto, + ) { + return this.furniture.createScenario(projectId, this.user(request), dto); + } + @Get('furniture-scenarios/compare') compareScenarios( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Query('ids') ids?: string, + ) { + return this.furniture.compareScenarios( + projectId, + this.user(request), + ids?.split(',').filter(Boolean), + ); + } + @Get('furniture-scenarios/:scenarioId') scenario( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Param('scenarioId') id: string, + ) { + return this.furniture.scenario(projectId, id, this.user(request)); + } + @Patch('furniture-scenarios/:scenarioId') updateScenario( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Param('scenarioId') id: string, + @Body() dto: UpdateFurnitureScenarioDto, + ) { + return this.furniture.updateScenario( + projectId, + id, + this.user(request), + dto, + ); + } + @Delete('furniture-scenarios/:scenarioId') deleteScenario( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Param('scenarioId') id: string, + ) { + return this.furniture.deleteScenario(projectId, id, this.user(request)); + } + @Post('furniture-scenarios/:scenarioId/copy') copyScenario( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Param('scenarioId') id: string, + @Body() dto: CreateFurnitureScenarioDto, + ) { + return this.furniture.createScenario(projectId, this.user(request), { + ...dto, + copyFromScenarioId: id, + }); + } + @Put('furniture-scenarios/:scenarioId/selections') selections( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Param('scenarioId') id: string, + @Body() dto: UpdateFurnitureScenarioSelectionsDto, + ) { + return this.furniture.updateSelections( + projectId, + id, + this.user(request), + dto, + ); + } + @Get('furniture-summary') summary( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + ) { + return this.furniture.summary(projectId, this.user(request)); + } + @Get('rooms/:roomId/furniture-summary') roomSummary( + @Req() request: AuthenticatedRequest, + @Param('projectId') projectId: string, + @Param('roomId') roomId: string, + ) { + return this.furniture.summary(projectId, this.user(request), roomId); + } +} diff --git a/apps/backend/src/renovation/furniture.repository.ts b/apps/backend/src/renovation/furniture.repository.ts new file mode 100644 index 0000000..3faf14b --- /dev/null +++ b/apps/backend/src/renovation/furniture.repository.ts @@ -0,0 +1,265 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { IsNull, Repository } from 'typeorm'; +import type { + FurnitureListQueryDto, + FurnitureOptionListQueryDto, +} from './dto/furniture.dto'; +import { + FurnitureOptionDocumentEntity, + FurnitureOptionEntity, + FurnitureRequirementEntity, + FurnitureScenarioEntity, + FurnitureScenarioSelectionEntity, +} from './entities/furniture.entities'; + +@Injectable() +export class FurnitureRepository { + constructor( + @InjectRepository(FurnitureRequirementEntity) + readonly requirements: Repository, + @InjectRepository(FurnitureOptionEntity) + readonly options: Repository, + @InjectRepository(FurnitureScenarioEntity) + readonly scenarios: Repository, + @InjectRepository(FurnitureScenarioSelectionEntity) + readonly selections: Repository, + @InjectRepository(FurnitureOptionDocumentEntity) + readonly optionDocuments: Repository, + ) {} + + async pageRequirements(projectId: string, query: FurnitureListQueryDto) { + const allowedSort: Record = { + name: 'requirement.name', + room: 'room.name', + category: 'requirement.category', + priority: 'requirement.priority', + status: 'requirement.status', + updatedAt: 'requirement.updatedAt', + sortOrder: 'requirement.sortOrder', + price: 'selectedOption.totalPrice', + deliveryDate: 'selectedOption.expectedDeliveryDate', + }; + const sort = allowedSort[query.sortBy]; + if (!sort) throw new Error('INVALID_FURNITURE_SORT'); + const qb = this.requirements + .createQueryBuilder('requirement') + .leftJoinAndMapMany( + 'requirement.options', + FurnitureOptionEntity, + 'option', + 'option.requirementId = requirement.id AND option.deletedAt IS NULL', + ) + .leftJoin('rooms', 'room', 'room.id = requirement.roomId') + .leftJoin( + FurnitureOptionEntity, + 'selectedOption', + 'selectedOption.requirementId = requirement.id AND selectedOption.currentlySelected = true AND selectedOption.deletedAt IS NULL', + ) + .where('requirement.projectId = :projectId', { projectId }) + .andWhere('requirement.deletedAt IS NULL'); + if (query.search) + qb.andWhere( + '(requirement.name LIKE :search OR requirement.description LIKE :search OR option.name LIKE :search OR option.retailer LIKE :search)', + { search: `%${query.search}%` }, + ); + if (query.roomId) + qb.andWhere('requirement.roomId = :roomId', { roomId: query.roomId }); + if (query.category) + qb.andWhere('requirement.category = :category', { + category: query.category, + }); + if (query.status) + qb.andWhere('requirement.status = :status', { status: query.status }); + if (query.priority) + qb.andWhere('requirement.priority = :priority', { + priority: query.priority, + }); + if (query.responsibleUserId) + qb.andWhere('requirement.responsibleUserId = :responsibleUserId', { + responsibleUserId: query.responsibleUserId, + }); + if (query.withoutOption) qb.andWhere('option.id IS NULL'); + if (query.selected) qb.andWhere('option.currentlySelected = true'); + if (query.favorite) qb.andWhere('option.favorite = true'); + if (query.ordered) + qb.andWhere( + "option.deliveryStatus IN ('ordered','shipped','partially_delivered','delayed')", + ); + if (query.delivered) qb.andWhere("option.deliveryStatus = 'delivered'"); + if (query.delayed) + qb.andWhere( + "option.expectedDeliveryDate < CURRENT_DATE() AND option.deliveryStatus NOT IN ('delivered','cancelled','returned')", + ); + if (query.overBudget) + qb.andWhere( + 'requirement.maximumBudget IS NOT NULL AND selectedOption.totalPrice > requirement.maximumBudget', + ); + if (query.openDecision) qb.andWhere('selectedOption.id IS NULL'); + const totalItems = await qb + .clone() + .select('requirement.id') + .distinct(true) + .getCount(); + const ids = await qb + .clone() + .select('requirement.id', 'id') + .addSelect(sort, 'sortValue') + .distinct(true) + .orderBy(sort, query.sortDirection) + .addOrderBy('requirement.id', 'ASC') + .offset((query.page - 1) * query.pageSize) + .limit(query.pageSize) + .getRawMany<{ id: string }>(); + const items = ids.length + ? await this.requirements.find({ + where: ids.map(({ id }) => ({ id, projectId, deletedAt: IsNull() })), + order: { sortOrder: 'ASC', name: 'ASC' }, + }) + : []; + const optionRows = ids.length + ? await this.options.find({ + where: ids.map(({ id }) => ({ + projectId, + requirementId: id, + deletedAt: IsNull(), + })), + order: { totalPrice: 'ASC' }, + }) + : []; + return { + items: items.map((item) => { + const options = optionRows.filter( + (option) => option.requirementId === item.id, + ); + const favoriteOption = + options.find((option) => option.favorite) ?? null; + const selectedOption = + options.find((option) => option.currentlySelected) ?? null; + const cheapestOption = options[0] ?? null; + const budgetVariance = + selectedOption && item.maximumBudget + ? ( + Number(selectedOption.totalPrice) - Number(item.maximumBudget) + ).toFixed(2) + : null; + return { + ...item, + options, + optionCount: options.length, + cheapestOption, + favoriteOption, + selectedOption, + selectedTotalPrice: selectedOption?.totalPrice ?? null, + budgetVariance, + orderStatus: selectedOption?.deliveryStatus ?? null, + expectedDelivery: selectedOption?.expectedDeliveryDate ?? null, + hasOpenDecision: !selectedOption, + isOverBudget: budgetVariance !== null && Number(budgetVariance) > 0, + }; + }), + page: query.page, + pageSize: query.pageSize, + totalItems, + totalPages: Math.ceil(totalItems / query.pageSize), + }; + } + + async pageOptions(projectId: string, query: FurnitureOptionListQueryDto) { + const allowedSort: Record = { + name: 'option.name', + retailer: 'option.retailer', + unitPrice: 'option.unitPrice', + totalPrice: 'option.totalPrice', + status: 'option.status', + availability: 'option.availability', + expectedDeliveryDate: 'option.expectedDeliveryDate', + updatedAt: 'option.updatedAt', + room: 'room.name', + requirement: 'requirement.name', + }; + const sort = allowedSort[query.sortBy]; + if (!sort) throw new Error('INVALID_FURNITURE_OPTION_SORT'); + const qb = this.options + .createQueryBuilder('option') + .innerJoin( + FurnitureRequirementEntity, + 'requirement', + 'requirement.id = option.requirementId AND requirement.deletedAt IS NULL', + ) + .leftJoin('rooms', 'room', 'room.id = requirement.roomId') + .where('option.projectId = :projectId AND option.deletedAt IS NULL', { + projectId, + }); + if (query.search) + qb.andWhere( + '(option.name LIKE :search OR option.manufacturer LIKE :search OR option.retailer LIKE :search OR requirement.name LIKE :search)', + { search: `%${query.search}%` }, + ); + if (query.roomId) + qb.andWhere('requirement.roomId = :roomId', { roomId: query.roomId }); + if (query.requirementId) + qb.andWhere('option.requirementId = :requirementId', { + requirementId: query.requirementId, + }); + if (query.status) + qb.andWhere('option.status = :status', { status: query.status }); + if (query.availability) + qb.andWhere('option.availability = :availability', { + availability: query.availability, + }); + if (query.favorite) qb.andWhere('option.favorite = true'); + if (query.selected) qb.andWhere('option.currentlySelected = true'); + if (query.ordered) + qb.andWhere( + "option.deliveryStatus IN ('ordered','shipped','partially_delivered','delayed')", + ); + if (query.delayed) + qb.andWhere( + "option.expectedDeliveryDate < CURRENT_DATE() AND option.deliveryStatus NOT IN ('delivered','cancelled','returned')", + ); + const totalItems = await qb.getCount(); + const raw = await qb + .select(['option', 'requirement.name', 'requirement.roomId', 'room.name']) + .orderBy(sort, query.sortDirection) + .addOrderBy('option.id', 'ASC') + .offset((query.page - 1) * query.pageSize) + .limit(query.pageSize) + .getRawAndEntities<{ + requirement_name?: string; + requirement_room_id?: string; + room_name?: string; + }>(); + return { + items: raw.entities.map((option, index) => ({ + ...option, + requirementName: raw.raw[index]?.requirement_name, + roomId: raw.raw[index]?.requirement_room_id, + roomName: raw.raw[index]?.room_name, + })), + page: query.page, + pageSize: query.pageSize, + totalItems, + totalPages: Math.ceil(totalItems / query.pageSize), + }; + } + + listOptions(projectId: string, requirementId: string) { + return this.options.find({ + where: { projectId, requirementId, deletedAt: IsNull() }, + order: { totalPrice: 'ASC' }, + }); + } + listRequirements(projectId: string, roomId?: string) { + return this.requirements.find({ + where: { projectId, ...(roomId ? { roomId } : {}), deletedAt: IsNull() }, + order: { sortOrder: 'ASC', name: 'ASC' }, + }); + } + listScenarios(projectId: string) { + return this.scenarios.find({ + where: { projectId, deletedAt: IsNull() }, + order: { isDefault: 'DESC', createdAt: 'ASC' }, + }); + } +} diff --git a/apps/backend/src/renovation/furniture.service.ts b/apps/backend/src/renovation/furniture.service.ts new file mode 100644 index 0000000..7b60c22 --- /dev/null +++ b/apps/backend/src/renovation/furniture.service.ts @@ -0,0 +1,1332 @@ +import { Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource, EntityManager, In, IsNull } from 'typeorm'; +import { ApiError } from '../common/errors/api-error'; +import { ErrorCode } from '../common/errors/error-codes'; +import { NotificationsService } from '../notifications/notifications.service'; +import { ProjectActivityEntity } from '../projects/entities/project-activity.entity'; +import { ProjectAccessService } from '../projects/project-access.service'; +import { ProjectsRepository } from '../projects/repositories/projects.repository'; +import { + CreateFurnitureOptionDto, + CreateFurnitureRequirementDto, + CreateFurnitureScenarioDto, + FurnitureDeliveryDto, + FurnitureDocumentLinkDto, + FurnitureExpenseDto, + FurnitureListQueryDto, + FurnitureOrderDto, + FurnitureOptionListQueryDto, + UpdateFurnitureOptionDto, + UpdateFurnitureRequirementDto, + UpdateFurnitureScenarioDto, + UpdateFurnitureScenarioSelectionsDto, +} from './dto/furniture.dto'; +import { + FurnitureAvailability, + FurnitureDeliveryStatus, + FurnitureOptionEntity, + FurnitureOptionStatus, + FurnitureRequirementEntity, + FurnitureRequirementStatus, + FurnitureScenarioEntity, + FurnitureScenarioSelectionEntity, + FurnitureScenarioStatus, + FurnitureScenarioType, +} from './entities/furniture.entities'; +import { calculateFurnitureTotal, sumMoney } from './furniture-pricing'; +import { FurnitureRepository } from './furniture.repository'; +import { RenovationRepository } from './renovation.repository'; + +@Injectable() +export class FurnitureService { + private readonly requirementSorts = new Set([ + 'name', + 'room', + 'category', + 'price', + 'priority', + 'status', + 'deliveryDate', + 'updatedAt', + 'sortOrder', + ]); + constructor( + private readonly furniture: FurnitureRepository, + private readonly renovation: RenovationRepository, + private readonly access: ProjectAccessService, + private readonly projects: ProjectsRepository, + private readonly notifications: NotificationsService, + @InjectDataSource() private readonly dataSource: DataSource, + ) {} + + async requirements( + projectId: string, + userId: string, + query: FurnitureListQueryDto, + ) { + await this.access.require(projectId, userId, 'read'); + if (!this.requirementSorts.has(query.sortBy)) + this.validation('Dieses Sortierfeld ist nicht erlaubt.'); + return this.furniture.pageRequirements(projectId, query); + } + async requirement(projectId: string, id: string, userId: string) { + await this.access.require(projectId, userId, 'read'); + const requirement = await this.owned( + this.furniture.requirements, + projectId, + id, + ); + return { + ...requirement, + options: await this.furniture.listOptions(projectId, id), + }; + } + async createRequirement( + projectId: string, + userId: string, + dto: CreateFurnitureRequirementDto, + ) { + await this.access.require(projectId, userId, 'edit'); + await this.validateRequirementLinks( + projectId, + dto.roomId, + dto.responsibleUserId, + ); + const saved = await this.furniture.requirements.save( + this.furniture.requirements.create({ + projectId, + roomId: dto.roomId, + name: dto.name.trim(), + description: dto.description?.trim() || null, + category: dto.category, + priority: dto.priority, + requiredQuantity: dto.requiredQuantity, + status: dto.status, + responsibleUserId: dto.responsibleUserId ?? null, + maximumBudget: this.decimal(dto.maximumBudget), + sortOrder: dto.sortOrder ?? 0, + createdByUserId: userId, + }), + ); + await this.activity(projectId, userId, 'furniture.requirement-created', { + requirementId: saved.id, + roomId: saved.roomId, + name: saved.name, + }); + if (saved.responsibleUserId && saved.responsibleUserId !== userId) + await this.notifications.createForUser({ + userId: saved.responsibleUserId, + type: 'furniture.requirement-assigned', + title: 'Möbelbedarf zugewiesen', + message: `Sie sind für „${saved.name}“ verantwortlich.`, + link: `/projekte/${projectId}/moebel?requirement=${saved.id}`, + metadata: { projectId, requirementId: saved.id }, + }); + return saved; + } + async updateRequirement( + projectId: string, + id: string, + userId: string, + dto: UpdateFurnitureRequirementDto, + ) { + await this.access.require(projectId, userId, 'edit'); + await this.validateRequirementLinks( + projectId, + dto.roomId, + dto.responsibleUserId, + ); + const before = await this.owned(this.furniture.requirements, projectId, id); + await this.versioned( + this.furniture.requirements, + projectId, + id, + dto.version, + { + roomId: dto.roomId, + name: dto.name.trim(), + description: dto.description?.trim() || null, + category: dto.category, + priority: dto.priority, + requiredQuantity: dto.requiredQuantity, + status: dto.status, + responsibleUserId: dto.responsibleUserId ?? null, + maximumBudget: this.decimal(dto.maximumBudget), + sortOrder: dto.sortOrder ?? 0, + }, + ); + await this.activity( + projectId, + userId, + before.roomId === dto.roomId + ? 'furniture.requirement-updated' + : 'furniture.requirement-moved', + { requirementId: id, roomId: dto.roomId }, + ); + return this.owned(this.furniture.requirements, projectId, id); + } + async deleteRequirement(projectId: string, id: string, userId: string) { + await this.access.require(projectId, userId, 'edit'); + const requirement = await this.owned( + this.furniture.requirements, + projectId, + id, + ); + const ordered = await this.furniture.options.count({ + where: { + projectId, + requirementId: id, + status: In([ + FurnitureOptionStatus.Ordered, + FurnitureOptionStatus.Delivered, + ]), + }, + }); + if ( + ordered > 0 && + requirement.status !== FurnitureRequirementStatus.Omitted + ) + this.conflict( + 'Bestellte Möbelbedarfe müssen zuerst als entfällt markiert und nachvollziehbar behandelt werden.', + ); + await this.furniture.requirements.softDelete({ id, projectId }); + await this.activity(projectId, userId, 'furniture.requirement-archived', { + requirementId: id, + }); + } + async copyRequirement(projectId: string, id: string, userId: string) { + await this.access.require(projectId, userId, 'edit'); + const source = await this.owned(this.furniture.requirements, projectId, id); + const saved = await this.furniture.requirements.save( + this.furniture.requirements.create({ + projectId, + roomId: source.roomId, + name: `${source.name} (Kopie)`, + description: source.description, + category: source.category, + priority: source.priority, + requiredQuantity: source.requiredQuantity, + status: FurnitureRequirementStatus.Identified, + responsibleUserId: source.responsibleUserId, + maximumBudget: source.maximumBudget, + sortOrder: source.sortOrder + 1, + createdByUserId: userId, + }), + ); + await this.activity(projectId, userId, 'furniture.requirement-created', { + requirementId: saved.id, + sourceRequirementId: id, + name: saved.name, + }); + return saved; + } + + async options(projectId: string, requirementId: string, userId: string) { + await this.access.require(projectId, userId, 'read'); + await this.owned(this.furniture.requirements, projectId, requirementId); + return this.furniture.listOptions(projectId, requirementId); + } + async projectOptions( + projectId: string, + userId: string, + query: FurnitureOptionListQueryDto, + ) { + await this.access.require(projectId, userId, 'read'); + const allowed = new Set([ + 'name', + 'retailer', + 'unitPrice', + 'totalPrice', + 'status', + 'availability', + 'expectedDeliveryDate', + 'updatedAt', + 'room', + 'requirement', + ]); + if (!allowed.has(query.sortBy)) + this.validation('Das Sortierfeld für Möbelalternativen ist ungültig.'); + return this.furniture.pageOptions(projectId, query); + } + async option(projectId: string, id: string, userId: string) { + await this.access.require(projectId, userId, 'read'); + const option = await this.owned(this.furniture.options, projectId, id); + const documentLinks = await this.furniture.optionDocuments.find({ + where: { projectId, optionId: id }, + }); + const expenses = await this.renovation.expenses.find({ + where: { projectId, furnitureOptionId: id }, + }); + return { + ...option, + documentIds: documentLinks.map((link) => link.documentId), + expenses, + }; + } + async createOption( + projectId: string, + requirementId: string, + userId: string, + dto: CreateFurnitureOptionDto, + ) { + await this.access.require(projectId, userId, 'edit'); + await this.owned(this.furniture.requirements, projectId, requirementId); + await this.validateOptionLinks(projectId, dto.budgetCategoryId); + const totalPrice = this.total(dto); + const saved = await this.furniture.options.save( + this.furniture.options.create({ + ...this.optionValues(dto), + projectId, + requirementId, + totalPrice, + currentlySelected: false, + createdByUserId: userId, + }), + ); + await this.furniture.requirements.update( + { id: requirementId, projectId }, + { status: FurnitureRequirementStatus.HasOptions }, + ); + await this.activity(projectId, userId, 'furniture.option-created', { + requirementId, + optionId: saved.id, + name: saved.name, + }); + return saved; + } + async updateOption( + projectId: string, + id: string, + userId: string, + dto: UpdateFurnitureOptionDto, + ) { + await this.access.require(projectId, userId, 'edit'); + const before = await this.owned(this.furniture.options, projectId, id); + await this.validateOptionLinks(projectId, dto.budgetCategoryId); + await this.versioned(this.furniture.options, projectId, id, dto.version, { + ...this.optionValues(dto), + totalPrice: this.total(dto), + }); + const saved = await this.owned(this.furniture.options, projectId, id); + await this.activity( + projectId, + userId, + before.totalPrice === saved.totalPrice + ? 'furniture.option-updated' + : 'furniture.price-changed', + { optionId: id, oldPrice: before.totalPrice, newPrice: saved.totalPrice }, + ); + if (before.totalPrice !== saved.totalPrice && saved.favorite) + await this.notifyResponsible( + projectId, + saved.requirementId, + userId, + 'Preis eines Favoriten geändert', + `Der Preis von „${saved.name}“ wurde geändert.`, + ); + return saved; + } + async deleteOption(projectId: string, id: string, userId: string) { + await this.access.require(projectId, userId, 'edit'); + const option = await this.owned(this.furniture.options, projectId, id); + if ( + option.orderedAt || + [FurnitureOptionStatus.Ordered, FurnitureOptionStatus.Delivered].includes( + option.status, + ) + ) + this.conflict( + 'Bestellte oder gelieferte Produkte können nur archiviert werden.', + ); + await this.furniture.options.softDelete({ id, projectId }); + await this.activity(projectId, userId, 'furniture.option-archived', { + optionId: id, + }); + } + async copyOption(projectId: string, id: string, userId: string) { + await this.access.require(projectId, userId, 'edit'); + const source = await this.owned(this.furniture.options, projectId, id); + const saved = await this.furniture.options.save( + this.furniture.options.create({ + projectId, + requirementId: source.requirementId, + name: `${source.name} (Kopie)`, + manufacturer: source.manufacturer, + model: source.model, + description: source.description, + retailer: source.retailer, + productUrl: source.productUrl, + articleNumber: source.articleNumber, + unitPrice: source.unitPrice, + originalPrice: source.originalPrice, + shippingCost: source.shippingCost, + additionalCost: source.additionalCost, + discount: source.discount, + totalPrice: source.totalPrice, + currency: source.currency, + quantity: source.quantity, + width: source.width, + height: source.height, + depth: source.depth, + weight: source.weight, + color: source.color, + material: source.material, + deliveryDays: source.deliveryDays, + earliestDeliveryDate: source.earliestDeliveryDate, + expectedDeliveryDate: source.expectedDeliveryDate, + returnDeadline: source.returnDeadline, + availability: source.availability, + favorite: false, + currentlySelected: false, + status: FurnitureOptionStatus.Reviewing, + notes: source.notes, + budgetCategoryId: source.budgetCategoryId, + existingItem: source.existingItem, + estimatedCurrentValue: source.estimatedCurrentValue, + movingCost: source.movingCost, + refurbishmentCost: source.refurbishmentCost, + currentLocation: source.currentLocation, + condition: source.condition, + orderedAt: null, + orderedByUserId: null, + orderNumber: null, + actualDeliveryDate: null, + deliveryStatus: FurnitureDeliveryStatus.NotOrdered, + deliveredQuantity: 0, + assemblyDate: null, + assembledBy: null, + createdByUserId: userId, + }), + ); + await this.activity(projectId, userId, 'furniture.option-created', { + optionId: saved.id, + sourceOptionId: id, + requirementId: saved.requirementId, + }); + return saved; + } + async setFavorite(projectId: string, id: string, userId: string) { + await this.access.require(projectId, userId, 'edit'); + const option = await this.owned(this.furniture.options, projectId, id); + option.favorite = !option.favorite; + option.status = option.favorite + ? FurnitureOptionStatus.Favorite + : FurnitureOptionStatus.Reviewing; + const saved = await this.furniture.options.save(option); + await this.activity(projectId, userId, 'furniture.favorite-changed', { + optionId: id, + favorite: saved.favorite, + }); + return saved; + } + async select(projectId: string, id: string, userId: string, version: number) { + await this.access.require(projectId, userId, 'edit'); + return this.dataSource.transaction(async (manager) => { + const options = manager.getRepository(FurnitureOptionEntity); + const candidate = await options.findOneBy({ id, projectId }); + if (!candidate || candidate.deletedAt) this.notFound(); + await manager + .getRepository(FurnitureRequirementEntity) + .createQueryBuilder('requirement') + .setLock('pessimistic_write') + .where( + 'requirement.id = :requirementId AND requirement.projectId = :projectId', + { + requirementId: candidate.requirementId, + projectId, + }, + ) + .getOneOrFail(); + const option = await options + .createQueryBuilder('option') + .setLock('pessimistic_write') + .where( + 'option.id = :id AND option.projectId = :projectId AND option.deletedAt IS NULL', + { id, projectId }, + ) + .getOne(); + if (!option) this.notFound(); + if (option.version !== version) + this.conflict( + 'Die Alternative wurde zwischenzeitlich geändert. Laden Sie die Daten neu.', + ); + if ( + [ + FurnitureOptionStatus.Archived, + FurnitureOptionStatus.Unavailable, + FurnitureOptionStatus.Rejected, + FurnitureOptionStatus.Returned, + ].includes(option.status) || + [ + FurnitureAvailability.Unavailable, + FurnitureAvailability.Discontinued, + ].includes(option.availability) + ) + this.conflict('Diese Alternative ist nicht auswählbar.'); + await options.update( + { + projectId, + requirementId: option.requirementId, + currentlySelected: true, + }, + { currentlySelected: false }, + ); + option.currentlySelected = true; + option.status = FurnitureOptionStatus.Selected; + const saved = await options.save(option); + await manager + .getRepository(FurnitureRequirementEntity) + .update( + { id: option.requirementId, projectId }, + { status: FurnitureRequirementStatus.Selected }, + ); + await this.activity( + projectId, + userId, + 'furniture.option-selected', + { optionId: id, requirementId: option.requirementId }, + manager, + ); + return saved; + }); + } + async order( + projectId: string, + id: string, + userId: string, + dto: FurnitureOrderDto, + ) { + await this.access.require(projectId, userId, 'edit'); + const option = await this.owned(this.furniture.options, projectId, id); + await this.versioned(this.furniture.options, projectId, id, dto.version, { + orderedAt: new Date(), + orderedByUserId: userId, + orderNumber: dto.orderNumber?.trim() || null, + expectedDeliveryDate: + dto.expectedDeliveryDate ?? option.expectedDeliveryDate, + deliveryStatus: dto.deliveryStatus, + status: FurnitureOptionStatus.Ordered, + }); + await this.furniture.requirements.update( + { id: option.requirementId, projectId }, + { status: FurnitureRequirementStatus.Ordered }, + ); + await this.activity(projectId, userId, 'furniture.ordered', { + optionId: id, + requirementId: option.requirementId, + }); + await this.notifyResponsible( + projectId, + option.requirementId, + userId, + 'Möbel bestellt', + `„${option.name}“ wurde bestellt.`, + ); + return this.owned(this.furniture.options, projectId, id); + } + async deliver( + projectId: string, + id: string, + userId: string, + dto: FurnitureDeliveryDto, + ) { + await this.access.require(projectId, userId, 'edit'); + const option = await this.owned(this.furniture.options, projectId, id); + if (dto.deliveredQuantity > option.quantity) + this.validation( + 'Die gelieferte Menge darf die bestellte Menge nicht überschreiten.', + ); + const complete = dto.deliveredQuantity === option.quantity; + await this.versioned(this.furniture.options, projectId, id, dto.version, { + deliveredQuantity: dto.deliveredQuantity, + actualDeliveryDate: + dto.actualDeliveryDate ?? new Date().toISOString().slice(0, 10), + deliveryStatus: complete + ? FurnitureDeliveryStatus.Delivered + : FurnitureDeliveryStatus.PartiallyDelivered, + status: complete + ? FurnitureOptionStatus.Delivered + : FurnitureOptionStatus.Ordered, + }); + await this.furniture.requirements.update( + { id: option.requirementId, projectId }, + { + status: complete + ? FurnitureRequirementStatus.Delivered + : FurnitureRequirementStatus.PartiallyDelivered, + }, + ); + await this.activity(projectId, userId, 'furniture.delivered', { + optionId: id, + deliveredQuantity: dto.deliveredQuantity, + }); + return this.owned(this.furniture.options, projectId, id); + } + async compareOptions( + projectId: string, + requirementId: string, + userId: string, + ) { + const requirement = await this.requirement( + projectId, + requirementId, + userId, + ); + const active = requirement.options.filter( + (option) => option.status !== FurnitureOptionStatus.Archived, + ); + const minimum = active.reduce( + (min, option) => + min === null || Number(option.totalPrice) < Number(min) + ? option.totalPrice + : min, + null, + ); + return { + requirement: { ...requirement, options: undefined }, + options: active.map((option) => ({ + ...option, + cheapest: option.totalPrice === minimum, + delayed: this.delayed(option), + })), + }; + } + async linkDocument( + projectId: string, + optionId: string, + userId: string, + dto: FurnitureDocumentLinkDto, + ) { + await this.access.require(projectId, userId, 'edit'); + await this.owned(this.furniture.options, projectId, optionId); + await this.owned(this.renovation.documents, projectId, dto.documentId); + const existing = await this.furniture.optionDocuments.findOneBy({ + optionId, + documentId: dto.documentId, + }); + if (!existing) + await this.furniture.optionDocuments.save( + this.furniture.optionDocuments.create({ + projectId, + optionId, + documentId: dto.documentId, + }), + ); + return { linked: true }; + } + + async scenarios(projectId: string, userId: string) { + await this.access.require(projectId, userId, 'read'); + const scenarios = await this.furniture.listScenarios(projectId); + return Promise.all( + scenarios.map((scenario) => this.scenarioResult(scenario)), + ); + } + async scenario(projectId: string, id: string, userId: string) { + await this.access.require(projectId, userId, 'read'); + return this.scenarioResult( + await this.owned(this.furniture.scenarios, projectId, id), + ); + } + async createScenario( + projectId: string, + userId: string, + dto: CreateFurnitureScenarioDto, + ) { + await this.access.require(projectId, userId, 'edit'); + return this.dataSource.transaction(async (manager) => { + const scenarios = manager.getRepository(FurnitureScenarioEntity); + if (dto.isDefault) + await scenarios.update( + { projectId, isDefault: true }, + { isDefault: false }, + ); + const saved = await scenarios.save( + scenarios.create({ + projectId, + name: dto.name.trim(), + description: dto.description?.trim() || null, + type: dto.type, + status: dto.status, + isDefault: dto.isDefault, + createdByUserId: userId, + }), + ); + if (dto.copyFromScenarioId) + await this.copySelections( + projectId, + dto.copyFromScenarioId, + saved.id, + manager, + ); + else if ( + dto.automaticSelection && + dto.automaticSelection !== FurnitureScenarioType.Custom + ) + await this.autoSelections( + projectId, + saved.id, + dto.automaticSelection, + manager, + ); + await this.activity( + projectId, + userId, + dto.copyFromScenarioId + ? 'furniture.scenario-copied' + : 'furniture.scenario-created', + { scenarioId: saved.id, name: saved.name }, + manager, + ); + return this.scenarioResult(saved, manager); + }); + } + async updateScenario( + projectId: string, + id: string, + userId: string, + dto: UpdateFurnitureScenarioDto, + ) { + await this.access.require(projectId, userId, 'edit'); + await this.owned(this.furniture.scenarios, projectId, id); + if (dto.isDefault) + await this.furniture.scenarios.update( + { projectId, isDefault: true }, + { isDefault: false }, + ); + await this.versioned(this.furniture.scenarios, projectId, id, dto.version, { + name: dto.name.trim(), + description: dto.description?.trim() || null, + type: dto.type, + status: dto.status, + isDefault: dto.isDefault, + }); + await this.activity(projectId, userId, 'furniture.scenario-updated', { + scenarioId: id, + }); + return this.scenario(projectId, id, userId); + } + async deleteScenario(projectId: string, id: string, userId: string) { + await this.access.require(projectId, userId, 'edit'); + const scenario = await this.owned(this.furniture.scenarios, projectId, id); + if (scenario.isDefault) + this.conflict( + 'Das Standardszenario kann nicht archiviert werden. Legen Sie zuerst ein anderes Standardszenario fest.', + ); + await this.furniture.scenarios.softDelete({ id, projectId }); + } + async updateSelections( + projectId: string, + scenarioId: string, + userId: string, + dto: UpdateFurnitureScenarioSelectionsDto, + ) { + await this.access.require(projectId, userId, 'edit'); + return this.dataSource.transaction(async (manager) => { + const scenarios = manager.getRepository(FurnitureScenarioEntity); + const scenario = await scenarios + .createQueryBuilder('scenario') + .setLock('pessimistic_write') + .where( + 'scenario.id = :scenarioId AND scenario.projectId = :projectId AND scenario.deletedAt IS NULL', + { scenarioId, projectId }, + ) + .getOne(); + if (!scenario) this.notFound(); + if (scenario.version !== dto.version) + this.conflict( + 'Das Szenario wurde zwischenzeitlich geändert. Laden Sie es neu.', + ); + if (scenario.status === FurnitureScenarioStatus.Archived) + this.conflict('Ein archiviertes Szenario kann nicht geändert werden.'); + const selections = manager.getRepository( + FurnitureScenarioSelectionEntity, + ); + const replacements: FurnitureScenarioSelectionEntity[] = []; + for (const selection of dto.selections) { + const requirement = await manager + .getRepository(FurnitureRequirementEntity) + .findOneBy({ id: selection.requirementId, projectId }); + const option = await manager + .getRepository(FurnitureOptionEntity) + .findOneBy({ + id: selection.optionId, + projectId, + requirementId: selection.requirementId, + }); + if ( + !requirement || + !option || + option.deletedAt || + [ + FurnitureOptionStatus.Archived, + FurnitureOptionStatus.Unavailable, + ].includes(option.status) + ) + this.validation( + 'Eine Szenarioauswahl ist ungültig oder gehört nicht zum Möbelbedarf.', + ); + replacements.push( + selections.create({ + projectId, + scenarioId, + requirementId: selection.requirementId, + optionId: selection.optionId, + quantity: selection.quantity, + priceOverride: this.decimal(selection.priceOverride), + note: selection.note?.trim() || null, + }), + ); + } + await selections.delete({ projectId, scenarioId }); + await selections.save(replacements); + scenario.version += 1; + await scenarios.save(scenario); + await this.activity( + projectId, + userId, + 'furniture.scenario-selection-changed', + { scenarioId, selectionCount: replacements.length }, + manager, + ); + return this.scenarioResult(scenario, manager); + }); + } + async compareScenarios(projectId: string, userId: string, ids?: string[]) { + await this.access.require(projectId, userId, 'read'); + const all = await this.furniture.listScenarios(projectId); + const selected = ids?.length + ? all.filter((scenario) => ids.includes(scenario.id)) + : all; + const results = await Promise.all( + selected.map((scenario) => this.scenarioResult(scenario)), + ); + const rooms = await this.renovation.listRooms(projectId); + return { + scenarios: results, + rooms: rooms.map((room) => ({ + id: room.id, + name: room.name, + costs: Object.fromEntries( + results.map((result) => [ + result.id, + result.byRoom[room.id] ?? '0.00', + ]), + ), + })), + costDrivers: this.costDrivers(results), + }; + } + async summary(projectId: string, userId: string, roomId?: string) { + await this.access.require(projectId, userId, 'read'); + if (roomId) await this.owned(this.renovation.rooms, projectId, roomId); + const requirements = await this.furniture.listRequirements( + projectId, + roomId, + ); + const options = requirements.length + ? await this.furniture.options.find({ + where: requirements.map((requirement) => ({ + projectId, + requirementId: requirement.id, + deletedAt: IsNull(), + })), + }) + : []; + const selected = options.filter((option) => option.currentlySelected); + const favorites = requirements + .map( + (requirement) => + options.find( + (option) => + option.requirementId === requirement.id && option.favorite, + ) ?? + options.find( + (option) => + option.requirementId === requirement.id && + option.currentlySelected, + ), + ) + .filter((option): option is FurnitureOptionEntity => !!option); + const cheapest = requirements + .map( + (requirement) => + options + .filter( + (option) => + option.requirementId === requirement.id && + this.available(option), + ) + .sort((a, b) => Number(a.totalPrice) - Number(b.totalPrice))[0], + ) + .filter((option): option is FurnitureOptionEntity => !!option); + const expenses = selected.length + ? await this.renovation.expenses.find({ + where: selected.map((option) => ({ + projectId, + furnitureOptionId: option.id, + })), + }) + : []; + const scenarios = await this.scenarios(projectId, userId); + const today = new Date().toISOString().slice(0, 10); + return { + requirements: requirements.length, + withoutOption: requirements.filter( + (requirement) => + !options.some((option) => option.requirementId === requirement.id), + ).length, + withoutDecision: requirements.filter( + (requirement) => + !selected.some((option) => option.requirementId === requirement.id), + ).length, + selected: selected.length, + ordered: options.filter((option) => !!option.orderedAt).length, + delivered: options.filter( + (option) => option.deliveryStatus === FurnitureDeliveryStatus.Delivered, + ).length, + delayed: options.filter( + (option) => + !!option.expectedDeliveryDate && + option.expectedDeliveryDate < today && + ![ + FurnitureDeliveryStatus.Delivered, + FurnitureDeliveryStatus.Cancelled, + FurnitureDeliveryStatus.Returned, + ].includes(option.deliveryStatus), + ).length, + budget: sumMoney( + requirements.map((requirement) => requirement.maximumBudget ?? '0'), + ), + cheapestCost: sumMoney(cheapest.map((option) => option.totalPrice)), + favoriteCost: sumMoney(favorites.map((option) => option.totalPrice)), + selectedCost: sumMoney(selected.map((option) => option.totalPrice)), + actualExpenseCost: sumMoney( + expenses + .filter((expense) => expense.paymentStatus !== 'cancelled') + .map((expense) => expense.amount), + ), + openPrices: requirements.filter( + (requirement) => + !options.some((option) => option.requirementId === requirement.id), + ).length, + overBudget: requirements.filter((requirement) => { + const option = selected.find( + (candidate) => candidate.requirementId === requirement.id, + ); + return ( + !!option && + !!requirement.maximumBudget && + Number(option.totalPrice) > Number(requirement.maximumBudget) + ); + }).length, + scenarios: scenarios.map((scenario) => ({ + id: scenario.id, + name: scenario.name, + total: scenario.total, + })), + }; + } + async createExpense( + projectId: string, + optionId: string, + userId: string, + dto: FurnitureExpenseDto, + ) { + await this.access.require(projectId, userId, 'edit'); + const option = await this.owned( + this.furniture.options, + projectId, + optionId, + ); + const requirement = await this.owned( + this.furniture.requirements, + projectId, + option.requirementId, + ); + await this.owned(this.renovation.budgets, projectId, dto.budgetCategoryId); + const expense = await this.renovation.expenses.save( + this.renovation.expenses.create({ + projectId, + budgetCategoryId: dto.budgetCategoryId, + roomId: requirement.roomId, + taskId: null, + furnitureRequirementId: requirement.id, + furnitureOptionId: option.id, + title: dto.title.trim(), + description: null, + amount: this.decimal(dto.amount) ?? option.totalPrice, + currency: option.currency, + expenseDate: new Date().toISOString().slice(0, 10), + paymentStatus: dto.paymentStatus, + dueDate: dto.dueDate ?? null, + supplier: dto.supplier?.trim() || option.retailer, + invoiceNumber: null, + documentId: null, + createdByUserId: userId, + }), + ); + await this.activity(projectId, userId, 'furniture.expense-created', { + optionId, + expenseId: expense.id, + amount: expense.amount, + }); + return expense; + } + + private async scenarioResult( + scenario: FurnitureScenarioEntity, + manager?: EntityManager, + ) { + const selections = await ( + manager?.getRepository(FurnitureScenarioSelectionEntity) ?? + this.furniture.selections + ).find({ + where: { projectId: scenario.projectId, scenarioId: scenario.id }, + }); + const options = selections.length + ? await ( + manager?.getRepository(FurnitureOptionEntity) ?? + this.furniture.options + ).find({ + where: selections.map((selection) => ({ + id: selection.optionId, + projectId: scenario.projectId, + })), + }) + : []; + const requirements = selections.length + ? await ( + manager?.getRepository(FurnitureRequirementEntity) ?? + this.furniture.requirements + ).find({ + where: selections.map((selection) => ({ + id: selection.requirementId, + projectId: scenario.projectId, + })), + }) + : []; + const totalFor = (selection: FurnitureScenarioSelectionEntity) => + selection.priceOverride ?? + options.find((option) => option.id === selection.optionId)?.totalPrice ?? + '0'; + const byRoom: Record = {}; + for (const selection of selections) { + const requirement = requirements.find( + (entry) => entry.id === selection.requirementId, + ); + if (requirement) + byRoom[requirement.roomId] = sumMoney([ + byRoom[requirement.roomId] ?? '0', + totalFor(selection), + ]); + } + const totalRequirements = await ( + manager?.getRepository(FurnitureRequirementEntity) ?? + this.furniture.requirements + ).count({ where: { projectId: scenario.projectId, deletedAt: IsNull() } }); + return { + ...scenario, + selections, + total: sumMoney(selections.map(totalFor)), + byRoom, + selectedRequirements: selections.length, + openRequirements: Math.max(0, totalRequirements - selections.length), + shippingCost: sumMoney(options.map((option) => option.shippingCost)), + additionalCost: sumMoney(options.map((option) => option.additionalCost)), + existingItems: options.filter((option) => option.existingItem).length, + }; + } + private async copySelections( + projectId: string, + sourceId: string, + targetId: string, + manager: EntityManager, + ) { + const source = await manager + .getRepository(FurnitureScenarioEntity) + .findOneBy({ id: sourceId, projectId }); + if (!source) this.notFound(); + const repository = manager.getRepository(FurnitureScenarioSelectionEntity); + const entries = await repository.find({ + where: { projectId, scenarioId: sourceId }, + }); + await repository.save( + entries.map((entry) => + repository.create({ + projectId: entry.projectId, + scenarioId: targetId, + requirementId: entry.requirementId, + optionId: entry.optionId, + quantity: entry.quantity, + priceOverride: entry.priceOverride, + note: entry.note, + }), + ), + ); + } + private async autoSelections( + projectId: string, + scenarioId: string, + type: FurnitureScenarioType, + manager: EntityManager, + ) { + const requirements = await manager + .getRepository(FurnitureRequirementEntity) + .find({ where: { projectId, deletedAt: IsNull() } }); + const optionRepo = manager.getRepository(FurnitureOptionEntity); + const selectionRepo = manager.getRepository( + FurnitureScenarioSelectionEntity, + ); + const selections: FurnitureScenarioSelectionEntity[] = []; + for (const requirement of requirements) { + let options = ( + await optionRepo.find({ + where: { + projectId, + requirementId: requirement.id, + deletedAt: IsNull(), + }, + }) + ).filter((option) => this.available(option)); + options = options.sort( + (a, b) => Number(a.totalPrice) - Number(b.totalPrice), + ); + const option = + type === FurnitureScenarioType.Premium + ? options.at(-1) + : type === FurnitureScenarioType.Preferred + ? (options.find((entry) => entry.currentlySelected) ?? + options.find((entry) => entry.favorite) ?? + options[0]) + : type === FurnitureScenarioType.Existing + ? options.find((entry) => entry.existingItem) + : options[0]; + if (option) + selections.push( + selectionRepo.create({ + projectId, + scenarioId, + requirementId: requirement.id, + optionId: option.id, + quantity: option.quantity, + priceOverride: null, + note: null, + }), + ); + } + await selectionRepo.save(selections); + } + private optionValues(dto: CreateFurnitureOptionDto) { + const text = (value?: string) => value?.trim() || null; + const decimal = (value?: number) => + value === undefined ? null : value.toFixed(2); + return { + name: dto.name.trim(), + manufacturer: text(dto.manufacturer), + model: text(dto.model), + description: text(dto.description), + retailer: text(dto.retailer), + productUrl: text(dto.productUrl), + articleNumber: text(dto.articleNumber), + unitPrice: dto.unitPrice.toFixed(2), + originalPrice: decimal(dto.originalPrice), + shippingCost: dto.shippingCost.toFixed(2), + additionalCost: dto.additionalCost.toFixed(2), + discount: dto.discount.toFixed(2), + currency: dto.currency.toUpperCase(), + quantity: dto.quantity, + width: decimal(dto.width), + height: decimal(dto.height), + depth: decimal(dto.depth), + weight: decimal(dto.weight), + color: text(dto.color), + material: text(dto.material), + deliveryDays: dto.deliveryDays ?? null, + earliestDeliveryDate: dto.earliestDeliveryDate ?? null, + expectedDeliveryDate: dto.expectedDeliveryDate ?? null, + returnDeadline: dto.returnDeadline ?? null, + availability: dto.availability, + favorite: dto.favorite, + status: dto.status, + notes: text(dto.notes), + budgetCategoryId: dto.budgetCategoryId ?? null, + existingItem: dto.existingItem, + estimatedCurrentValue: decimal(dto.estimatedCurrentValue), + movingCost: dto.movingCost.toFixed(2), + refurbishmentCost: dto.refurbishmentCost.toFixed(2), + currentLocation: text(dto.currentLocation), + condition: dto.condition ?? null, + }; + } + private total(dto: CreateFurnitureOptionDto) { + try { + return calculateFurnitureTotal(dto); + } catch { + this.validation( + 'Der Rabatt darf die berechneten Gesamtkosten nicht überschreiten.', + ); + } + } + private available(option: FurnitureOptionEntity) { + return ( + ![ + FurnitureOptionStatus.Archived, + FurnitureOptionStatus.Rejected, + FurnitureOptionStatus.Unavailable, + FurnitureOptionStatus.Returned, + ].includes(option.status) && + ![ + FurnitureAvailability.Unavailable, + FurnitureAvailability.Discontinued, + ].includes(option.availability) + ); + } + private delayed(option: FurnitureOptionEntity) { + return ( + !!option.expectedDeliveryDate && + option.expectedDeliveryDate < new Date().toISOString().slice(0, 10) && + ![ + FurnitureDeliveryStatus.Delivered, + FurnitureDeliveryStatus.Cancelled, + FurnitureDeliveryStatus.Returned, + ].includes(option.deliveryStatus) + ); + } + private costDrivers( + results: Array<{ + name: string; + selections: FurnitureScenarioSelectionEntity[]; + total: string; + }>, + ) { + if (results.length < 2) return []; + const base = results[0]; + const comparison = results[1]; + if (!base || !comparison) return []; + const ids = new Set([ + ...base.selections.map((s) => s.requirementId), + ...comparison.selections.map((s) => s.requirementId), + ]); + return [...ids] + .map((requirementId) => ({ + requirementId, + changed: + base.selections.find((s) => s.requirementId === requirementId) + ?.optionId !== + comparison.selections.find((s) => s.requirementId === requirementId) + ?.optionId, + })) + .filter((entry) => entry.changed); + } + private async validateRequirementLinks( + projectId: string, + roomId: string, + responsibleUserId?: string, + ) { + await this.owned(this.renovation.rooms, projectId, roomId); + if (responsibleUserId) { + const member = await this.projects.findMembership( + projectId, + responsibleUserId, + ); + if (!member?.active || !member.user.active) + this.validation( + 'Der Verantwortliche ist kein aktives Projektmitglied.', + ); + } + } + private async validateOptionLinks( + projectId: string, + budgetCategoryId?: string, + ) { + if (budgetCategoryId) + await this.owned(this.renovation.budgets, projectId, budgetCategoryId); + } + private async notifyResponsible( + projectId: string, + requirementId: string, + actorId: string, + title: string, + message: string, + ) { + const requirement = await this.owned( + this.furniture.requirements, + projectId, + requirementId, + ); + if ( + requirement.responsibleUserId && + requirement.responsibleUserId !== actorId + ) + await this.notifications.createForUser({ + userId: requirement.responsibleUserId, + type: 'furniture.status-changed', + title, + message, + link: `/projekte/${projectId}/moebel?requirement=${requirementId}`, + metadata: { projectId, requirementId }, + }); + } + private async activity( + projectId: string, + userId: string, + action: string, + metadata: Record, + manager?: EntityManager, + ) { + const entity = new ProjectActivityEntity(); + entity.projectId = projectId; + entity.actorUserId = userId; + entity.action = action; + entity.metadata = metadata; + await this.projects.saveActivity(entity, manager); + } + private async owned( + repository: { findOneBy(where: object): Promise }, + projectId: string, + id: string, + ): Promise { + const entity = await repository.findOneBy({ id, projectId }); + if (!entity) this.notFound(); + return entity; + } + private async versioned( + repository: { + update( + criteria: object, + values: object, + ): Promise<{ affected?: number | null }>; + }, + projectId: string, + id: string, + version: number, + values: object, + ) { + const result = await repository.update( + { id, projectId, version }, + { ...values, version: () => 'version + 1' }, + ); + if (result.affected !== 1) + this.conflict( + 'Dieser Datensatz wurde zwischenzeitlich geändert. Laden Sie die aktuellen Daten neu.', + ); + } + private decimal(value?: number) { + return value === undefined ? null : value.toFixed(2); + } + private validation(message: string): never { + throw new ApiError(ErrorCode.ValidationFailed, message, 400); + } + private notFound(): never { + throw new ApiError( + ErrorCode.NotFound, + 'Der Datensatz wurde nicht gefunden.', + 404, + ); + } + private conflict(message: string): never { + throw new ApiError(ErrorCode.Conflict, message, 409); + } +} diff --git a/apps/backend/src/renovation/mentions.ts b/apps/backend/src/renovation/mentions.ts new file mode 100644 index 0000000..e964dfd --- /dev/null +++ b/apps/backend/src/renovation/mentions.ts @@ -0,0 +1,15 @@ +export function newMentionUserIds( + existingUserIds: readonly string[], + requestedUserIds: readonly string[], + authorUserId: string, +): { notify: string[]; selfMentioned: boolean } { + const existing = new Set(existingUserIds); + const uniqueRequested = new Set(requestedUserIds); + return { + notify: Array.from(uniqueRequested).filter( + (userId) => userId !== authorUserId && !existing.has(userId), + ), + selfMentioned: + uniqueRequested.has(authorUserId) && !existing.has(authorUserId), + }; +} diff --git a/apps/backend/src/renovation/progress.ts b/apps/backend/src/renovation/progress.ts new file mode 100644 index 0000000..fa4f024 --- /dev/null +++ b/apps/backend/src/renovation/progress.ts @@ -0,0 +1,65 @@ +import { + TaskStatus, + type RenovationTaskEntity, +} from './entities/renovation.entities'; + +const statusProgress: Record = { + [TaskStatus.Idea]: 0, + [TaskStatus.Planned]: 10, + [TaskStatus.Commissioned]: 25, + [TaskStatus.InProgress]: 50, + [TaskStatus.Blocked]: 25, + [TaskStatus.Acceptance]: 90, + [TaskStatus.Done]: 100, + [TaskStatus.Omitted]: 0, +}; + +export function calculateProgress( + tasks: readonly Pick[], +): number { + const relevant = tasks.filter((task) => task.status !== TaskStatus.Omitted); + const weight = relevant.reduce( + (sum, task) => sum + Number(task.weight || 1), + 0, + ); + if (weight === 0) return 0; + return Math.round( + relevant.reduce( + (sum, task) => + sum + statusProgress[task.status] * Number(task.weight || 1), + 0, + ) / weight, + ); +} + +export function wouldCreateDependencyCycle( + dependencies: readonly Pick< + { predecessorTaskId: string; successorTaskId: string }, + 'predecessorTaskId' | 'successorTaskId' + >[], + predecessorTaskId: string, + successorTaskId: string, +): boolean { + if (predecessorTaskId === successorTaskId) return true; + const edges = new Map(); + for (const edge of [ + ...dependencies, + { predecessorTaskId, successorTaskId }, + ]) { + const targets = edges.get(edge.predecessorTaskId) ?? []; + targets.push(edge.successorTaskId); + edges.set(edge.predecessorTaskId, targets); + } + const visiting = new Set(); + const visited = new Set(); + const visit = (node: string): boolean => { + if (visiting.has(node)) return true; + if (visited.has(node)) return false; + visiting.add(node); + for (const next of edges.get(node) ?? []) if (visit(next)) return true; + visiting.delete(node); + visited.add(node); + return false; + }; + return Array.from(edges.keys()).some(visit); +} diff --git a/apps/backend/src/renovation/reminder.service.ts b/apps/backend/src/renovation/reminder.service.ts new file mode 100644 index 0000000..f6b38eb --- /dev/null +++ b/apps/backend/src/renovation/reminder.service.ts @@ -0,0 +1,298 @@ +import { + Injectable, + Logger, + OnModuleDestroy, + OnModuleInit, +} from '@nestjs/common'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource, EntityManager } from 'typeorm'; +import { AppConfigService } from '../config/config.service'; +import { NotificationType } from '../notifications/notification-types'; +import { NotificationsService } from '../notifications/notifications.service'; +import { + InvitationStatus, + ProjectInvitationEntity, +} from '../projects/entities/project-invitation.entity'; +import { ProjectRole } from '../projects/entities/project-membership.entity'; +import { ProjectsRepository } from '../projects/repositories/projects.repository'; +import { + ExpenseEntity, + MilestoneEntity, + ReminderDeliveryEntity, + RenovationTaskEntity, +} from './entities/renovation.entities'; +import { + FurnitureOptionEntity, + FurnitureRequirementEntity, +} from './entities/furniture.entities'; + +interface ReminderInput { + projectId: string; + userId: string; + entityType: string; + entityId: string; + reminderType: string; + referenceDate: string; + notificationType: string; + title: string; + message: string; + link: string; +} + +@Injectable() +export class ReminderService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(ReminderService.name); + private readonly jobName = 'hauspilot-reminders'; + + constructor( + private readonly scheduler: SchedulerRegistry, + private readonly config: AppConfigService, + private readonly notifications: NotificationsService, + private readonly projects: ProjectsRepository, + @InjectDataSource() private readonly dataSource: DataSource, + ) {} + + onModuleInit() { + const interval = setInterval( + () => + void this.run().catch((error: unknown) => + this.logger.error({ error }, 'Reminder job failed'), + ), + this.config.reminders.intervalMs, + ); + interval.unref(); + this.scheduler.addInterval(this.jobName, interval); + } + + onModuleDestroy() { + try { + this.scheduler.deleteInterval(this.jobName); + } catch { + /* already stopped */ + } + } + + async run(now = new Date()): Promise<{ created: number }> { + const today = now.toISOString().slice(0, 10); + const dueSoon = new Date(now); + dueSoon.setUTCDate( + dueSoon.getUTCDate() + this.config.reminders.dueSoonDays, + ); + const dueSoonDate = dueSoon.toISOString().slice(0, 10); + let created = 0; + const tasks = await this.dataSource + .getRepository(RenovationTaskEntity) + .createQueryBuilder('task') + .where('task.deletedAt IS NULL') + .andWhere("task.status NOT IN ('done','omitted')") + .andWhere('task.assigneeUserId IS NOT NULL') + .andWhere('task.dueDate <= :dueSoon', { dueSoon: dueSoonDate }) + .getMany(); + for (const task of tasks) { + if ( + !task.assigneeUserId || + !task.dueDate || + !(await this.isActiveMember(task.projectId, task.assigneeUserId)) + ) + continue; + const overdue = task.dueDate < today; + created += await this.deliver({ + projectId: task.projectId, + userId: task.assigneeUserId, + entityType: 'task', + entityId: task.id, + reminderType: overdue ? 'TASK_OVERDUE' : 'TASK_DUE_SOON', + referenceDate: task.dueDate, + notificationType: overdue + ? NotificationType.TaskOverdue + : NotificationType.TaskDueSoon, + title: overdue ? 'Aufgabe überfällig' : 'Aufgabe bald fällig', + message: `Die Aufgabe „${task.title}“ ist ${overdue ? 'überfällig' : 'bald fällig'}.`, + link: `/projekte/${task.projectId}/aufgaben/${task.id}`, + }); + } + const milestones = await this.dataSource + .getRepository(MilestoneEntity) + .createQueryBuilder('milestone') + .where("milestone.status NOT IN ('done','cancelled')") + .andWhere('milestone.date <= :dueSoon', { dueSoon: dueSoonDate }) + .getMany(); + for (const milestone of milestones) + for (const userId of await this.targets( + milestone.projectId, + milestone.responsibleUserId, + )) + created += await this.deliver({ + projectId: milestone.projectId, + userId, + entityType: 'milestone', + entityId: milestone.id, + reminderType: + milestone.date < today ? 'MILESTONE_OVERDUE' : 'MILESTONE_DUE_SOON', + referenceDate: milestone.date, + notificationType: NotificationType.MilestoneAtRisk, + title: 'Meilenstein benötigt Aufmerksamkeit', + message: `Der Meilenstein „${milestone.title}“ steht an oder ist überfällig.`, + link: `/projekte/${milestone.projectId}/zeitplan`, + }); + const expenses = await this.dataSource + .getRepository(ExpenseEntity) + .createQueryBuilder('expense') + .where("expense.paymentStatus = 'open'") + .andWhere('expense.dueDate < :today', { today }) + .getMany(); + for (const expense of expenses) + if (expense.dueDate) + for (const userId of await this.targets(expense.projectId, null)) + created += await this.deliver({ + projectId: expense.projectId, + userId, + entityType: 'expense', + entityId: expense.id, + reminderType: 'EXPENSE_OVERDUE', + referenceDate: expense.dueDate, + notificationType: NotificationType.ExpenseOverdue, + title: 'Offene Ausgabe überfällig', + message: `Die Zahlung „${expense.title}“ ist überfällig.`, + link: `/projekte/${expense.projectId}/budget`, + }); + const invitations = await this.dataSource + .getRepository(ProjectInvitationEntity) + .createQueryBuilder('invitation') + .where('invitation.status = :status', { + status: InvitationStatus.Pending, + }) + .andWhere('invitation.invitedUserId IS NOT NULL') + .andWhere('DATE(invitation.expiresAt) BETWEEN :today AND :dueSoon', { + today, + dueSoon: dueSoonDate, + }) + .getMany(); + for (const invitation of invitations) + if (invitation.invitedUserId) + created += await this.deliver({ + projectId: invitation.projectId, + userId: invitation.invitedUserId, + entityType: 'invitation', + entityId: invitation.id, + reminderType: 'INVITATION_EXPIRING', + referenceDate: invitation.expiresAt.toISOString().slice(0, 10), + notificationType: NotificationType.InvitationExpiring, + title: 'Projekteinladung läuft bald ab', + message: 'Eine offene Projekteinladung läuft bald ab.', + link: '/einladungen', + }); + const furnitureOptions = await this.dataSource + .getRepository(FurnitureOptionEntity) + .createQueryBuilder('option') + .innerJoin( + FurnitureRequirementEntity, + 'requirement', + 'requirement.id = option.requirementId AND requirement.deletedAt IS NULL', + ) + .addSelect('requirement.responsibleUserId', 'responsibleUserId') + .where('option.deletedAt IS NULL') + .andWhere('option.expectedDeliveryDate <= :dueSoon', { + dueSoon: dueSoonDate, + }) + .andWhere( + "option.deliveryStatus IN ('ordered','shipped','partially_delivered','delayed')", + ) + .getRawAndEntities(); + for (const [index, option] of furnitureOptions.entities.entries()) { + if (!option.expectedDeliveryDate) continue; + const raw = furnitureOptions.raw[index] as { + responsibleUserId?: string | null; + }; + const overdue = option.expectedDeliveryDate < today; + for (const userId of await this.targets( + option.projectId, + raw.responsibleUserId ?? null, + )) + created += await this.deliver({ + projectId: option.projectId, + userId, + entityType: 'furniture_option', + entityId: option.id, + reminderType: overdue + ? 'FURNITURE_DELIVERY_DELAYED' + : 'FURNITURE_DELIVERY_DUE', + referenceDate: option.expectedDeliveryDate, + notificationType: overdue + ? NotificationType.FurnitureDeliveryDelayed + : NotificationType.FurnitureDeliveryDue, + title: overdue + ? 'Möbellieferung verspätet' + : 'Möbellieferung steht bevor', + message: `Die Lieferung „${option.name}“ ist ${overdue ? 'überfällig' : 'bald fällig'}.`, + link: `/projekte/${option.projectId}/moebel?option=${option.id}`, + }); + } + return { created }; + } + + private async deliver(input: ReminderInput): Promise { + const dedupeKey = `${input.reminderType}:${input.entityId}:${input.userId}:${input.referenceDate}`; + try { + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(ReminderDeliveryEntity).insert({ + projectId: input.projectId, + userId: input.userId, + entityType: input.entityType, + entityId: input.entityId, + reminderType: input.reminderType, + referenceDate: input.referenceDate, + dedupeKey, + }); + await this.notifications.createForUser( + { + userId: input.userId, + type: input.notificationType, + title: input.title, + message: input.message, + link: input.link, + metadata: { + projectId: input.projectId, + entityId: input.entityId, + dedupeKey, + }, + }, + manager, + ); + }); + return 1; + } catch (error: unknown) { + if ((error as { code?: string }).code === 'ER_DUP_ENTRY') return 0; + throw error; + } + } + + private async isActiveMember( + projectId: string, + userId: string, + manager?: EntityManager, + ) { + const membership = await this.projects.findMembership( + projectId, + userId, + manager, + ); + return membership?.active === true && membership.user.active; + } + + private async targets(projectId: string, responsibleUserId: string | null) { + if (responsibleUserId) + return (await this.isActiveMember(projectId, responsibleUserId)) + ? [responsibleUserId] + : []; + return (await this.projects.listMembers(projectId)) + .filter( + (member) => + member.active && + member.user.active && + [ProjectRole.Owner, ProjectRole.Administrator].includes(member.role), + ) + .map((member) => member.userId); + } +} diff --git a/apps/backend/src/renovation/renovation.controller.ts b/apps/backend/src/renovation/renovation.controller.ts new file mode 100644 index 0000000..ffdf732 --- /dev/null +++ b/apps/backend/src/renovation/renovation.controller.ts @@ -0,0 +1,415 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + Req, + Res, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import type { Response } from 'express'; +import type { AuthenticatedRequest } from '../auth/authenticated-request'; +import { RequirePermissions } from '../auth/guards/require-permissions.decorator'; +import { ApiError } from '../common/errors/api-error'; +import { ErrorCode } from '../common/errors/error-codes'; +import { Permission } from '../roles/permissions'; +import { + ApplyTemplateDto, + CalendarQueryDto, + CreateBudgetCategoryDto, + CreateBuildingDto, + CreateChecklistItemDto, + CreateCommentDto, + CreateDependencyDto, + CreateExpenseDto, + CreateFloorDto, + CreateMilestoneDto, + CreateRoomDto, + CreateTaskDto, + DocumentMetadataDto, + DocumentListQueryDto, + ExpenseListQueryDto, + FachListQueryDto, + MilestoneListQueryDto, + RoomListQueryDto, + TaskListQueryDto, + UpdateBudgetCategoryDto, + UpdateBuildingDto, + UpdateChecklistItemDto, + UpdateExpenseDto, + UpdateFloorDto, + UpdateMilestoneDto, + UpdateRoomDto, + UpdateTaskDto, +} from './dto/renovation.dto'; +import { RenovationService } from './renovation.service'; + +@Controller() +@RequirePermissions(Permission.ProjectsUse) +export class RenovationController { + constructor(private readonly service: RenovationService) {} + + @Get('projects/:projectId/buildings') buildings( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + ) { + return this.service.buildings(p, this.user(r)); + } + @Post('projects/:projectId/buildings') createBuilding( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + @Body() d: CreateBuildingDto, + ) { + return this.service.createBuilding(p, this.user(r), d); + } + @Patch('projects/:projectId/buildings/:id') updateBuilding( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + @Param('id') id: string, + @Body() d: UpdateBuildingDto, + ) { + return this.service.updateBuilding(p, id, this.user(r), d); + } + @Delete('projects/:projectId/buildings/:id') deleteBuilding( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + @Param('id') id: string, + ) { + return this.service.deleteBuilding(p, id, this.user(r)); + } + + @Get('projects/:projectId/floors') floors( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + ) { + return this.service.floors(p, this.user(r)); + } + @Post('projects/:projectId/floors/defaults') defaultFloors( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + ) { + return this.service.ensureDefaultFloors(p, this.user(r)); + } + @Post('projects/:projectId/floors') createFloor( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + @Body() d: CreateFloorDto, + ) { + return this.service.createFloor(p, this.user(r), d); + } + @Patch('projects/:projectId/floors/:id') updateFloor( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + @Param('id') id: string, + @Body() d: UpdateFloorDto, + ) { + return this.service.updateFloor(p, id, this.user(r), d); + } + @Delete('projects/:projectId/floors/:id') deleteFloor( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + @Param('id') id: string, + ) { + return this.service.deleteFloor(p, id, this.user(r)); + } + + @Get('projects/:projectId/rooms') rooms( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + @Query() q: RoomListQueryDto, + ) { + return this.service.rooms(p, this.user(r), q); + } + @Post('projects/:projectId/rooms') createRoom( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + @Body() d: CreateRoomDto, + ) { + return this.service.createRoom(p, this.user(r), d); + } + @Patch('projects/:projectId/rooms/:id') updateRoom( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + @Param('id') id: string, + @Body() d: UpdateRoomDto, + ) { + return this.service.updateRoom(p, id, this.user(r), d); + } + @Delete('projects/:projectId/rooms/:id') deleteRoom( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + @Param('id') id: string, + ) { + return this.service.deleteRoom(p, id, this.user(r)); + } + + @Get('projects/:projectId/tasks') tasks( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + @Query() q: TaskListQueryDto, + ) { + return this.service.tasks(p, this.user(r), q); + } + @Get('projects/:projectId/tasks/:id') task( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + @Param('id') id: string, + ) { + return this.service.taskDetail(p, id, this.user(r)); + } + @Post('projects/:projectId/tasks') createTask( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + @Body() d: CreateTaskDto, + ) { + return this.service.createTask(p, this.user(r), d); + } + @Patch('projects/:projectId/tasks/:id') updateTask( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + @Param('id') id: string, + @Body() d: UpdateTaskDto, + ) { + return this.service.updateTask(p, id, this.user(r), d); + } + @Delete('projects/:projectId/tasks/:id') deleteTask( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + @Param('id') id: string, + ) { + return this.service.deleteTask(p, id, this.user(r)); + } + + @Get('projects/:projectId/tasks/:taskId/checklist') checklist( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + @Param('taskId') t: string, + ) { + return this.service.checklist(p, t, this.user(r)); + } + @Post('projects/:projectId/tasks/:taskId/checklist') addChecklist( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + @Param('taskId') t: string, + @Body() d: CreateChecklistItemDto, + ) { + return this.service.addChecklist(p, t, this.user(r), d); + } + @Patch('projects/:projectId/tasks/:taskId/checklist/:id') updateChecklist( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + @Param('taskId') t: string, + @Param('id') id: string, + @Body() d: UpdateChecklistItemDto, + ) { + return this.service.updateChecklist(p, t, id, this.user(r), d); + } + + @Get('projects/:projectId/tasks/:taskId/dependencies') dependencies( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + @Param('taskId') t: string, + ) { + return this.service.dependencies(p, t, this.user(r)); + } + @Post('projects/:projectId/tasks/:taskId/dependencies') addDependency( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + @Param('taskId') t: string, + @Body() d: CreateDependencyDto, + ) { + return this.service.addDependency(p, t, this.user(r), d); + } + @Delete('projects/:projectId/tasks/:taskId/dependencies/:id') + removeDependency( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + @Param('taskId') t: string, + @Param('id') id: string, + ) { + return this.service.removeDependency(p, t, id, this.user(r)); + } + + @Get('projects/:projectId/tasks/:taskId/comments') comments( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + @Param('taskId') t: string, + @Query() q: FachListQueryDto, + ) { + return this.service.comments(p, t, this.user(r), q); + } + @Post('projects/:projectId/tasks/:taskId/comments') addComment( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + @Param('taskId') t: string, + @Body() d: CreateCommentDto, + ) { + return this.service.addComment(p, t, this.user(r), d); + } + @Patch('projects/:projectId/tasks/:taskId/comments/:id') updateComment( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + @Param('taskId') t: string, + @Param('id') id: string, + @Body() d: CreateCommentDto, + ) { + return this.service.updateComment(p, t, id, this.user(r), d); + } + @Delete('projects/:projectId/tasks/:taskId/comments/:id') deleteComment( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + @Param('taskId') t: string, + @Param('id') id: string, + ) { + return this.service.deleteComment(p, t, id, this.user(r)); + } + + @Get('projects/:projectId/milestones') milestones( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + @Query() q: MilestoneListQueryDto, + ) { + return this.service.milestones(p, this.user(r), q); + } + @Post('projects/:projectId/milestones') createMilestone( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + @Body() d: CreateMilestoneDto, + ) { + return this.service.createMilestone(p, this.user(r), d); + } + @Patch('projects/:projectId/milestones/:id') updateMilestone( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + @Param('id') id: string, + @Body() d: UpdateMilestoneDto, + ) { + return this.service.updateMilestone(p, id, this.user(r), d); + } + + @Get('projects/:projectId/budget-categories') budgets( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + ) { + return this.service.budgetCategories(p, this.user(r)); + } + @Post('projects/:projectId/budget-categories') createBudget( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + @Body() d: CreateBudgetCategoryDto, + ) { + return this.service.createBudget(p, this.user(r), d); + } + @Patch('projects/:projectId/budget-categories/:id') updateBudget( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + @Param('id') id: string, + @Body() d: UpdateBudgetCategoryDto, + ) { + return this.service.updateBudget(p, id, this.user(r), d); + } + + @Get('projects/:projectId/expenses') expenses( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + @Query() q: ExpenseListQueryDto, + ) { + return this.service.expenses(p, this.user(r), q); + } + @Post('projects/:projectId/expenses') createExpense( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + @Body() d: CreateExpenseDto, + ) { + return this.service.createExpense(p, this.user(r), d); + } + @Patch('projects/:projectId/expenses/:id') updateExpense( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + @Param('id') id: string, + @Body() d: UpdateExpenseDto, + ) { + return this.service.updateExpense(p, id, this.user(r), d); + } + + @Get('projects/:projectId/documents') documents( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + @Query() q: DocumentListQueryDto, + ) { + return this.service.documents(p, this.user(r), q); + } + @Post('projects/:projectId/documents') + @UseInterceptors( + FileInterceptor('file', { + limits: { fileSize: 50 * 1024 * 1024, files: 1 }, + }), + ) + upload( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + @Body() d: DocumentMetadataDto, + @UploadedFile() file?: Express.Multer.File, + ) { + return this.service.uploadDocument(p, this.user(r), d, file); + } + @Get('projects/:projectId/documents/:id/download') async download( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + @Param('id') id: string, + @Res() response: Response, + ) { + const result = await this.service.downloadDocument(p, id, this.user(r)); + response.type(result.document.mimeType); + response.attachment(result.document.originalFilename); + response.send(result.data); + } + @Delete('projects/:projectId/documents/:id') deleteDocument( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + @Param('id') id: string, + ) { + return this.service.deleteDocument(p, id, this.user(r)); + } + + @Get('projects/:projectId/dashboard') dashboard( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + ) { + return this.service.dashboard(p, this.user(r)); + } + @Get('projects/:projectId/calendar') calendar( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + @Query() q: CalendarQueryDto, + ) { + return this.service.calendar(p, this.user(r), q); + } + @Get('templates') templates() { + return this.service.templates(); + } + @Post('projects/:projectId/apply-template/:templateId') template( + @Req() r: AuthenticatedRequest, + @Param('projectId') p: string, + @Param('templateId') t: string, + @Body() d: ApplyTemplateDto, + ) { + return this.service.applyTemplate(p, t, this.user(r), d); + } + + private user(request: AuthenticatedRequest) { + if (!request.user) + throw new ApiError( + ErrorCode.Unauthorized, + 'Bitte melden Sie sich an.', + 401, + ); + return request.user.id; + } +} diff --git a/apps/backend/src/renovation/renovation.module.ts b/apps/backend/src/renovation/renovation.module.ts new file mode 100644 index 0000000..4aa7675 --- /dev/null +++ b/apps/backend/src/renovation/renovation.module.ts @@ -0,0 +1,98 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ScheduleModule } from '@nestjs/schedule'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { ProjectsModule } from '../projects/projects.module'; +import { ProjectActivityEntity } from '../projects/entities/project-activity.entity'; +import { ProjectInvitationEntity } from '../projects/entities/project-invitation.entity'; +import { ProjectMembershipEntity } from '../projects/entities/project-membership.entity'; +import { ProjectEntity } from '../projects/entities/project.entity'; +import { UserEntity } from '../users/entities/user.entity'; +import { UsersRepository } from '../users/repositories/users.repository'; +import { ProjectAccessService } from '../projects/project-access.service'; +import { ProjectsRepository } from '../projects/repositories/projects.repository'; +import { DocumentStorageService } from './document-storage.service'; +import { + BudgetCategoryEntity, + BuildingEntity, + ChecklistItemEntity, + ExpenseEntity, + FloorEntity, + MilestoneEntity, + ProjectDocumentEntity, + RenovationTaskEntity, + RoomEntity, + TaskCommentEntity, + TaskDependencyEntity, + TaskCommentMentionEntity, + ReminderDeliveryEntity, + AppliedProjectTemplateEntity, +} from './entities/renovation.entities'; +import { RenovationController } from './renovation.controller'; +import { RenovationRepository } from './renovation.repository'; +import { RenovationService } from './renovation.service'; +import { ReminderService } from './reminder.service'; +import { DevelopmentSeedService } from './development-seed.service'; +import { FurnitureController } from './furniture.controller'; +import { FurnitureRepository } from './furniture.repository'; +import { FurnitureService } from './furniture.service'; +import { + FurnitureOptionDocumentEntity, + FurnitureOptionEntity, + FurnitureRequirementEntity, + FurnitureScenarioEntity, + FurnitureScenarioSelectionEntity, +} from './entities/furniture.entities'; + +const renovationEntities = [ + BuildingEntity, + FloorEntity, + RoomEntity, + RenovationTaskEntity, + ChecklistItemEntity, + TaskDependencyEntity, + TaskCommentEntity, + MilestoneEntity, + BudgetCategoryEntity, + ExpenseEntity, + ProjectDocumentEntity, + TaskCommentMentionEntity, + ReminderDeliveryEntity, + AppliedProjectTemplateEntity, + FurnitureRequirementEntity, + FurnitureOptionEntity, + FurnitureScenarioEntity, + FurnitureScenarioSelectionEntity, + FurnitureOptionDocumentEntity, +]; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + ...renovationEntities, + ProjectEntity, + ProjectMembershipEntity, + ProjectInvitationEntity, + ProjectActivityEntity, + UserEntity, + ]), + NotificationsModule, + ProjectsModule, + ScheduleModule.forRoot(), + ], + controllers: [RenovationController, FurnitureController], + providers: [ + RenovationService, + RenovationRepository, + DocumentStorageService, + ProjectAccessService, + ProjectsRepository, + UsersRepository, + ReminderService, + DevelopmentSeedService, + FurnitureRepository, + FurnitureService, + ], + exports: [DevelopmentSeedService], +}) +export class RenovationModule {} diff --git a/apps/backend/src/renovation/renovation.repository.ts b/apps/backend/src/renovation/renovation.repository.ts new file mode 100644 index 0000000..d7a125e --- /dev/null +++ b/apps/backend/src/renovation/renovation.repository.ts @@ -0,0 +1,289 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { IsNull, Repository } from 'typeorm'; +import type { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; +import type { + DocumentListQueryDto, + ExpenseListQueryDto, + FachListQueryDto, + MilestoneListQueryDto, + RoomListQueryDto, + TaskListQueryDto, +} from './dto/renovation.dto'; +import { + BudgetCategoryEntity, + BuildingEntity, + ChecklistItemEntity, + ExpenseEntity, + FloorEntity, + MilestoneEntity, + ProjectDocumentEntity, + RenovationTaskEntity, + RoomEntity, + TaskCommentEntity, + TaskCommentMentionEntity, + TaskDependencyEntity, + ReminderDeliveryEntity, + AppliedProjectTemplateEntity, +} from './entities/renovation.entities'; + +@Injectable() +export class RenovationRepository { + constructor( + @InjectRepository(BuildingEntity) + readonly buildings: Repository, + @InjectRepository(FloorEntity) readonly floors: Repository, + @InjectRepository(RoomEntity) readonly rooms: Repository, + @InjectRepository(RenovationTaskEntity) + readonly tasks: Repository, + @InjectRepository(ChecklistItemEntity) + readonly checklist: Repository, + @InjectRepository(TaskDependencyEntity) + readonly dependencies: Repository, + @InjectRepository(TaskCommentEntity) + readonly comments: Repository, + @InjectRepository(MilestoneEntity) + readonly milestones: Repository, + @InjectRepository(BudgetCategoryEntity) + readonly budgets: Repository, + @InjectRepository(ExpenseEntity) + readonly expenses: Repository, + @InjectRepository(ProjectDocumentEntity) + readonly documents: Repository, + @InjectRepository(TaskCommentMentionEntity) + readonly mentions: Repository, + @InjectRepository(ReminderDeliveryEntity) + readonly reminders: Repository, + @InjectRepository(AppliedProjectTemplateEntity) + readonly appliedTemplates: Repository, + ) {} + + listBuildings(projectId: string) { + return this.buildings.find({ + where: { projectId }, + order: { sortOrder: 'ASC' }, + }); + } + listFloors(projectId: string) { + return this.floors.find({ + where: { projectId }, + order: { sortOrder: 'ASC' }, + }); + } + listRooms(projectId: string) { + return this.rooms.find({ + where: { projectId, deletedAt: IsNull() }, + order: { sortOrder: 'ASC' }, + }); + } + listTasks(projectId: string) { + return this.tasks.find({ + where: { projectId, deletedAt: IsNull() }, + order: { dueDate: 'ASC', createdAt: 'DESC' }, + }); + } + listMilestones(projectId: string) { + return this.milestones.find({ + where: { projectId }, + order: { date: 'ASC' }, + }); + } + listBudgets(projectId: string) { + return this.budgets.find({ + where: { projectId, active: true }, + order: { sortOrder: 'ASC' }, + }); + } + listExpenses(projectId: string) { + return this.expenses.find({ + where: { projectId }, + order: { expenseDate: 'DESC' }, + }); + } + listDocuments(projectId: string) { + return this.documents.find({ + where: { projectId, deletedAt: IsNull() }, + order: { createdAt: 'DESC' }, + }); + } + + pageRooms(projectId: string, query: RoomListQueryDto) { + const qb = this.rooms + .createQueryBuilder('room') + .where('room.projectId = :projectId', { projectId }) + .andWhere('room.deletedAt IS NULL'); + if (query.search) + qb.andWhere('(room.name LIKE :search OR room.description LIKE :search)', { + search: `%${query.search}%`, + }); + if (query.floorId) + qb.andWhere('room.floorId = :floorId', { floorId: query.floorId }); + if (query.status) + qb.andWhere('room.status = :status', { status: query.status }); + return this.page(qb, query, `room.${query.sortBy}`); + } + + pageTasks(projectId: string, userId: string, query: TaskListQueryDto) { + const qb = this.tasks + .createQueryBuilder('task') + .where('task.projectId = :projectId', { projectId }) + .andWhere('task.deletedAt IS NULL'); + if (query.search) + qb.andWhere( + '(task.title LIKE :search OR task.description LIKE :search)', + { search: `%${query.search}%` }, + ); + if (query.statuses?.length) + qb.andWhere('task.status IN (:...statuses)', { + statuses: query.statuses, + }); + if (query.priority) + qb.andWhere('task.priority = :priority', { priority: query.priority }); + if (query.roomId) + qb.andWhere('task.roomId = :roomId', { roomId: query.roomId }); + if (query.assigneeUserId) + qb.andWhere('task.assigneeUserId = :assigneeUserId', { + assigneeUserId: query.assigneeUserId, + }); + if (query.category) + qb.andWhere('task.category = :category', { category: query.category }); + if (query.startFrom) + qb.andWhere('task.plannedStartDate >= :startFrom', { + startFrom: query.startFrom, + }); + if (query.dueFrom) + qb.andWhere('task.dueDate >= :dueFrom', { dueFrom: query.dueFrom }); + if (query.dueTo) + qb.andWhere('task.dueDate <= :dueTo', { dueTo: query.dueTo }); + if (query.overdue) + qb.andWhere('task.dueDate < CURRENT_DATE()').andWhere( + "task.status NOT IN ('done','omitted')", + ); + if (query.unassigned) qb.andWhere('task.assigneeUserId IS NULL'); + if (query.mine) + qb.andWhere('task.assigneeUserId = :currentUserId', { + currentUserId: userId, + }); + if (query.blocked) + qb.andWhere( + `EXISTS (SELECT 1 FROM task_dependencies dependency INNER JOIN renovation_tasks predecessor ON predecessor.id = dependency.predecessor_task_id WHERE dependency.successor_task_id = task.id AND predecessor.status <> 'done' AND predecessor.deleted_at IS NULL)`, + ); + return this.page(qb, query, `task.${query.sortBy}`); + } + + pageMilestones(projectId: string, query: MilestoneListQueryDto) { + const qb = this.milestones + .createQueryBuilder('milestone') + .where('milestone.projectId = :projectId', { projectId }); + if (query.search) + qb.andWhere( + '(milestone.title LIKE :search OR milestone.description LIKE :search)', + { search: `%${query.search}%` }, + ); + if (query.status) + qb.andWhere('milestone.status = :status', { status: query.status }); + if (query.from) + qb.andWhere('milestone.date >= :from', { from: query.from }); + if (query.to) qb.andWhere('milestone.date <= :to', { to: query.to }); + return this.page(qb, query, `milestone.${query.sortBy}`); + } + + pageExpenses(projectId: string, query: ExpenseListQueryDto) { + const qb = this.expenses + .createQueryBuilder('expense') + .where('expense.projectId = :projectId', { projectId }); + if (query.search) + qb.andWhere( + '(expense.title LIKE :search OR expense.description LIKE :search OR expense.supplier LIKE :search)', + { search: `%${query.search}%` }, + ); + if (query.categoryId) + qb.andWhere('expense.budgetCategoryId = :categoryId', { + categoryId: query.categoryId, + }); + if (query.roomId) + qb.andWhere('expense.roomId = :roomId', { roomId: query.roomId }); + if (query.taskId) + qb.andWhere('expense.taskId = :taskId', { taskId: query.taskId }); + if (query.paymentStatus) + qb.andWhere('expense.paymentStatus = :paymentStatus', { + paymentStatus: query.paymentStatus, + }); + if (query.supplier) + qb.andWhere('expense.supplier LIKE :supplier', { + supplier: `%${query.supplier}%`, + }); + if (query.createdByUserId) + qb.andWhere('expense.createdByUserId = :createdByUserId', { + createdByUserId: query.createdByUserId, + }); + if (query.from) + qb.andWhere('expense.expenseDate >= :from', { from: query.from }); + if (query.to) qb.andWhere('expense.expenseDate <= :to', { to: query.to }); + if (query.dueFrom) + qb.andWhere('expense.dueDate >= :dueFrom', { dueFrom: query.dueFrom }); + if (query.dueTo) + qb.andWhere('expense.dueDate <= :dueTo', { dueTo: query.dueTo }); + return this.page(qb, query, `expense.${query.sortBy}`); + } + + pageDocuments(projectId: string, query: DocumentListQueryDto) { + const qb = this.documents + .createQueryBuilder('document') + .where('document.projectId = :projectId', { projectId }) + .andWhere('document.deletedAt IS NULL'); + if (query.search) + qb.andWhere( + '(document.title LIKE :search OR document.description LIKE :search OR document.originalFilename LIKE :search)', + { search: `%${query.search}%` }, + ); + if (query.type) qb.andWhere('document.type = :type', { type: query.type }); + if (query.roomId) + qb.andWhere('document.roomId = :roomId', { roomId: query.roomId }); + if (query.taskId) + qb.andWhere('document.taskId = :taskId', { taskId: query.taskId }); + if (query.uploadedByUserId) + qb.andWhere('document.uploadedByUserId = :uploadedByUserId', { + uploadedByUserId: query.uploadedByUserId, + }); + if (query.from) + qb.andWhere('document.uploadedAt >= :from', { from: query.from }); + if (query.to) + qb.andWhere('document.uploadedAt < DATE_ADD(:to, INTERVAL 1 DAY)', { + to: query.to, + }); + return this.page(qb, query, `document.${query.sortBy}`); + } + + pageComments(projectId: string, taskId: string, query: FachListQueryDto) { + const qb = this.comments + .createQueryBuilder('comment') + .where('comment.projectId = :projectId AND comment.taskId = :taskId', { + projectId, + taskId, + }) + .andWhere('comment.deletedAt IS NULL'); + if (query.search) + qb.andWhere('comment.text LIKE :search', { search: `%${query.search}%` }); + return this.page(qb, query, 'comment.createdAt'); + } + + private async page( + qb: SelectQueryBuilder, + query: FachListQueryDto, + sort: string, + ) { + const [items, totalItems] = await qb + .orderBy(sort, query.sortDirection) + .skip((query.page - 1) * query.pageSize) + .take(query.pageSize) + .getManyAndCount(); + return { + items, + page: query.page, + pageSize: query.pageSize, + totalItems, + totalPages: Math.ceil(totalItems / query.pageSize), + }; + } +} diff --git a/apps/backend/src/renovation/renovation.service.ts b/apps/backend/src/renovation/renovation.service.ts new file mode 100644 index 0000000..e2a2850 --- /dev/null +++ b/apps/backend/src/renovation/renovation.service.ts @@ -0,0 +1,1702 @@ +import { Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource, EntityManager } from 'typeorm'; +import { ApiError } from '../common/errors/api-error'; +import { ErrorCode } from '../common/errors/error-codes'; +import { NotificationType } from '../notifications/notification-types'; +import { NotificationsService } from '../notifications/notifications.service'; +import { ProjectActivityEntity } from '../projects/entities/project-activity.entity'; +import { ProjectRole } from '../projects/entities/project-membership.entity'; +import { ProjectAccessService } from '../projects/project-access.service'; +import { ProjectsRepository } from '../projects/repositories/projects.repository'; +import { ProjectEntity } from '../projects/entities/project.entity'; +import { + ApplyTemplateDto, + CreateBudgetCategoryDto, + CreateBuildingDto, + CreateChecklistItemDto, + CalendarQueryDto, + DocumentListQueryDto, + DocumentMetadataDto, + ExpenseListQueryDto, + FachListQueryDto, + MilestoneListQueryDto, + RoomListQueryDto, + TaskListQueryDto, + CreateCommentDto, + CreateDependencyDto, + CreateExpenseDto, + CreateFloorDto, + CreateMilestoneDto, + CreateRoomDto, + CreateTaskDto, + UpdateBudgetCategoryDto, + UpdateBuildingDto, + UpdateChecklistItemDto, + UpdateExpenseDto, + UpdateFloorDto, + UpdateMilestoneDto, + UpdateRoomDto, + UpdateTaskDto, + UpdateDocumentMetadataDto, +} from './dto/renovation.dto'; +import { + ProjectDocumentEntity, + AppliedProjectTemplateEntity, + BudgetCategoryEntity, + BuildingEntity, + BuildingType, + FloorEntity, + MilestoneEntity, + RenovationTaskEntity, + RoomEntity, + RoomStatus, + TaskPriority, + TaskStatus, + TaskCommentMentionEntity, +} from './entities/renovation.entities'; +import { calculateProgress, wouldCreateDependencyCycle } from './progress'; +import { RenovationRepository } from './renovation.repository'; +import { DocumentStorageService } from './document-storage.service'; +import { + handoverTasks, + movingTasks, + roomRenovationTasks, + standardBudgets, + terracedHouseFloors, +} from './templates'; +import type { TaskTemplate } from './templates'; +import { newMentionUserIds } from './mentions'; +import { FurnitureService } from './furniture.service'; +import { FurnitureRequirementEntity } from './entities/furniture.entities'; + +@Injectable() +export class RenovationService { + constructor( + private readonly repo: RenovationRepository, + private readonly access: ProjectAccessService, + private readonly projects: ProjectsRepository, + private readonly notifications: NotificationsService, + private readonly storage: DocumentStorageService, + private readonly furniture: FurnitureService, + @InjectDataSource() private readonly dataSource: DataSource, + ) {} + + async buildings(projectId: string, userId: string) { + await this.read(projectId, userId); + return this.repo.listBuildings(projectId); + } + async floors(projectId: string, userId: string) { + await this.read(projectId, userId); + return this.repo.listFloors(projectId); + } + + async ensureDefaultFloors(projectId: string, userId: string) { + await this.edit(projectId, userId); + return this.dataSource.transaction(async (manager) => { + const buildings = manager.getRepository(BuildingEntity); + const floors = manager.getRepository(FloorEntity); + let building = await buildings.findOne({ + where: { projectId }, + order: { sortOrder: 'ASC' }, + }); + if (!building) { + building = await buildings.save( + buildings.create({ + projectId, + name: 'Haus', + description: null, + type: BuildingType.TerracedHouse, + sortOrder: 0, + }), + ); + } + const existing = await floors.find({ where: { projectId } }); + const existingNames = new Set( + existing.map((floor) => floor.name.toLocaleLowerCase('de-DE')), + ); + const created: FloorEntity[] = []; + for (const [sortOrder, name] of [ + 'Keller', + 'Erdgeschoss', + '1. Stock', + 'Dachboden', + ].entries()) { + if (existingNames.has(name.toLocaleLowerCase('de-DE'))) continue; + created.push( + await floors.save( + floors.create({ + projectId, + buildingId: building.id, + name, + description: null, + sortOrder, + }), + ), + ); + } + await this.activity( + projectId, + userId, + 'floors.defaults-created', + { createdCount: created.length }, + manager, + ); + return { + created, + floors: await floors.find({ + where: { projectId }, + order: { sortOrder: 'ASC' }, + }), + }; + }); + } + async rooms(projectId: string, userId: string, query: RoomListQueryDto) { + await this.read(projectId, userId); + return this.repo.pageRooms(projectId, query); + } + async tasks(projectId: string, userId: string, query: TaskListQueryDto) { + await this.read(projectId, userId); + const page = await this.repo.pageTasks(projectId, userId, query); + return { ...page, items: await this.enrichTasks(projectId, page.items) }; + } + async taskDetail(projectId: string, taskId: string, userId: string) { + await this.read(projectId, userId); + const task = await this.requireOwned(this.repo.tasks, projectId, taskId); + const [ + checklist, + dependencies, + comments, + documents, + activities, + members, + allTasks, + ] = await Promise.all([ + this.repo.checklist.find({ + where: { projectId, taskId }, + order: { sortOrder: 'ASC' }, + }), + this.repo.dependencies.find({ + where: [ + { projectId, predecessorTaskId: taskId }, + { projectId, successorTaskId: taskId }, + ], + }), + this.repo.comments.find({ + where: { projectId, taskId }, + order: { createdAt: 'ASC' }, + }), + this.repo.documents.find({ + where: { projectId, taskId }, + order: { uploadedAt: 'DESC' }, + }), + this.projects.listActivities(projectId, 100), + this.projects.listMembers(projectId), + this.repo.listTasks(projectId), + ]); + const taskMap = new Map(allTasks.map((entry) => [entry.id, entry])); + const memberMap = new Map( + members.map((entry) => [entry.userId, entry.user.name]), + ); + const mentions = comments.length + ? await this.repo.mentions + .createQueryBuilder('mention') + .where('mention.commentId IN (:...commentIds)', { + commentIds: comments.map((comment) => comment.id), + }) + .getMany() + : []; + return { + ...(await this.enrichTasks(projectId, [task]))[0], + checklist, + dependencies: dependencies.map((dependency) => ({ + ...dependency, + predecessor: this.taskReference( + taskMap.get(dependency.predecessorTaskId), + memberMap, + ), + successor: this.taskReference( + taskMap.get(dependency.successorTaskId), + memberMap, + ), + })), + comments: comments.map((comment) => ({ + ...comment, + authorName: + memberMap.get(comment.authorUserId) ?? 'Ehemaliges Projektmitglied', + mentionedUserIds: mentions + .filter((mention) => mention.commentId === comment.id) + .map((mention) => mention.userId), + })), + documents, + activities: activities + .filter((activity) => activity.metadata?.['taskId'] === taskId) + .map((activity) => ({ + id: activity.id, + action: activity.action, + actorName: activity.actorUser?.name ?? 'Ehemaliges Projektmitglied', + createdAt: activity.createdAt.toISOString(), + metadata: activity.metadata, + })), + }; + } + + async calendar(projectId: string, userId: string, query: CalendarQueryDto) { + await this.read(projectId, userId); + if (query.from > query.to) + throw new ApiError( + ErrorCode.ValidationFailed, + 'Der Kalenderzeitraum ist ungültig.', + 400, + ); + const types = new Set( + query.types ?? ['task_start', 'task_due', 'milestone', 'expense_due'], + ); + const events: Array<{ + id: string; + entityId: string; + type: string; + title: string; + date: string; + status: string; + roomId: string | null; + assigneeUserId: string | null; + link: string; + }> = []; + if (types.has('task_start') || types.has('task_due')) { + const qb = this.repo.tasks + .createQueryBuilder('task') + .where('task.projectId = :projectId', { projectId }) + .andWhere('task.deletedAt IS NULL') + .andWhere( + '((task.plannedStartDate BETWEEN :from AND :to) OR (task.dueDate BETWEEN :from AND :to))', + query, + ); + if (query.roomId) + qb.andWhere('task.roomId = :roomId', { roomId: query.roomId }); + if (query.assigneeUserId) + qb.andWhere('task.assigneeUserId = :assigneeUserId', { + assigneeUserId: query.assigneeUserId, + }); + for (const task of await qb.getMany()) { + if ( + types.has('task_start') && + task.plannedStartDate && + task.plannedStartDate >= query.from && + task.plannedStartDate <= query.to + ) + events.push({ + id: `task-start:${task.id}`, + entityId: task.id, + type: 'task_start', + title: task.title, + date: task.plannedStartDate, + status: task.status, + roomId: task.roomId, + assigneeUserId: task.assigneeUserId, + link: `/projekte/${projectId}/aufgaben/${task.id}`, + }); + if ( + types.has('task_due') && + task.dueDate && + task.dueDate >= query.from && + task.dueDate <= query.to + ) + events.push({ + id: `task-due:${task.id}`, + entityId: task.id, + type: 'task_due', + title: task.title, + date: task.dueDate, + status: task.status, + roomId: task.roomId, + assigneeUserId: task.assigneeUserId, + link: `/projekte/${projectId}/aufgaben/${task.id}`, + }); + } + } + if (types.has('milestone')) + for (const milestone of await this.repo.milestones + .createQueryBuilder('milestone') + .where( + 'milestone.projectId = :projectId AND milestone.date BETWEEN :from AND :to', + { projectId, from: query.from, to: query.to }, + ) + .getMany()) + events.push({ + id: `milestone:${milestone.id}`, + entityId: milestone.id, + type: 'milestone', + title: milestone.title, + date: milestone.date, + status: milestone.status, + roomId: null, + assigneeUserId: milestone.responsibleUserId, + link: `/projekte/${projectId}/zeitplan`, + }); + if (types.has('expense_due')) + for (const expense of await this.repo.expenses + .createQueryBuilder('expense') + .where( + "expense.projectId = :projectId AND expense.dueDate BETWEEN :from AND :to AND expense.paymentStatus = 'open'", + { projectId, from: query.from, to: query.to }, + ) + .getMany()) + if (expense.dueDate) + events.push({ + id: `expense:${expense.id}`, + entityId: expense.id, + type: 'expense_due', + title: expense.title, + date: expense.dueDate, + status: expense.paymentStatus, + roomId: expense.roomId, + assigneeUserId: null, + link: `/projekte/${projectId}/budget`, + }); + return events.sort( + (left, right) => + left.date.localeCompare(right.date) || + left.title.localeCompare(right.title), + ); + } + async milestones( + projectId: string, + userId: string, + query: MilestoneListQueryDto, + ) { + await this.read(projectId, userId); + return this.repo.pageMilestones(projectId, query); + } + async budgetCategories(projectId: string, userId: string) { + await this.read(projectId, userId); + return this.repo.listBudgets(projectId); + } + async expenses( + projectId: string, + userId: string, + query: ExpenseListQueryDto, + ) { + await this.read(projectId, userId); + return this.repo.pageExpenses(projectId, query); + } + async documents( + projectId: string, + userId: string, + query: DocumentListQueryDto, + ) { + await this.read(projectId, userId); + return this.repo.pageDocuments(projectId, query); + } + + async uploadDocument( + projectId: string, + userId: string, + dto: DocumentMetadataDto, + file?: Express.Multer.File, + ) { + await this.edit(projectId, userId); + if (!file) + throw new ApiError( + ErrorCode.ValidationFailed, + 'Bitte wählen Sie eine Datei aus.', + 400, + ); + if (dto.roomId) + await this.requireOwned(this.repo.rooms, projectId, dto.roomId); + if (dto.taskId) + await this.requireOwned(this.repo.tasks, projectId, dto.taskId); + const stored = await this.storage.store(file); + try { + const entity = this.repo.documents.create({ + projectId, + roomId: dto.roomId ?? null, + taskId: dto.taskId ?? null, + type: dto.type, + title: dto.title.trim(), + description: dto.description?.trim() || null, + originalFilename: file.originalname.slice(0, 255), + storageName: stored.storageName, + mimeType: file.mimetype, + fileSize: file.size, + storageReference: stored.storageReference, + uploadedByUserId: userId, + uploadedAt: new Date(), + }); + const saved = await this.repo.documents.save(entity); + await this.activity(projectId, userId, 'document.uploaded', { + documentId: saved.id, + title: saved.title, + }); + return saved; + } catch (error: unknown) { + await this.storage.remove(stored.storageReference); + throw error; + } + } + async downloadDocument( + projectId: string, + id: string, + userId: string, + ): Promise<{ document: ProjectDocumentEntity; data: Buffer }> { + await this.read(projectId, userId); + const document = await this.requireOwned( + this.repo.documents, + projectId, + id, + ); + return { + document, + data: await this.storage.read(document.storageReference), + }; + } + async deleteDocument(projectId: string, id: string, userId: string) { + await this.edit(projectId, userId); + const document = await this.requireOwned( + this.repo.documents, + projectId, + id, + ); + await this.repo.documents.softDelete({ id, projectId }); + await this.storage.remove(document.storageReference); + await this.activity(projectId, userId, 'document.deleted', { + documentId: id, + }); + } + + async updateDocument( + projectId: string, + id: string, + userId: string, + dto: UpdateDocumentMetadataDto, + ) { + await this.edit(projectId, userId); + if (dto.roomId) + await this.requireOwned(this.repo.rooms, projectId, dto.roomId); + if (dto.taskId) + await this.requireOwned(this.repo.tasks, projectId, dto.taskId); + await this.versioned(this.repo.documents, projectId, id, dto.version, { + roomId: dto.roomId ?? null, + taskId: dto.taskId ?? null, + type: dto.type, + title: dto.title.trim(), + description: dto.description?.trim() || null, + }); + await this.activity(projectId, userId, 'document.updated', { + documentId: id, + }); + return this.requireOwned(this.repo.documents, projectId, id); + } + + async createBuilding( + projectId: string, + userId: string, + dto: CreateBuildingDto, + ) { + await this.edit(projectId, userId); + const entity = this.repo.buildings.create({ + projectId, + name: dto.name.trim(), + description: dto.description?.trim() || null, + type: dto.type, + sortOrder: dto.sortOrder ?? 0, + }); + const saved = await this.repo.buildings.save(entity); + await this.activity(projectId, userId, 'building.created', { + buildingId: saved.id, + name: saved.name, + }); + return saved; + } + async updateBuilding( + projectId: string, + id: string, + userId: string, + dto: UpdateBuildingDto, + ) { + await this.edit(projectId, userId); + await this.versioned(this.repo.buildings, projectId, id, dto.version, { + name: dto.name.trim(), + description: dto.description?.trim() || null, + type: dto.type, + sortOrder: dto.sortOrder ?? 0, + }); + return this.requireOwned(this.repo.buildings, projectId, id); + } + async deleteBuilding(projectId: string, id: string, userId: string) { + await this.edit(projectId, userId); + await this.requireOwned(this.repo.buildings, projectId, id); + if (await this.repo.floors.exist({ where: { projectId, buildingId: id } })) + this.conflict( + 'Das Gebäude enthält Etagen und kann nicht gelöscht werden.', + ); + await this.repo.buildings.delete({ id, projectId }); + await this.activity(projectId, userId, 'building.deleted', { + buildingId: id, + }); + } + + async createFloor(projectId: string, userId: string, dto: CreateFloorDto) { + await this.edit(projectId, userId); + await this.requireOwned(this.repo.buildings, projectId, dto.buildingId); + const saved = await this.repo.floors.save( + this.repo.floors.create({ + projectId, + buildingId: dto.buildingId, + name: dto.name.trim(), + description: dto.description?.trim() || null, + sortOrder: dto.sortOrder ?? 0, + }), + ); + await this.activity(projectId, userId, 'floor.created', { + floorId: saved.id, + name: saved.name, + }); + return saved; + } + async updateFloor( + projectId: string, + id: string, + userId: string, + dto: UpdateFloorDto, + ) { + await this.edit(projectId, userId); + await this.requireOwned(this.repo.buildings, projectId, dto.buildingId); + await this.versioned(this.repo.floors, projectId, id, dto.version, { + buildingId: dto.buildingId, + name: dto.name.trim(), + description: dto.description?.trim() || null, + sortOrder: dto.sortOrder ?? 0, + }); + return this.requireOwned(this.repo.floors, projectId, id); + } + async deleteFloor(projectId: string, id: string, userId: string) { + await this.edit(projectId, userId); + await this.requireOwned(this.repo.floors, projectId, id); + if (await this.repo.rooms.exist({ where: { projectId, floorId: id } })) + this.conflict('Die Etage enthält Räume und kann nicht gelöscht werden.'); + await this.repo.floors.delete({ id, projectId }); + } + + async createRoom(projectId: string, userId: string, dto: CreateRoomDto) { + await this.edit(projectId, userId); + await this.requireOwned(this.repo.floors, projectId, dto.floorId); + const saved = await this.repo.rooms.save( + this.repo.rooms.create({ + projectId, + floorId: dto.floorId, + name: dto.name.trim(), + description: dto.description?.trim() || null, + type: dto.type, + status: dto.status, + area: this.decimal(dto.area), + plannedBudget: this.decimal(dto.plannedBudget), + sortOrder: dto.sortOrder ?? 0, + previewDocumentId: null, + }), + ); + await this.activity(projectId, userId, 'room.created', { + roomId: saved.id, + name: saved.name, + }); + return saved; + } + async updateRoom( + projectId: string, + id: string, + userId: string, + dto: UpdateRoomDto, + ) { + await this.edit(projectId, userId); + await this.requireOwned(this.repo.floors, projectId, dto.floorId); + const before = await this.requireOwned(this.repo.rooms, projectId, id); + await this.versioned(this.repo.rooms, projectId, id, dto.version, { + floorId: dto.floorId, + name: dto.name.trim(), + description: dto.description?.trim() || null, + type: dto.type, + status: dto.status, + area: this.decimal(dto.area), + plannedBudget: this.decimal(dto.plannedBudget), + sortOrder: dto.sortOrder ?? 0, + }); + if (before.status !== dto.status) + await this.activity(projectId, userId, 'room.status-changed', { + roomId: id, + status: dto.status, + }); + return this.requireOwned(this.repo.rooms, projectId, id); + } + async deleteRoom(projectId: string, id: string, userId: string) { + await this.edit(projectId, userId); + await this.requireOwned(this.repo.rooms, projectId, id); + const [tasks, expenses, documents, furniture] = await Promise.all([ + this.repo.tasks.count({ where: { projectId, roomId: id } }), + this.repo.expenses.count({ where: { projectId, roomId: id } }), + this.repo.documents.count({ where: { projectId, roomId: id } }), + this.dataSource.getRepository(FurnitureRequirementEntity).count({ + where: { projectId, roomId: id }, + }), + ]); + if (tasks + expenses + documents + furniture > 0) + this.conflict( + `Der Raum ist mit ${tasks} Aufgaben, ${expenses} Ausgaben, ${documents} Dokumenten und ${furniture} Möbelbedarfen verknüpft. Archivieren Sie ihn stattdessen.`, + ); + await this.repo.rooms.softDelete({ id, projectId }); + } + + async createTask(projectId: string, userId: string, dto: CreateTaskDto) { + await this.edit(projectId, userId); + await this.validateTaskLinks(projectId, dto.roomId, dto.assigneeUserId); + const task = this.repo.tasks.create({ + projectId, + roomId: dto.roomId ?? null, + title: dto.title.trim(), + description: dto.description?.trim() || null, + category: dto.category, + status: dto.status, + priority: dto.priority, + assigneeUserId: dto.assigneeUserId ?? null, + plannedStartDate: dto.plannedStartDate ?? null, + dueDate: dto.dueDate ?? null, + completedAt: dto.status === TaskStatus.Done ? new Date() : null, + estimatedEffortHours: this.decimal(dto.estimatedEffortHours), + estimatedCost: this.decimal(dto.estimatedCost), + actualCost: this.decimal(dto.actualCost), + blockingReason: dto.blockingReason?.trim() || null, + sortOrder: 0, + weight: this.requiredDecimal(dto.weight ?? 1), + createdByUserId: userId, + }); + const saved = await this.repo.tasks.save(task); + await this.activity(projectId, userId, 'task.created', { + taskId: saved.id, + title: saved.title, + }); + await this.notifyAssignment(saved, userId); + return saved; + } + async updateTask( + projectId: string, + id: string, + userId: string, + dto: UpdateTaskDto, + ) { + await this.edit(projectId, userId); + await this.validateTaskLinks(projectId, dto.roomId, dto.assigneeUserId); + const before = await this.requireOwned(this.repo.tasks, projectId, id); + if ( + dto.status === TaskStatus.InProgress && + (await this.isBlocked(projectId, id)) + ) + this.conflict( + 'Die Aufgabe kann erst starten, wenn alle Vorgänger erledigt sind.', + ); + await this.versioned(this.repo.tasks, projectId, id, dto.version, { + roomId: dto.roomId ?? null, + title: dto.title.trim(), + description: dto.description?.trim() || null, + category: dto.category, + status: dto.status, + priority: dto.priority, + assigneeUserId: dto.assigneeUserId ?? null, + plannedStartDate: dto.plannedStartDate ?? null, + dueDate: dto.dueDate ?? null, + completedAt: + dto.status === TaskStatus.Done + ? (before.completedAt ?? new Date()) + : null, + estimatedEffortHours: this.decimal(dto.estimatedEffortHours), + estimatedCost: this.decimal(dto.estimatedCost), + actualCost: this.decimal(dto.actualCost), + blockingReason: dto.blockingReason?.trim() || null, + weight: this.requiredDecimal(dto.weight ?? 1), + }); + const saved = await this.requireOwned(this.repo.tasks, projectId, id); + await this.activity( + projectId, + userId, + before.status === saved.status ? 'task.updated' : 'task.status-changed', + { taskId: id, status: saved.status }, + ); + if (before.assigneeUserId !== saved.assigneeUserId) + await this.notifyAssignment(saved, userId); + return saved; + } + async deleteTask(projectId: string, id: string, userId: string) { + await this.edit(projectId, userId); + await this.requireOwned(this.repo.tasks, projectId, id); + await this.repo.tasks.softDelete({ id, projectId }); + await this.activity(projectId, userId, 'task.deleted', { taskId: id }); + } + + async checklist(projectId: string, taskId: string, userId: string) { + await this.read(projectId, userId); + await this.requireOwned(this.repo.tasks, projectId, taskId); + return this.repo.checklist.find({ + where: { projectId, taskId }, + order: { sortOrder: 'ASC' }, + }); + } + async addChecklist( + projectId: string, + taskId: string, + userId: string, + dto: CreateChecklistItemDto, + ) { + await this.edit(projectId, userId); + await this.requireOwned(this.repo.tasks, projectId, taskId); + return this.repo.checklist.save( + this.repo.checklist.create({ + projectId, + taskId, + text: dto.text.trim(), + completed: false, + sortOrder: dto.sortOrder ?? 0, + completedAt: null, + completedByUserId: null, + }), + ); + } + async updateChecklist( + projectId: string, + taskId: string, + id: string, + userId: string, + dto: UpdateChecklistItemDto, + ) { + await this.edit(projectId, userId); + const item = await this.requireOwned(this.repo.checklist, projectId, id); + if (item.taskId !== taskId) this.notFound(); + if (dto.text !== undefined) item.text = dto.text.trim(); + if (dto.sortOrder !== undefined) item.sortOrder = dto.sortOrder; + if (dto.completed !== undefined) { + item.completed = dto.completed; + item.completedAt = dto.completed ? new Date() : null; + item.completedByUserId = dto.completed ? userId : null; + } + return this.repo.checklist.save(item); + } + + async deleteChecklist( + projectId: string, + taskId: string, + id: string, + userId: string, + ) { + await this.edit(projectId, userId); + const item = await this.requireOwned(this.repo.checklist, projectId, id); + if (item.taskId !== taskId) this.notFound(); + await this.repo.checklist.delete({ id, projectId, taskId }); + } + + async dependencies(projectId: string, taskId: string, userId: string) { + await this.read(projectId, userId); + await this.requireOwned(this.repo.tasks, projectId, taskId); + return this.repo.dependencies.find({ + where: { projectId, successorTaskId: taskId }, + }); + } + async addDependency( + projectId: string, + taskId: string, + userId: string, + dto: CreateDependencyDto, + ) { + await this.edit(projectId, userId); + await Promise.all([ + this.requireOwned(this.repo.tasks, projectId, taskId), + this.requireOwned(this.repo.tasks, projectId, dto.predecessorTaskId), + ]); + const all = await this.repo.dependencies.find({ where: { projectId } }); + if (wouldCreateDependencyCycle(all, dto.predecessorTaskId, taskId)) + this.conflict('Diese Abhängigkeit würde einen Zyklus erzeugen.'); + const saved = await this.repo.dependencies.save( + this.repo.dependencies.create({ + projectId, + predecessorTaskId: dto.predecessorTaskId, + successorTaskId: taskId, + type: 'finish_to_start', + }), + ); + await this.activity(projectId, userId, 'task.dependency-added', { + taskId, + predecessorTaskId: dto.predecessorTaskId, + }); + return saved; + } + async removeDependency( + projectId: string, + taskId: string, + id: string, + userId: string, + ) { + await this.edit(projectId, userId); + const dependency = await this.requireOwned( + this.repo.dependencies, + projectId, + id, + ); + if (dependency.successorTaskId !== taskId) this.notFound(); + await this.repo.dependencies.delete(id); + await this.activity(projectId, userId, 'task.dependency-removed', { + taskId, + dependencyId: id, + }); + } + + async comments( + projectId: string, + taskId: string, + userId: string, + query: FachListQueryDto, + ) { + await this.read(projectId, userId); + await this.requireOwned(this.repo.tasks, projectId, taskId); + return this.repo.pageComments(projectId, taskId, query); + } + async addComment( + projectId: string, + taskId: string, + userId: string, + dto: CreateCommentDto, + ) { + await this.edit(projectId, userId); + await this.requireOwned(this.repo.tasks, projectId, taskId); + return this.dataSource.transaction(async (manager) => { + await this.validateMentionUsers( + projectId, + dto.mentionedUserIds ?? [], + manager, + ); + const saved = await manager.getRepository(this.repo.comments.target).save( + this.repo.comments.create({ + projectId, + taskId, + authorUserId: userId, + text: dto.text.trim(), + }), + ); + await this.addNewMentions( + saved.id, + projectId, + taskId, + userId, + dto.mentionedUserIds ?? [], + manager, + ); + await this.activity( + projectId, + userId, + 'task.comment-added', + { taskId, commentId: saved.id }, + manager, + ); + return saved; + }); + } + async updateComment( + projectId: string, + taskId: string, + id: string, + userId: string, + dto: CreateCommentDto, + ) { + await this.edit(projectId, userId); + return this.dataSource.transaction(async (manager) => { + const comment = await manager + .getRepository(this.repo.comments.target) + .findOneBy({ id, projectId }); + if ( + !comment || + comment.taskId !== taskId || + comment.authorUserId !== userId + ) + throw new ApiError( + ErrorCode.PermissionDenied, + 'Nur eigene Kommentare können bearbeitet werden.', + 403, + ); + await this.validateMentionUsers( + projectId, + dto.mentionedUserIds ?? [], + manager, + ); + comment.text = dto.text.trim(); + const saved = await manager + .getRepository(this.repo.comments.target) + .save(comment); + await this.addNewMentions( + id, + projectId, + taskId, + userId, + dto.mentionedUserIds ?? [], + manager, + ); + return saved; + }); + } + async deleteComment( + projectId: string, + taskId: string, + id: string, + userId: string, + ) { + await this.edit(projectId, userId); + const comment = await this.requireOwned(this.repo.comments, projectId, id); + if (comment.taskId !== taskId || comment.authorUserId !== userId) + throw new ApiError( + ErrorCode.PermissionDenied, + 'Nur eigene Kommentare können gelöscht werden.', + 403, + ); + await this.repo.comments.softDelete(id); + } + + async createMilestone( + projectId: string, + userId: string, + dto: CreateMilestoneDto, + ) { + await this.edit(projectId, userId); + await this.validateAssignee(projectId, dto.responsibleUserId); + const saved = await this.repo.milestones.save( + this.repo.milestones.create({ + projectId, + title: dto.title.trim(), + description: dto.description?.trim() || null, + date: dto.date, + status: dto.status, + type: dto.type, + responsibleUserId: dto.responsibleUserId ?? null, + }), + ); + await this.activity(projectId, userId, 'milestone.created', { + milestoneId: saved.id, + }); + return saved; + } + async updateMilestone( + projectId: string, + id: string, + userId: string, + dto: UpdateMilestoneDto, + ) { + await this.edit(projectId, userId); + await this.validateAssignee(projectId, dto.responsibleUserId); + await this.versioned(this.repo.milestones, projectId, id, dto.version, { + title: dto.title.trim(), + description: dto.description?.trim() || null, + date: dto.date, + status: dto.status, + type: dto.type, + responsibleUserId: dto.responsibleUserId ?? null, + }); + await this.activity(projectId, userId, 'milestone.updated', { + milestoneId: id, + }); + return this.requireOwned(this.repo.milestones, projectId, id); + } + async deleteMilestone(projectId: string, id: string, userId: string) { + await this.edit(projectId, userId); + await this.requireOwned(this.repo.milestones, projectId, id); + await this.repo.milestones.delete({ id, projectId }); + await this.activity(projectId, userId, 'milestone.deleted', { + milestoneId: id, + }); + } + + async createBudget( + projectId: string, + userId: string, + dto: CreateBudgetCategoryDto, + ) { + await this.manage(projectId, userId); + const saved = await this.repo.budgets.save( + this.repo.budgets.create({ + projectId, + name: dto.name.trim(), + plannedBudget: this.requiredDecimal(dto.plannedBudget), + sortOrder: dto.sortOrder ?? 0, + active: true, + }), + ); + await this.activity(projectId, userId, 'budget.created', { + categoryId: saved.id, + }); + return saved; + } + async updateBudget( + projectId: string, + id: string, + userId: string, + dto: UpdateBudgetCategoryDto, + ) { + await this.manage(projectId, userId); + await this.versioned(this.repo.budgets, projectId, id, dto.version, { + name: dto.name.trim(), + plannedBudget: this.requiredDecimal(dto.plannedBudget), + sortOrder: dto.sortOrder ?? 0, + }); + await this.activity(projectId, userId, 'budget.updated', { + categoryId: id, + }); + return this.requireOwned(this.repo.budgets, projectId, id); + } + async deleteBudget(projectId: string, id: string, userId: string) { + await this.manage(projectId, userId); + await this.requireOwned(this.repo.budgets, projectId, id); + const expenseCount = await this.repo.expenses.count({ + where: { projectId, budgetCategoryId: id }, + }); + if (expenseCount) + this.conflict( + 'Die Budgetkategorie wird von Ausgaben verwendet und kann nur archiviert werden.', + ); + await this.repo.budgets.delete({ id, projectId }); + await this.activity(projectId, userId, 'budget.deleted', { + categoryId: id, + }); + } + + async createExpense( + projectId: string, + userId: string, + dto: CreateExpenseDto, + ) { + await this.edit(projectId, userId); + await this.validateExpenseLinks(projectId, dto); + const saved = await this.repo.expenses.save( + this.repo.expenses.create({ + projectId, + budgetCategoryId: dto.budgetCategoryId, + roomId: dto.roomId ?? null, + taskId: dto.taskId ?? null, + title: dto.title.trim(), + description: dto.description?.trim() || null, + amount: this.requiredDecimal(dto.amount), + currency: (dto.currency ?? 'EUR').toUpperCase(), + expenseDate: dto.expenseDate, + paymentStatus: dto.paymentStatus, + dueDate: dto.dueDate ?? null, + supplier: dto.supplier?.trim() || null, + invoiceNumber: dto.invoiceNumber?.trim() || null, + documentId: dto.documentId ?? null, + createdByUserId: userId, + }), + ); + await this.activity(projectId, userId, 'expense.created', { + expenseId: saved.id, + }); + return saved; + } + async updateExpense( + projectId: string, + id: string, + userId: string, + dto: UpdateExpenseDto, + ) { + await this.edit(projectId, userId); + await this.validateExpenseLinks(projectId, dto); + await this.versioned(this.repo.expenses, projectId, id, dto.version, { + budgetCategoryId: dto.budgetCategoryId, + roomId: dto.roomId ?? null, + taskId: dto.taskId ?? null, + title: dto.title.trim(), + description: dto.description?.trim() || null, + amount: this.requiredDecimal(dto.amount), + currency: (dto.currency ?? 'EUR').toUpperCase(), + expenseDate: dto.expenseDate, + paymentStatus: dto.paymentStatus, + dueDate: dto.dueDate ?? null, + supplier: dto.supplier?.trim() || null, + invoiceNumber: dto.invoiceNumber?.trim() || null, + documentId: dto.documentId ?? null, + }); + return this.requireOwned(this.repo.expenses, projectId, id); + } + async deleteExpense(projectId: string, id: string, userId: string) { + await this.edit(projectId, userId); + await this.requireOwned(this.repo.expenses, projectId, id); + await this.repo.expenses.delete({ id, projectId }); + await this.activity(projectId, userId, 'expense.deleted', { + expenseId: id, + }); + } + + async dashboard(projectId: string, userId: string) { + await this.read(projectId, userId); + const [tasks, rooms, milestones, budgets, expenses, members, furniture] = + await Promise.all([ + this.repo.listTasks(projectId), + this.repo.listRooms(projectId), + this.repo.listMilestones(projectId), + this.repo.listBudgets(projectId), + this.repo.listExpenses(projectId), + this.projects.listMembers(projectId), + this.furniture.summary(projectId, userId), + ]); + const dependencies = await this.repo.dependencies.find({ + where: { projectId }, + }); + const today = new Date().toISOString().slice(0, 10); + const relevant = tasks.filter( + (t) => t.status !== TaskStatus.Done && t.status !== TaskStatus.Omitted, + ); + const blockedIds = new Set( + dependencies + .filter( + (d) => + tasks.find((t) => t.id === d.predecessorTaskId)?.status !== + TaskStatus.Done, + ) + .map((d) => d.successorTaskId), + ); + const paid = this.sum( + expenses.filter((e) => e.paymentStatus === 'paid').map((e) => e.amount), + ); + const open = this.sum( + expenses.filter((e) => e.paymentStatus === 'open').map((e) => e.amount), + ); + const actual = this.sum( + expenses + .filter((e) => e.paymentStatus !== 'cancelled') + .map((e) => e.amount), + ); + const planned = this.sum(budgets.map((b) => b.plannedBudget)); + return { + progress: calculateProgress(tasks), + tasks: { + open: relevant.length, + overdue: relevant.filter((t) => !!t.dueDate && t.dueDate < today) + .length, + blocked: blockedIds.size, + critical: relevant.filter((t) => t.priority === TaskPriority.Critical) + .length, + unassigned: relevant.filter((t) => !t.assigneeUserId).length, + mine: relevant.filter((t) => t.assigneeUserId === userId).length, + }, + rooms: { + total: rooms.length, + done: rooms.filter((r) => r.status === RoomStatus.Done).length, + renovating: rooms.filter((r) => r.status === RoomStatus.Renovating) + .length, + }, + budget: { planned, actual, paid, open, remaining: planned - actual }, + milestones: milestones.slice(0, 5).map((m) => ({ + ...m, + atRisk: m.date < today && !['done', 'cancelled'].includes(m.status), + })), + activeMembers: members.length, + furniture, + hints: this.hints(relevant, planned, actual, blockedIds.size), + }; + } + + templates() { + return [ + { + id: 'terraced-house', + name: 'Reihenhaus-Renovierung', + description: 'Etagen und typische Räume', + }, + { + id: 'handover', + name: 'Schlüsselübergabe', + description: 'Prüfungen rund um die Übergabe', + }, + { + id: 'room-renovation', + name: 'Renovierung eines Raums', + description: 'Renovierungsablauf mit Abhängigkeiten', + }, + { id: 'moving', name: 'Umzug', description: 'Organisation des Umzugs' }, + ]; + } + async applyTemplate( + projectId: string, + templateId: string, + userId: string, + dto: ApplyTemplateDto, + ) { + await this.manage(projectId, userId); + if (!this.templates().some((template) => template.id === templateId)) + this.notFound(); + const targetKey = + templateId === 'room-renovation' ? (dto.roomId ?? '') : 'project'; + if (!targetKey) + throw new ApiError( + ErrorCode.ValidationFailed, + 'Für die Raumrenovierung muss ein Raum gewählt werden.', + 400, + ); + return this.dataSource.transaction(async (manager) => { + if ( + dto.roomId && + !(await manager + .getRepository(RoomEntity) + .findOneBy({ id: dto.roomId, projectId })) + ) + this.notFound(); + const appliedRepository = manager.getRepository( + AppliedProjectTemplateEntity, + ); + const applied = await appliedRepository.findOneBy({ + projectId, + templateId, + targetKey, + }); + if (applied && !dto.confirmDuplicate) + this.conflict( + 'Diese Vorlage wurde für das gewählte Ziel bereits angewendet. Bestätigen Sie die erneute Anwendung ausdrücklich.', + ); + let createdTasks = 0; + let createdRooms = 0; + if (templateId === 'terraced-house') { + const building = await manager.getRepository(BuildingEntity).save( + manager.getRepository(BuildingEntity).create({ + projectId, + name: 'Reihenhaus', + description: 'Aus Vorlage erstellt', + type: BuildingType.TerracedHouse, + sortOrder: 0, + }), + ); + for (const [floorIndex, definition] of terracedHouseFloors.entries()) { + const floor = await manager.getRepository(FloorEntity).save( + manager.getRepository(FloorEntity).create({ + projectId, + buildingId: building.id, + name: definition.name, + description: null, + sortOrder: floorIndex, + }), + ); + for (const [roomIndex, room] of definition.rooms.entries()) { + await manager.getRepository(RoomEntity).save( + manager.getRepository(RoomEntity).create({ + projectId, + floorId: floor.id, + name: room[0], + description: null, + type: room[1], + status: RoomStatus.Unplanned, + area: null, + plannedBudget: null, + sortOrder: roomIndex, + previewDocumentId: null, + }), + ); + createdRooms++; + } + } + for (const [index, name] of standardBudgets.entries()) + await manager.getRepository(BudgetCategoryEntity).save( + manager.getRepository(BudgetCategoryEntity).create({ + projectId, + name, + plannedBudget: name === 'Reserve' ? '5000.00' : '0.00', + sortOrder: index, + active: true, + }), + ); + const project = await manager + .getRepository(ProjectEntity) + .findOneBy({ id: projectId }); + if (!project) this.notFound(); + project.totalBudget = '45000.00'; + project.currency = 'EUR'; + project.status = 'renovation'; + await manager.getRepository(ProjectEntity).save(project); + const today = new Date(); + const milestoneDefinitions = [ + ['Schlüsselübergabe', 'key_handover', 14], + ['Renovierungsbeginn', 'renovation_start', 15], + ['Renovierungsabschluss', 'renovation_end', 50], + ['Umzug', 'moving', 57], + ] as const; + for (const definition of milestoneDefinitions) + await manager.getRepository(MilestoneEntity).save( + manager.getRepository(MilestoneEntity).create({ + projectId, + title: definition[0], + description: null, + date: this.addDays(today, definition[2]), + status: 'planned', + type: definition[1], + responsibleUserId: null, + }), + ); + createdTasks += ( + await this.createTemplateTasks( + manager, + projectId, + userId, + handoverTasks.slice(0, 6), + null, + ) + ).length; + } else if (templateId === 'handover') + createdTasks = ( + await this.createTemplateTasks( + manager, + projectId, + userId, + handoverTasks, + null, + ) + ).length; + else if (templateId === 'moving') + createdTasks = ( + await this.createTemplateTasks( + manager, + projectId, + userId, + movingTasks, + null, + ) + ).length; + else + createdTasks = ( + await this.createTemplateTasks( + manager, + projectId, + userId, + roomRenovationTasks, + dto.roomId ?? null, + ) + ).length; + if (!applied) + await appliedRepository.save( + appliedRepository.create({ + projectId, + templateId, + targetKey, + appliedByUserId: userId, + }), + ); + await this.activity( + projectId, + userId, + 'template.applied', + { templateId, targetKey, createdTasks, createdRooms }, + manager, + ); + return { createdTasks, createdRooms }; + }); + } + + private async enrichTasks(projectId: string, tasks: RenovationTaskEntity[]) { + const dependencies = await this.repo.dependencies.find({ + where: { projectId }, + }); + const done = new Set( + tasks.filter((t) => t.status === TaskStatus.Done).map((t) => t.id), + ); + return tasks.map((task) => ({ + ...task, + blockedByDependencies: dependencies.some( + (d) => d.successorTaskId === task.id && !done.has(d.predecessorTaskId), + ), + })); + } + private async createTemplateTasks( + manager: EntityManager, + projectId: string, + userId: string, + definitions: TaskTemplate[], + roomId: string | null, + ) { + const tasks: RenovationTaskEntity[] = []; + for (const [index, definition] of definitions.entries()) { + tasks.push( + await manager.getRepository(RenovationTaskEntity).save( + manager.getRepository(RenovationTaskEntity).create({ + projectId, + roomId, + title: definition.title, + description: null, + category: definition.category, + status: TaskStatus.Planned, + priority: TaskPriority.Normal, + assigneeUserId: null, + plannedStartDate: null, + dueDate: null, + completedAt: null, + estimatedEffortHours: null, + estimatedCost: null, + actualCost: null, + blockingReason: null, + sortOrder: index, + weight: '1.00', + createdByUserId: userId, + }), + ), + ); + } + for (const [index, definition] of definitions.entries()) { + if (definition.predecessor === undefined) continue; + const predecessor = tasks[definition.predecessor]; + const successor = tasks[index]; + if (predecessor && successor) + await manager.getRepository(this.repo.dependencies.target).save( + this.repo.dependencies.create({ + projectId, + predecessorTaskId: predecessor.id, + successorTaskId: successor.id, + type: 'finish_to_start', + }), + ); + } + return tasks; + } + private addDays(date: Date, days: number) { + const result = new Date(date); + result.setUTCDate(result.getUTCDate() + days); + return result.toISOString().slice(0, 10); + } + private taskReference( + task: RenovationTaskEntity | undefined, + members: Map, + ) { + if (!task) return null; + return { + id: task.id, + title: task.title, + status: task.status, + assigneeUserId: task.assigneeUserId, + assigneeName: task.assigneeUserId + ? (members.get(task.assigneeUserId) ?? null) + : null, + }; + } + private async isBlocked(projectId: string, taskId: string) { + const deps = await this.repo.dependencies.find({ + where: { projectId, successorTaskId: taskId }, + }); + for (const dep of deps) + if ( + ( + await this.requireOwned( + this.repo.tasks, + projectId, + dep.predecessorTaskId, + ) + ).status !== TaskStatus.Done + ) + return true; + return false; + } + private async validateTaskLinks( + projectId: string, + roomId?: string, + assignee?: string, + ) { + if (roomId) await this.requireOwned(this.repo.rooms, projectId, roomId); + await this.validateAssignee(projectId, assignee); + } + private async validateAssignee(projectId: string, userId?: string) { + if (!userId) return; + const membership = await this.projects.findMembership(projectId, userId); + if ( + !membership?.active || + !membership.user.active || + membership.role === ProjectRole.Reader + ) + throw new ApiError( + ErrorCode.ProjectMemberNotFound, + 'Der Verantwortliche ist kein aktives, bearbeitendes Projektmitglied.', + 400, + ); + } + private async validateExpenseLinks(projectId: string, dto: CreateExpenseDto) { + await this.requireOwned(this.repo.budgets, projectId, dto.budgetCategoryId); + if (dto.roomId) + await this.requireOwned(this.repo.rooms, projectId, dto.roomId); + if (dto.taskId) + await this.requireOwned(this.repo.tasks, projectId, dto.taskId); + if (dto.documentId) + await this.requireOwned(this.repo.documents, projectId, dto.documentId); + } + private async notifyAssignment(task: RenovationTaskEntity, actorId: string) { + if (!task.assigneeUserId || task.assigneeUserId === actorId) return; + await this.notifications.createForUser({ + userId: task.assigneeUserId, + type: NotificationType.TaskAssigned, + title: 'Aufgabe zugewiesen', + message: `Ihnen wurde die Aufgabe „${task.title}“ zugewiesen.`, + link: `/projekte/${task.projectId}/aufgaben/${task.id}`, + metadata: { projectId: task.projectId, taskId: task.id }, + }); + } + private async activity( + projectId: string, + userId: string, + action: string, + metadata: Record, + manager?: EntityManager, + ) { + const entity = new ProjectActivityEntity(); + entity.projectId = projectId; + entity.actorUserId = userId; + entity.action = action; + entity.metadata = metadata; + await this.projects.saveActivity(entity, manager); + } + private async validateMentionUsers( + projectId: string, + userIds: string[], + manager: EntityManager, + ) { + for (const mentionedUserId of new Set(userIds)) { + const membership = await this.projects.findMembership( + projectId, + mentionedUserId, + manager, + ); + if (!membership?.active || !membership.user.active) { + throw new ApiError( + ErrorCode.ProjectMemberNotFound, + 'Erwähnt werden können nur aktive Projektmitglieder.', + 400, + ); + } + } + } + private async addNewMentions( + commentId: string, + projectId: string, + taskId: string, + authorUserId: string, + userIds: string[], + manager: EntityManager, + ) { + const repository = manager.getRepository(TaskCommentMentionEntity); + const existing = await repository.find({ where: { commentId } }); + const changes = newMentionUserIds( + existing.map((mention) => mention.userId), + userIds, + authorUserId, + ); + const additions = [ + ...changes.notify, + ...(changes.selfMentioned ? [authorUserId] : []), + ]; + for (const mentionedUserId of additions) { + const mention = repository.create({ + projectId, + commentId, + userId: mentionedUserId, + notificationCreated: mentionedUserId === authorUserId, + }); + const saved = await repository.save(mention); + if (mentionedUserId !== authorUserId) { + await this.notifications.createForUser( + { + userId: mentionedUserId, + type: NotificationType.TaskMention, + title: 'In einem Kommentar erwähnt', + message: 'Sie wurden in einem Aufgabenkommentar erwähnt.', + link: `/projekte/${projectId}/aufgaben/${taskId}#comment-${commentId}`, + metadata: { projectId, taskId, commentId }, + }, + manager, + ); + saved.notificationCreated = true; + await repository.save(saved); + } + } + } + private read(projectId: string, userId: string) { + return this.access.require(projectId, userId, 'read'); + } + private edit(projectId: string, userId: string) { + return this.access.require(projectId, userId, 'edit'); + } + private async manage(projectId: string, userId: string) { + const membership = await this.access.require(projectId, userId, 'edit'); + if ( + ![ProjectRole.Owner, ProjectRole.Administrator].includes(membership.role) + ) + throw new ApiError( + ErrorCode.ProjectAccessDenied, + 'Diese Aktion ist Projektadministratoren vorbehalten.', + 403, + ); + } + private async requireOwned( + repository: { + findOneBy(where: { id: string; projectId: string }): Promise; + }, + projectId: string, + id: string, + ): Promise { + const entity = await repository.findOneBy({ id, projectId }); + if (!entity) this.notFound(); + return entity; + } + private async versioned( + repository: { + update( + criteria: { id: string; projectId: string; version: number }, + values: object, + ): Promise<{ affected?: number | null }>; + }, + projectId: string, + id: string, + version: number, + values: object, + ) { + const result = await repository.update( + { id, projectId, version }, + { ...values, version: () => 'version + 1' }, + ); + if (result.affected !== 1) + this.conflict( + 'Dieser Datensatz wurde zwischenzeitlich geändert. Laden Sie die aktuellen Daten neu.', + ); + } + private decimal(value?: number): string | null { + return value === undefined ? null : value.toFixed(2); + } + private requiredDecimal(value: number): string { + return value.toFixed(2); + } + private sum(values: string[]) { + return values.reduce((sum, value) => sum + Number(value), 0); + } + private hints( + tasks: RenovationTaskEntity[], + budget: number, + actual: number, + blocked: number, + ) { + const result: string[] = []; + const today = new Date().toISOString().slice(0, 10); + const overdue = tasks.filter( + (t) => !!t.dueDate && t.dueDate < today, + ).length; + const unassigned = tasks.filter((t) => !t.assigneeUserId).length; + if (overdue) result.push(`${overdue} Aufgaben sind überfällig.`); + if (blocked) + result.push( + `${blocked} Aufgaben werden durch offene Vorgänger blockiert.`, + ); + if (unassigned) + result.push(`Für ${unassigned} Aufgaben fehlt ein Verantwortlicher.`); + if (actual > budget && budget > 0) + result.push('Das geplante Budget ist überschritten.'); + return result; + } + private notFound(): never { + throw new ApiError( + ErrorCode.NotFound, + 'Der Datensatz wurde nicht gefunden.', + 404, + ); + } + private conflict(message: string): never { + throw new ApiError(ErrorCode.Conflict, message, 409); + } +} diff --git a/apps/backend/src/renovation/templates.ts b/apps/backend/src/renovation/templates.ts new file mode 100644 index 0000000..66f5ee0 --- /dev/null +++ b/apps/backend/src/renovation/templates.ts @@ -0,0 +1,128 @@ +export interface TaskTemplate { + title: string; + category: string; + predecessor?: number; +} + +export const terracedHouseFloors = [ + { + name: 'Keller', + rooms: [ + ['Kellerraum', 'basement'], + ['Hauswirtschaftsraum', 'utility_room'], + ], + }, + { + name: 'Erdgeschoss', + rooms: [ + ['Küche', 'kitchen'], + ['Wohnzimmer', 'living_room'], + ['Essbereich', 'dining_room'], + ['Gäste-WC', 'guest_toilet'], + ['Flur', 'hallway'], + ['Terrasse', 'terrace'], + ['Garten', 'garden'], + ['Garage', 'garage'], + ], + }, + { + name: '1. Stock', + rooms: [ + ['Schlafzimmer', 'bedroom'], + ['Kinderzimmer', 'child_room'], + ['Badezimmer', 'bathroom'], + ['Flur', 'hallway'], + ], + }, + { + name: 'Dachboden', + rooms: [ + ['Arbeitszimmer', 'office'], + ['Abstellraum', 'storage'], + ], + }, +] as const; + +export const standardBudgets = [ + 'Elektrik', + 'Sanitär', + 'Heizung', + 'Malerarbeiten', + 'Boden', + 'Küche', + 'Möbel', + 'Außenbereich', + 'Umzug', + 'Werkzeuge', + 'Gebühren', + 'Sonstiges', + 'Reserve', +]; + +export const handoverTasks: TaskTemplate[] = [ + 'Übergabeprotokoll prüfen', + 'Zählerstände dokumentieren', + 'Zählerstände fotografieren', + 'Schlüssel zählen', + 'Schäden dokumentieren', + 'Grundrisse und Unterlagen übernehmen', + 'Schlösser austauschen', + 'Stromversorgung prüfen', + 'Wasserversorgung prüfen', + 'Heizung prüfen', + 'Internetanschluss prüfen', + 'Versicherungsbeginn prüfen', +].map((title, index) => ({ + title, + category: index < 6 ? 'administration' : 'general', + ...(index > 0 && index < 6 ? { predecessor: 0 } : {}), +})); + +export const roomRenovationTasks: TaskTemplate[] = [ + 'Raum ausmessen', + 'Bestand fotografieren', + 'Möbel entfernen', + 'Boden und Einbauten schützen', + 'Alte Tapeten oder Beläge entfernen', + 'Elektrik prüfen', + 'Schäden ausbessern', + 'Wände vorbereiten', + 'Wände streichen', + 'Boden verlegen', + 'Sockelleisten montieren', + 'Steckdosen und Abdeckungen montieren', + 'Endreinigung', + 'Abnahme', +].map((title, index) => ({ + title, + category: + index === 5 || index === 11 + ? 'electrical' + : index >= 9 && index <= 10 + ? 'flooring' + : 'painting', + ...(index >= 4 ? { predecessor: index - 1 } : {}), +})); + +export const movingTasks: TaskTemplate[] = [ + 'Umzugsunternehmen anfragen', + 'Angebote vergleichen', + 'Umzugsunternehmen beauftragen', + 'Umzugstermin bestätigen', + 'Helfer organisieren', + 'Kartons beschaffen', + 'Nachsendeauftrag einrichten', + 'Strom ummelden', + 'Internet ummelden', + 'Versicherungen informieren', + 'Arbeitgeber informieren', + 'Halteverbotszone beantragen', + 'Alte Wohnung vorbereiten', + 'Zählerstände erfassen', + 'Schlüsselübergabe organisieren', + 'Endreinigung planen', +].map((title, index) => ({ + title, + category: 'moving', + ...([1, 2, 3].includes(index) ? { predecessor: index - 1 } : {}), +})); diff --git a/apps/backend/src/renovation/tests/furniture-grid-query.spec.ts b/apps/backend/src/renovation/tests/furniture-grid-query.spec.ts new file mode 100644 index 0000000..ef7c025 --- /dev/null +++ b/apps/backend/src/renovation/tests/furniture-grid-query.spec.ts @@ -0,0 +1,57 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { describe, expect, it } from 'vitest'; +import { + FurnitureListQueryDto, + FurnitureOptionListQueryDto, +} from '../dto/furniture.dto'; + +describe('Furniture grid query validation', () => { + it('accepts bounded paging and supported furniture filters', async () => { + const dto = plainToInstance(FurnitureListQueryDto, { + page: '2', + pageSize: '50', + roomId: 'e09ea0e6-6fd4-42b4-ae23-fb70818e0995', + category: 'seating', + openDecision: 'true', + overBudget: 'true', + sortBy: 'price', + sortDirection: 'DESC', + }); + expect(await validate(dto)).toEqual([]); + expect(dto).toMatchObject({ + page: 2, + pageSize: 50, + openDecision: true, + overBudget: true, + }); + }); + + it('rejects excessive page sizes and invalid enum filters', async () => { + const dto = plainToInstance(FurnitureOptionListQueryDto, { + pageSize: '1000', + status: 'not-a-status', + availability: 'somewhere', + }); + const properties = (await validate(dto)).map((error) => error.property); + expect(properties).toEqual( + expect.arrayContaining(['pageSize', 'status', 'availability']), + ); + }); + + it('transforms AG Grid boolean query parameters explicitly', async () => { + const dto = plainToInstance(FurnitureOptionListQueryDto, { + favorite: 'true', + selected: 'false', + ordered: 'true', + delayed: 'false', + }); + expect(await validate(dto)).toEqual([]); + expect(dto).toMatchObject({ + favorite: true, + selected: false, + ordered: true, + delayed: false, + }); + }); +}); diff --git a/apps/backend/src/renovation/tests/furniture-pricing.spec.ts b/apps/backend/src/renovation/tests/furniture-pricing.spec.ts new file mode 100644 index 0000000..d43cb18 --- /dev/null +++ b/apps/backend/src/renovation/tests/furniture-pricing.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { calculateFurnitureTotal, sumMoney } from '../furniture-pricing'; + +describe('furniture price calculation', () => { + it('includes quantity, shipping and additional costs and subtracts discounts cent-exactly', () => { + expect( + calculateFurnitureTotal({ + unitPrice: '1299.99', + quantity: 2, + shippingCost: '49.95', + additionalCost: '20.00', + discount: '100.00', + }), + ).toBe('2569.93'); + }); + + it('uses only transport and refurbishment costs for existing furniture', () => { + expect( + calculateFurnitureTotal({ + unitPrice: '800.00', + quantity: 1, + existingItem: true, + movingCost: '75.00', + refurbishmentCost: '125.00', + shippingCost: '0.00', + }), + ).toBe('200.00'); + }); + + it('rejects discounts greater than all costs', () => { + expect(() => + calculateFurnitureTotal({ + unitPrice: '10.00', + quantity: 1, + discount: '10.01', + }), + ).toThrow('DISCOUNT_EXCEEDS_COST'); + }); + + it('sums decimal money without binary floating point drift', () => { + expect(sumMoney(['0.10', '0.20', '1299.99'])).toBe('1300.29'); + }); +}); diff --git a/apps/backend/src/renovation/tests/mentions.spec.ts b/apps/backend/src/renovation/tests/mentions.spec.ts new file mode 100644 index 0000000..811d71d --- /dev/null +++ b/apps/backend/src/renovation/tests/mentions.spec.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { newMentionUserIds } from '../mentions'; + +describe('Kommentar-Erwähnungen', () => { + it('dedupliziert Mehrfachnennungen und unterdrückt Selbstbenachrichtigungen', () => { + expect( + newMentionUserIds( + ['member-a'], + ['member-a', 'member-b', 'member-b', 'author'], + 'author', + ), + ).toEqual({ notify: ['member-b'], selfMentioned: true }); + }); + + it('benachrichtigt beim Bearbeiten nur neu hinzugekommene Mitglieder', () => { + expect( + newMentionUserIds( + ['member-a', 'member-b'], + ['member-b', 'member-c'], + 'author', + ), + ).toEqual({ notify: ['member-c'], selfMentioned: false }); + }); +}); diff --git a/apps/backend/src/renovation/tests/progress.spec.ts b/apps/backend/src/renovation/tests/progress.spec.ts new file mode 100644 index 0000000..186d68e --- /dev/null +++ b/apps/backend/src/renovation/tests/progress.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { TaskStatus } from '../entities/renovation.entities'; +import { calculateProgress, wouldCreateDependencyCycle } from '../progress'; + +describe('HausPilot progress', () => { + it('calculates a weighted progress and excludes omitted tasks', () => { + expect( + calculateProgress([ + { status: TaskStatus.Done, weight: '2.00' }, + { status: TaskStatus.InProgress, weight: '1.00' }, + { status: TaskStatus.Omitted, weight: '100.00' }, + ]), + ).toBe(83); + }); + + it('returns zero for a project without relevant tasks', () => { + expect( + calculateProgress([{ status: TaskStatus.Omitted, weight: '1.00' }]), + ).toBe(0); + }); +}); + +describe('task dependency cycle detection', () => { + it('rejects self dependencies', () => { + expect(wouldCreateDependencyCycle([], 'a', 'a')).toBe(true); + }); + + it('detects an indirect cycle', () => { + const existing = [ + { predecessorTaskId: 'a', successorTaskId: 'b' }, + { predecessorTaskId: 'b', successorTaskId: 'c' }, + ]; + expect(wouldCreateDependencyCycle(existing, 'c', 'a')).toBe(true); + }); + + it('accepts an acyclic dependency graph', () => { + const existing = [{ predecessorTaskId: 'a', successorTaskId: 'b' }]; + expect(wouldCreateDependencyCycle(existing, 'b', 'c')).toBe(false); + }); +}); diff --git a/apps/backend/src/renovation/tests/reminder.service.spec.ts b/apps/backend/src/renovation/tests/reminder.service.spec.ts new file mode 100644 index 0000000..e3c8cd6 --- /dev/null +++ b/apps/backend/src/renovation/tests/reminder.service.spec.ts @@ -0,0 +1,143 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { DataSource, EntityManager, EntityTarget } from 'typeorm'; +import type { SchedulerRegistry } from '@nestjs/schedule'; +import type { AppConfigService } from '../../config/config.service'; +import type { NotificationsService } from '../../notifications/notifications.service'; +import type { ProjectsRepository } from '../../projects/repositories/projects.repository'; +import { ProjectRole } from '../../projects/entities/project-membership.entity'; +import { + ExpenseEntity, + MilestoneEntity, + ReminderDeliveryEntity, + RenovationTaskEntity, + TaskPriority, + TaskStatus, +} from '../entities/renovation.entities'; +import { ReminderService } from '../reminder.service'; +import { FurnitureOptionEntity } from '../entities/furniture.entities'; + +describe('ReminderService', () => { + it('dedupliziert wiederholte Jobläufe über den stabilen Datenbankschlüssel', async () => { + const task = Object.assign(new RenovationTaskEntity(), { + id: '10000000-0000-4000-8000-000000000001', + projectId: '20000000-0000-4000-8000-000000000001', + assigneeUserId: '30000000-0000-4000-8000-000000000001', + title: 'Elektrik prüfen', + dueDate: '2026-07-18', + status: TaskStatus.InProgress, + priority: TaskPriority.High, + }); + const lists = new Map, object[]>([ + [RenovationTaskEntity, [task]], + [MilestoneEntity, []], + [ExpenseEntity, []], + [FurnitureOptionEntity, []], + ]); + const delivered = new Set(); + const createForUser = vi.fn().mockResolvedValue({}); + const manager = { + getRepository: (target: EntityTarget) => ({ + insert: (value: { dedupeKey: string }) => { + expect(target).toBe(ReminderDeliveryEntity); + if (delivered.has(value.dedupeKey)) + return Promise.reject( + Object.assign(new Error('duplicate'), { code: 'ER_DUP_ENTRY' }), + ); + delivered.add(value.dedupeKey); + return Promise.resolve(); + }, + }), + } as unknown as EntityManager; + const dataSource = { + getRepository: (target: EntityTarget) => ({ + createQueryBuilder: () => { + const builder = { + where: () => builder, + andWhere: () => builder, + innerJoin: () => builder, + addSelect: () => builder, + getMany: () => Promise.resolve(lists.get(target) ?? []), + getRawAndEntities: () => Promise.resolve({ entities: [], raw: [] }), + }; + return builder; + }, + }), + transaction: async ( + work: (entityManager: EntityManager) => Promise, + ) => work(manager), + } as unknown as DataSource; + const projects = { + findMembership: vi + .fn() + .mockResolvedValue({ active: true, user: { active: true } }), + listMembers: vi.fn().mockResolvedValue([ + { + active: true, + user: { active: true }, + role: ProjectRole.Owner, + userId: task.assigneeUserId, + }, + ]), + } as unknown as ProjectsRepository; + const service = new ReminderService( + {} as SchedulerRegistry, + { + reminders: { intervalMs: 900_000, dueSoonDays: 3 }, + } as AppConfigService, + { createForUser } as unknown as NotificationsService, + projects, + dataSource, + ); + + await expect( + service.run(new Date('2026-07-19T10:00:00Z')), + ).resolves.toEqual({ created: 1 }); + await expect( + service.run(new Date('2026-07-19T10:00:00Z')), + ).resolves.toEqual({ created: 0 }); + expect(createForUser).toHaveBeenCalledTimes(1); + expect(Array.from(delivered)[0]).toContain( + `TASK_OVERDUE:${task.id}:${task.assigneeUserId}:${task.dueDate}`, + ); + }); + + it('überspringt erledigte und entfernten Mitgliedern zugewiesene Aufgaben', async () => { + const getMany = vi + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + const dataSource = { + getRepository: () => ({ + createQueryBuilder: () => { + const builder = { + where: () => builder, + andWhere: () => builder, + innerJoin: () => builder, + addSelect: () => builder, + getMany, + getRawAndEntities: () => Promise.resolve({ entities: [], raw: [] }), + }; + return builder; + }, + }), + } as unknown as DataSource; + const notifications = { + createForUser: vi.fn(), + } as unknown as NotificationsService; + const service = new ReminderService( + {} as SchedulerRegistry, + { + reminders: { intervalMs: 900_000, dueSoonDays: 3 }, + } as AppConfigService, + notifications, + {} as ProjectsRepository, + dataSource, + ); + await expect( + service.run(new Date('2026-07-19T10:00:00Z')), + ).resolves.toEqual({ created: 0 }); + expect(notifications.createForUser).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/renovation/tests/templates.spec.ts b/apps/backend/src/renovation/tests/templates.spec.ts new file mode 100644 index 0000000..a19e7d6 --- /dev/null +++ b/apps/backend/src/renovation/tests/templates.spec.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { + handoverTasks, + movingTasks, + roomRenovationTasks, + standardBudgets, + terracedHouseFloors, +} from '../templates'; + +describe('HausPilot-Systemvorlagen', () => { + it('enthält die vollständige Reihenhausstruktur und Standardbudgets', () => { + expect(terracedHouseFloors.map((floor) => floor.name)).toEqual([ + 'Keller', + 'Erdgeschoss', + '1. Stock', + 'Dachboden', + ]); + expect( + terracedHouseFloors.reduce( + (count, floor) => count + floor.rooms.length, + 0, + ), + ).toBe(16); + expect(standardBudgets).toHaveLength(13); + expect(standardBudgets.includes('Reserve')).toBe(true); + }); + + it('referenziert in der Raumvorlage nur vorhandene Vorgänger', () => { + expect(handoverTasks).toHaveLength(12); + expect(movingTasks).toHaveLength(16); + expect(roomRenovationTasks).toHaveLength(14); + for (const [index, task] of roomRenovationTasks.entries()) { + if (task.predecessor !== undefined) { + expect(task.predecessor).toBeLessThan(index); + expect(roomRenovationTasks[task.predecessor]).toBeDefined(); + } + } + }); +}); diff --git a/apps/backend/src/roles/permissions.ts b/apps/backend/src/roles/permissions.ts index 4ae1f31..bf236f1 100644 --- a/apps/backend/src/roles/permissions.ts +++ b/apps/backend/src/roles/permissions.ts @@ -14,6 +14,7 @@ export enum Permission { NotificationsReadOwn = 'notifications.readOwn', NotificationsUpdateOwn = 'notifications.updateOwn', NotificationsManage = 'notifications.manage', + ProjectsUse = 'projects.use', } export const allPermissions = Object.values(Permission); diff --git a/apps/backend/src/roles/roles.service.ts b/apps/backend/src/roles/roles.service.ts index 0c65597..38b6341 100644 --- a/apps/backend/src/roles/roles.service.ts +++ b/apps/backend/src/roles/roles.service.ts @@ -189,6 +189,7 @@ export class RolesService { Permission.SessionsReadOwn, Permission.NotificationsReadOwn, Permission.NotificationsUpdateOwn, + Permission.ProjectsUse, ], true, manager, diff --git a/apps/backend/src/users/entities/user.entity.ts b/apps/backend/src/users/entities/user.entity.ts index 21c6f0b..a465227 100644 --- a/apps/backend/src/users/entities/user.entity.ts +++ b/apps/backend/src/users/entities/user.entity.ts @@ -34,6 +34,9 @@ export class UserEntity { @Column({ type: 'varchar', length: 320, nullable: true }) email!: string | null; + @Column({ name: 'email_verified', type: 'boolean', nullable: true }) + emailVerified!: boolean | null; + @Column({ type: 'boolean', default: true }) active!: boolean; diff --git a/apps/backend/src/users/repositories/users.repository.ts b/apps/backend/src/users/repositories/users.repository.ts index 416baed..468b17b 100644 --- a/apps/backend/src/users/repositories/users.repository.ts +++ b/apps/backend/src/users/repositories/users.repository.ts @@ -23,6 +23,19 @@ export class UsersRepository { return this.repo.findOne({ where: { issuer, subject } }); } + async findActiveByNormalizedEmail( + normalizedEmail: string, + manager?: EntityManager, + ): Promise { + const users = await (manager?.getRepository(UserEntity) ?? this.repo) + .createQueryBuilder('user') + .where('user.active = :active', { active: true }) + .andWhere('LOWER(TRIM(user.email)) = :email', { email: normalizedEmail }) + .take(2) + .getMany(); + return users.length === 1 ? (users[0] ?? null) : null; + } + async search( query: string | undefined, page: number, diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index 2038d03..772a519 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -7,7 +7,7 @@ "outDir": "dist", "rootDir": "src", "lib": ["ES2023"], - "types": ["node", "vitest"], + "types": ["node", "vitest", "multer"], "experimentalDecorators": true, "emitDecoratorMetadata": true, "isolatedModules": false, diff --git a/apps/frontend/.prettierrc b/apps/frontend/.prettierrc index d6c16d7..3491200 100644 --- a/apps/frontend/.prettierrc +++ b/apps/frontend/.prettierrc @@ -1,6 +1,7 @@ { "printWidth": 100, "singleQuote": true, + "endOfLine": "auto", "overrides": [ { "files": "*.html", diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 845c470..5d43826 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -18,6 +18,8 @@ "@angular/platform-browser-dynamic": "22.0.6", "@angular/router": "22.0.6", "@boilerplate/api-client": "1.0.0", + "ag-grid-angular": "^36.0.1", + "ag-grid-community": "^36.0.1", "rxjs": "7.8.2", "tslib": "2.8.1" } diff --git a/apps/frontend/src/app/app.config.ts b/apps/frontend/src/app/app.config.ts index 7562033..0a16893 100644 --- a/apps/frontend/src/app/app.config.ts +++ b/apps/frontend/src/app/app.config.ts @@ -1,16 +1,22 @@ -import { provideBrowserGlobalErrorListeners } from '@angular/core'; +import { LOCALE_ID, provideBrowserGlobalErrorListeners } from '@angular/core'; import type { ApplicationConfig } from '@angular/core'; import { provideHttpClient, withInterceptors } from '@angular/common/http'; +import { registerLocaleData } from '@angular/common'; +import localeDe from '@angular/common/locales/de'; import { provideRouter, withComponentInputBinding } from '@angular/router'; import { routes } from './app.routes'; import { csrfInterceptor } from './core/csrf.interceptor'; +import { sessionExpiryInterceptor } from './core/session-expiry.interceptor'; import { titleStrategyProvider } from './core/title.strategy'; +registerLocaleData(localeDe); + export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), - provideHttpClient(withInterceptors([csrfInterceptor])), + { provide: LOCALE_ID, useValue: 'de-DE' }, + provideHttpClient(withInterceptors([csrfInterceptor, sessionExpiryInterceptor])), provideRouter(routes, withComponentInputBinding()), titleStrategyProvider, ], diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index 622462b..ae58be7 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -13,6 +13,46 @@ export const routes: Routes = [ loadComponent: () => import('./features/dashboard/dashboard.page').then((m) => m.DashboardPageComponent), }, + { + path: 'projekte', + title: 'Projekte', + canActivate: [permissionGuard], + data: { permissions: ['projects.use'] }, + loadComponent: () => + import('./features/projects/projects.page').then((m) => m.ProjectsPageComponent), + }, + { + path: 'projekte/:id/:section', + title: 'HausPilot-Projekt', + canActivate: [permissionGuard], + data: { permissions: ['projects.use'] }, + loadComponent: () => + import('./features/projects/project-workspace.page').then( + (m) => m.ProjectWorkspacePageComponent, + ), + }, + { + path: 'projekte/:id', + title: 'Projekt', + canActivate: [permissionGuard], + data: { permissions: ['projects.use'] }, + loadComponent: () => + import('./features/projects/project-detail.page').then( + (m) => m.ProjectDetailPageComponent, + ), + }, + { + path: 'einladungen', + title: 'Einladungen', + loadComponent: () => + import('./features/projects/invitations.page').then((m) => m.InvitationsPageComponent), + }, + { + path: 'einladungen/:token', + title: 'Projekteinladung', + loadComponent: () => + import('./features/projects/invitations.page').then((m) => m.InvitationsPageComponent), + }, { path: 'profil', title: 'Profil', diff --git a/apps/frontend/src/app/core/auth.service.spec.ts b/apps/frontend/src/app/core/auth.service.spec.ts index 54971dc..e37ac1c 100644 --- a/apps/frontend/src/app/core/auth.service.spec.ts +++ b/apps/frontend/src/app/core/auth.service.spec.ts @@ -7,6 +7,7 @@ const user: UserDto = { id: 'u1', name: 'Ada', email: 'ada@example.test', + emailVerified: true, active: true, lastLoginAt: null, settings: { tablePageSize: 20, sidebarExpanded: true }, @@ -32,4 +33,17 @@ describe('AuthService', () => { expect(service.has('items.read')).toBe(true); expect(service.has('users.manage')).toBe(false); }); + + it('clears the current user when the server session expires', () => { + TestBed.configureTestingModule({ + providers: [{ provide: ApiClientService, useValue: { me: () => of(user) } }], + }); + const service = TestBed.inject(AuthService); + service.user.set(user); + + service.clearSessionState(); + + expect(service.user()).toBeNull(); + expect(service.loaded()).toBe(true); + }); }); diff --git a/apps/frontend/src/app/core/auth.service.ts b/apps/frontend/src/app/core/auth.service.ts index 3f8798b..d272c24 100644 --- a/apps/frontend/src/app/core/auth.service.ts +++ b/apps/frontend/src/app/core/auth.service.ts @@ -40,4 +40,9 @@ export class AuthService { has(permission: Permission): boolean { return this.permissions().has(permission); } + + clearSessionState(): void { + this.user.set(null); + this.loaded.set(true); + } } diff --git a/apps/frontend/src/app/core/session-expiry.interceptor.ts b/apps/frontend/src/app/core/session-expiry.interceptor.ts new file mode 100644 index 0000000..7d7e639 --- /dev/null +++ b/apps/frontend/src/app/core/session-expiry.interceptor.ts @@ -0,0 +1,21 @@ +import type { HttpInterceptorFn } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { catchError, throwError } from 'rxjs'; +import { AuthService } from './auth.service'; + +export const sessionExpiryInterceptor: HttpInterceptorFn = (request, next) => { + const auth = inject(AuthService); + return next(request).pipe( + catchError((error: unknown) => { + if ( + typeof error === 'object' && + error !== null && + 'status' in error && + error.status === 401 + ) { + auth.clearSessionState(); + } + return throwError(() => error); + }), + ); +}; diff --git a/apps/frontend/src/app/features/account/account-security.page.spec.ts b/apps/frontend/src/app/features/account/account-security.page.spec.ts index d8b6710..d93625c 100644 --- a/apps/frontend/src/app/features/account/account-security.page.spec.ts +++ b/apps/frontend/src/app/features/account/account-security.page.spec.ts @@ -10,6 +10,7 @@ const user: UserDto = { id: 'u1', name: 'Ada Lovelace', email: 'ada@example.com', + emailVerified: true, active: true, lastLoginAt: '2026-07-16T08:30:00.000Z', settings: { tablePageSize: 20, sidebarExpanded: true }, diff --git a/apps/frontend/src/app/features/projects/furniture-grid.component.spec.ts b/apps/frontend/src/app/features/projects/furniture-grid.component.spec.ts new file mode 100644 index 0000000..bb77082 --- /dev/null +++ b/apps/frontend/src/app/features/projects/furniture-grid.component.spec.ts @@ -0,0 +1,304 @@ +import { provideHttpClient } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { vi } from 'vitest'; +import { AuthService } from '../../core/auth.service'; +import { FurnitureGridComponent } from './furniture-grid.component'; +import type { + FurnitureOption, + FurnitureRequirement, + FurnitureScenario, + PageResult, +} from './hauspilot-api.service'; +import { HauspilotApiService } from './hauspilot-api.service'; + +const requirement: FurnitureRequirement = { + id: 'requirement-1', + version: 2, + createdAt: '2026-01-01', + updatedAt: '2026-01-02', + projectId: 'project-1', + roomId: 'room-1', + name: 'Sofa', + description: null, + category: 'seating', + priority: 'high', + requiredQuantity: 1, + status: 'decision_open', + responsibleUserId: null, + maximumBudget: '2000.00', + sortOrder: 0, + options: [], + optionCount: 0, +}; +const page: PageResult = { + items: [requirement], + page: 1, + pageSize: 50, + totalItems: 1, + totalPages: 1, +}; + +describe('FurnitureGridComponent', () => { + it('uses stable database IDs and passes paging and room filters to the API', async () => { + const calls: Array> = []; + const api = { + furnitureRequirements: ( + _id: string, + query: Record, + ) => { + calls.push(query); + return of(page); + }, + furnitureProjectOptions: () => of({ ...page, items: [] }), + }; + await TestBed.configureTestingModule({ + imports: [FurnitureGridComponent], + providers: [ + provideHttpClient(), + { provide: HauspilotApiService, useValue: api }, + { provide: AuthService, useValue: { user: () => ({ id: 'user-1' }) } }, + ], + }).compileComponents(); + const component = TestBed.createComponent(FurnitureGridComponent).componentInstance; + component.projectId = 'project-1'; + component.roomId = 'room-1'; + component.load(2); + expect(component.getRowId({ data: requirement })).toBe('requirement-1'); + expect(calls[0]).toMatchObject({ page: 2, pageSize: 50, roomId: 'room-1' }); + }); + + it('debounces search requests', () => { + vi.useFakeTimers(); + let calls = 0; + const api = { + furnitureRequirements: () => { + calls += 1; + return of(page); + }, + furnitureProjectOptions: () => of({ ...page, items: [] }), + }; + TestBed.configureTestingModule({ + imports: [FurnitureGridComponent], + providers: [ + provideHttpClient(), + { provide: HauspilotApiService, useValue: api }, + { provide: AuthService, useValue: { user: () => ({ id: 'user-1' }) } }, + ], + }); + const component = TestBed.createComponent(FurnitureGridComponent).componentInstance; + component.projectId = 'project-1'; + component.searchChanges.next('So'); + vi.advanceTimersByTime(150); + component.searchChanges.next('Sofa'); + vi.advanceTimersByTime(299); + expect(calls).toBe(0); + vi.advanceTimersByTime(1); + expect(calls).toBe(1); + vi.useRealTimers(); + }); + + it('does not expose editable requirement cells to readers', async () => { + await TestBed.configureTestingModule({ + imports: [FurnitureGridComponent], + providers: [ + provideHttpClient(), + { + provide: HauspilotApiService, + useValue: { + furnitureRequirements: () => of(page), + furnitureProjectOptions: () => of({ ...page, items: [] }), + }, + }, + { provide: AuthService, useValue: { user: () => ({ id: 'reader-1' }) } }, + ], + }).compileComponents(); + const component = TestBed.createComponent(FurnitureGridComponent).componentInstance; + component.canEdit = false; + component.ngOnChanges(); + expect(component.columns().filter((column) => column.editable === true)).toHaveLength(0); + }); + + it('uses select editors and readable icon labels for priority and status', async () => { + await TestBed.configureTestingModule({ + imports: [FurnitureGridComponent], + providers: [ + provideHttpClient(), + { + provide: HauspilotApiService, + useValue: { + furnitureRequirements: () => of(page), + furnitureProjectOptions: () => of({ ...page, items: [] }), + }, + }, + { provide: AuthService, useValue: { user: () => ({ id: 'editor-1' }) } }, + ], + }).compileComponents(); + const component = TestBed.createComponent(FurnitureGridComponent).componentInstance; + component.canEdit = true; + component.ngOnChanges(); + const priority = component.columns().find((column) => column.field === 'priority'); + const status = component.columns().find((column) => column.field === 'status'); + expect(priority?.cellEditor).toBe('agSelectCellEditor'); + expect(status?.cellEditor).toBe('agSelectCellEditor'); + expect(priority?.cellEditorParams).toMatchObject({ + values: ['optional', 'low', 'normal', 'high', 'essential'], + }); + expect(status?.cellEditorParams).toMatchObject({ + values: [ + 'identified', + 'research', + 'has_options', + 'decision_open', + 'selected', + 'ordered', + 'partially_delivered', + 'delivered', + 'assembled', + 'omitted', + ], + }); + expect( + typeof priority?.valueFormatter === 'function' + ? priority.valueFormatter({ value: 'essential' } as never) + : '', + ).toBe('◆ Unverzichtbar'); + expect( + typeof status?.valueFormatter === 'function' + ? status.valueFormatter({ value: 'decision_open' } as never) + : '', + ).toBe('? Entscheidung offen'); + }); + + it('falls back to a supported server sort when persisted grid state is invalid', async () => { + const calls: Array> = []; + const api = { + furnitureRequirements: ( + _id: string, + query: Record, + ) => { + calls.push(query); + return of(page); + }, + furnitureProjectOptions: () => of({ ...page, items: [] }), + }; + await TestBed.configureTestingModule({ + imports: [FurnitureGridComponent], + providers: [ + provideHttpClient(), + { provide: HauspilotApiService, useValue: api }, + { provide: AuthService, useValue: { user: () => ({ id: 'user-1' }) } }, + ], + }).compileComponents(); + const component = TestBed.createComponent(FurnitureGridComponent).componentInstance; + component.projectId = 'project-1'; + Object.defineProperty(component, 'gridApi', { + value: { getColumnState: () => [{ colId: 'optionCount', sort: 'asc' }] }, + }); + component.load(1); + expect(calls[0]?.['sortBy']).toBe('sortOrder'); + }); + + it('assigns an option to a scenario through the existing selection endpoint', async () => { + const option: FurnitureOption = { + id: 'option-1', + version: 1, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + projectId: 'project-1', + requirementId: requirement.id, + name: 'Sofa Wunsch', + manufacturer: null, + model: null, + description: null, + retailer: null, + productUrl: null, + articleNumber: null, + unitPrice: '1000.00', + originalPrice: null, + shippingCost: '0.00', + additionalCost: '0.00', + discount: '0.00', + totalPrice: '1000.00', + currency: 'EUR', + quantity: 1, + width: null, + height: null, + depth: null, + weight: null, + color: null, + material: null, + deliveryDays: null, + expectedDeliveryDate: null, + availability: 'available', + favorite: true, + currentlySelected: false, + status: 'favorite', + notes: null, + budgetCategoryId: null, + existingItem: false, + movingCost: '0.00', + refurbishmentCost: '0.00', + deliveryStatus: 'not_ordered', + deliveredQuantity: 0, + orderNumber: null, + orderedAt: null, + }; + const scenario: FurnitureScenario = { + id: 'scenario-1', + version: 3, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + projectId: 'project-1', + name: 'Wunsch', + description: null, + type: 'preferred', + status: 'active', + isDefault: true, + total: '0.00', + selectedRequirements: 0, + openRequirements: 1, + byRoom: {}, + selections: [], + }; + const updateSelections = vi.fn(() => + of({ + ...scenario, + version: 4, + total: option.totalPrice, + selections: [ + { requirementId: requirement.id, optionId: option.id, quantity: option.quantity }, + ], + }), + ); + await TestBed.configureTestingModule({ + imports: [FurnitureGridComponent], + providers: [ + provideHttpClient(), + { + provide: HauspilotApiService, + useValue: { + furnitureRequirements: () => of(page), + furnitureProjectOptions: () => of({ ...page, items: [] }), + updateFurnitureScenarioSelections: updateSelections, + }, + }, + { provide: AuthService, useValue: { user: () => ({ id: 'editor-1' }) } }, + ], + }).compileComponents(); + const component = TestBed.createComponent(FurnitureGridComponent).componentInstance; + component.projectId = 'project-1'; + component.canEdit = true; + component.scenarios = [scenario]; + component.cellChanged({ + oldValue: '', + newValue: option.id, + data: { ...requirement, options: [option] }, + column: { getColId: () => `scenario:${scenario.id}` }, + node: { setDataValue: vi.fn() }, + } as never); + expect(updateSelections).toHaveBeenCalledWith('project-1', scenario, [ + { requirementId: requirement.id, optionId: option.id, quantity: 1 }, + ]); + }); +}); diff --git a/apps/frontend/src/app/features/projects/furniture-grid.component.ts b/apps/frontend/src/app/features/projects/furniture-grid.component.ts new file mode 100644 index 0000000..03316a8 --- /dev/null +++ b/apps/frontend/src/app/features/projects/furniture-grid.component.ts @@ -0,0 +1,975 @@ +import { CurrencyPipe } from '@angular/common'; +import { Component, EventEmitter, Input, Output, inject, signal } from '@angular/core'; +import type { OnChanges } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { AgGridAngular } from 'ag-grid-angular'; +import { + AllCommunityModule, + ModuleRegistry, + themeQuartz, + type CellClickedEvent, + type CellStyle, + type CellValueChangedEvent, + type ColDef, + type GridApi, + type GridReadyEvent, +} from 'ag-grid-community'; +import { debounceTime, distinctUntilChanged, Subject, type Observable } from 'rxjs'; +import { AuthService } from '../../core/auth.service'; +import type { + FurnitureOption, + FurnitureRequirement, + FurnitureScenario, + PageResult, + Room, +} from './hauspilot-api.service'; +import { HauspilotApiService } from './hauspilot-api.service'; +import { conflictMessage } from './project-workspace.helpers'; + +ModuleRegistry.registerModules([AllCommunityModule]); + +type View = 'requirements' | 'options' | 'orders' | 'scenarios'; +type FurnitureRequirementRow = FurnitureRequirement & { + scenarioSelections?: Record; +}; +type FurnitureRow = FurnitureRequirementRow | FurnitureOption; + +const money = (value: unknown) => + new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(Number(value ?? 0)); +const parseGermanNumber = (value: unknown) => { + if (typeof value === 'number') return Number.isFinite(value) ? value : null; + if (typeof value !== 'string') return null; + const normalized = value.trim().replace(/\./g, '').replace(',', '.'); + const parsed = Number(normalized); + return Number.isFinite(parsed) ? parsed : null; +}; + +interface GridValuePresentation { + icon: string; + label: string; + tone: 'neutral' | 'info' | 'success' | 'warning' | 'danger'; +} + +const requirementPriorities: Record = { + optional: { icon: '○', label: 'Optional', tone: 'neutral' }, + low: { icon: '↓', label: 'Niedrig', tone: 'neutral' }, + normal: { icon: '●', label: 'Normal', tone: 'info' }, + high: { icon: '↑', label: 'Hoch', tone: 'warning' }, + essential: { icon: '◆', label: 'Unverzichtbar', tone: 'danger' }, +}; + +const requirementStatuses: Record = { + identified: { icon: '◌', label: 'Bedarf erkannt', tone: 'neutral' }, + research: { icon: '⌕', label: 'Recherche', tone: 'info' }, + has_options: { icon: '≡', label: 'Alternativen vorhanden', tone: 'info' }, + decision_open: { icon: '?', label: 'Entscheidung offen', tone: 'warning' }, + selected: { icon: '✓', label: 'Ausgewählt', tone: 'info' }, + ordered: { icon: '▣', label: 'Bestellt', tone: 'info' }, + partially_delivered: { icon: '◒', label: 'Teilweise geliefert', tone: 'warning' }, + delivered: { icon: '✓', label: 'Geliefert', tone: 'success' }, + assembled: { icon: '⌂', label: 'Aufgebaut', tone: 'success' }, + omitted: { icon: '—', label: 'Entfällt', tone: 'neutral' }, +}; + +const optionStatuses: Record = { + idea: { icon: '◌', label: 'Idee', tone: 'neutral' }, + reviewing: { icon: '⌕', label: 'In Prüfung', tone: 'info' }, + favorite: { icon: '★', label: 'Favorit', tone: 'warning' }, + selected: { icon: '✓', label: 'Ausgewählt', tone: 'info' }, + rejected: { icon: '×', label: 'Abgelehnt', tone: 'neutral' }, + unavailable: { icon: '!', label: 'Nicht verfügbar', tone: 'danger' }, + ordered: { icon: '▣', label: 'Bestellt', tone: 'info' }, + delivered: { icon: '✓', label: 'Geliefert', tone: 'success' }, + returned: { icon: '↩', label: 'Zurückgegeben', tone: 'warning' }, + archived: { icon: '—', label: 'Archiviert', tone: 'neutral' }, +}; + +const availabilityValues: Record = { + unknown: { icon: '?', label: 'Unbekannt', tone: 'neutral' }, + available: { icon: '✓', label: 'Verfügbar', tone: 'success' }, + limited: { icon: '!', label: 'Begrenzt verfügbar', tone: 'warning' }, + unavailable: { icon: '×', label: 'Nicht verfügbar', tone: 'danger' }, + discontinued: { icon: '—', label: 'Nicht mehr erhältlich', tone: 'danger' }, +}; + +const deliveryStatuses: Record = { + not_ordered: { icon: '○', label: 'Nicht bestellt', tone: 'neutral' }, + planned: { icon: '◌', label: 'Bestellung geplant', tone: 'info' }, + ordered: { icon: '▣', label: 'Bestellt', tone: 'info' }, + shipped: { icon: '→', label: 'Versandt', tone: 'info' }, + partially_delivered: { icon: '◒', label: 'Teilweise geliefert', tone: 'warning' }, + delivered: { icon: '✓', label: 'Geliefert', tone: 'success' }, + delayed: { icon: '!', label: 'Lieferverzögerung', tone: 'danger' }, + cancelled: { icon: '×', label: 'Storniert', tone: 'neutral' }, + returned: { icon: '↩', label: 'Zurückgegeben', tone: 'warning' }, +}; + +@Component({ + selector: 'app-furniture-grid', + standalone: true, + imports: [AgGridAngular, CurrencyPipe, FormsModule], + template: ` +
+
+ @for (entry of views; track entry.id) { + + } +
+
+ + + @if (view() === 'requirements') { + + + } + @if (view() === 'options' || view() === 'orders') { + + + } + +
+ @if (error()) { + + } + @if (savingRow()) { +

Änderung wird gespeichert …

+ } + @if (view() === 'scenarios') { +

+ Szenariozuweisung: Klicken Sie in eine Szenariospalte und wählen Sie eine Alternative aus. + „Nicht zugewiesen“ lässt den Bedarf im Szenario offen. +

+ } + + + @if (view() === 'scenarios') { +
+ @for (scenario of scenarios; track scenario.id) { +
+ {{ scenario.name }}{{ scenario.total | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' }}{{ scenario.openRequirements }} offene Bedarfe +
+ } +
+ } +
+ `, + styles: [ + ` + :host { + display: block; + } + .grid-shell { + display: grid; + gap: var(--space-3); + } + .grid-tabs { + display: flex; + gap: var(--space-2); + overflow: auto; + } + .grid-tabs button { + min-height: 2.75rem; + border: 0; + border-bottom: 0.2rem solid transparent; + background: transparent; + color: var(--color-text-muted); + padding: var(--space-2) var(--space-4); + cursor: pointer; + } + .grid-tabs button.active { + color: var(--color-primary); + border-bottom-color: var(--color-primary); + font-weight: 700; + } + .toolbar { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: var(--space-3); + } + .toolbar label { + display: grid; + gap: var(--space-1); + min-width: 11rem; + } + .toolbar label:has(input[type='checkbox']) { + display: flex; + min-width: auto; + align-items: center; + min-height: 2.75rem; + } + .toolbar input:not([type='checkbox']), + .toolbar select { + min-height: 2.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text); + padding: 0 var(--space-2); + } + .furniture-grid { + width: 100%; + height: min(62vh, 42rem); + min-height: 25rem; + } + .pager { + display: flex; + justify-content: center; + align-items: center; + gap: var(--space-3); + } + .pager button { + min-height: 2.75rem; + } + .row-error { + color: var(--color-danger); + border-left: 0.25rem solid var(--color-danger); + padding: var(--space-2); + } + .save-state { + color: var(--color-text-muted); + } + .scenario-help { + margin: 0; + color: var(--color-info); + background: var(--color-info-subtle); + border-left: 0.25rem solid var(--color-info); + padding: var(--space-3) var(--space-4); + } + .scenario-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); + gap: var(--space-3); + } + .scenario-summary article { + display: grid; + gap: var(--space-1); + } + @media (max-width: 40rem) { + .toolbar { + display: grid; + } + .toolbar label { + min-width: 0; + } + .furniture-grid { + height: 32rem; + } + } + `, + ], +}) +export class FurnitureGridComponent implements OnChanges { + @Input({ required: true }) projectId = ''; + @Input() rooms: Room[] = []; + @Input() canEdit = false; + @Input() scenarios: FurnitureScenario[] = []; + @Output() editRequirement = new EventEmitter(); + @Output() addOption = new EventEmitter(); + @Output() editOption = new EventEmitter(); + @Output() dataChanged = new EventEmitter(); + private readonly api = inject(HauspilotApiService); + private readonly auth = inject(AuthService); + private gridApi?: GridApi; + private rollback = false; + readonly view = signal('requirements'); + readonly rows = signal([]); + readonly loading = signal(false); + readonly savingRow = signal(null); + readonly error = signal(null); + readonly page = signal(1); + readonly totalPages = signal(1); + readonly totalItems = signal(0); + readonly searchChanges = new Subject(); + search = ''; + roomId = ''; + openOnly = false; + overBudget = false; + favoriteOnly = false; + delayedOnly = false; + readonly views: Array<{ id: View; label: string }> = [ + { id: 'requirements', label: 'Bedarfe' }, + { id: 'options', label: 'Alternativen' }, + { id: 'orders', label: 'Bestellungen' }, + { id: 'scenarios', label: 'Szenarien' }, + ]; + readonly gridTheme = themeQuartz.withParams({ + accentColor: 'var(--color-primary)', + backgroundColor: 'var(--color-surface)', + foregroundColor: 'var(--color-text)', + borderColor: 'var(--color-border)', + headerBackgroundColor: 'var(--color-surface-muted)', + rowHoverColor: 'var(--color-surface-muted)', + }); + readonly defaultColDef: ColDef = { sortable: true, resizable: true, minWidth: 110 }; + readonly rowSelection = { mode: 'multiRow' as const, checkboxes: true, headerCheckbox: true }; + readonly getRowId = (params: { data: FurnitureRow }) => params.data.id; + readonly columns = signal[]>([]); + + constructor() { + this.searchChanges + .pipe(debounceTime(300), distinctUntilChanged()) + .subscribe(() => this.load(1)); + } + ngOnChanges() { + this.refreshColumns(); + if (this.projectId) this.load(1); + } + setView(view: View) { + this.view.set(view); + this.refreshColumns(); + this.load(1); + setTimeout(() => this.restoreState()); + } + gridReady(event: GridReadyEvent) { + this.gridApi = event.api; + this.restoreState(); + } + load(page = 1) { + if (!this.projectId) return; + this.loading.set(true); + this.error.set(null); + const sort = this.gridApi?.getColumnState().find((column) => column.sort); + const fallbackSort = + this.view() === 'requirements' || this.view() === 'scenarios' ? 'sortOrder' : 'updatedAt'; + const sortBy = this.allowedSorts().has(sort?.colId ?? '') + ? (sort?.colId ?? fallbackSort) + : fallbackSort; + const query = { + page, + pageSize: 50, + search: this.search, + roomId: this.roomId, + sortBy, + sortDirection: sort?.sort === 'asc' ? 'ASC' : 'DESC', + ...(this.openOnly ? { openDecision: true } : {}), + ...(this.overBudget ? { overBudget: true } : {}), + ...(this.favoriteOnly ? { favorite: true } : {}), + ...(this.delayedOnly ? { delayed: true } : {}), + ...(this.view() === 'orders' ? { ordered: true } : {}), + }; + const request: Observable> = + this.view() === 'requirements' || this.view() === 'scenarios' + ? this.api.furnitureRequirements(this.projectId, query) + : this.api.furnitureProjectOptions(this.projectId, query); + request.subscribe({ + next: (result) => { + this.rows.set(this.withScenarioSelections(result.items)); + this.page.set(result.page); + this.totalPages.set(Math.max(1, result.totalPages)); + this.totalItems.set(result.totalItems); + this.loading.set(false); + }, + error: (e: unknown) => this.fail(e), + }); + } + sortChanged() { + if (!this.loading()) this.load(1); + this.persistState(); + } + cellChanged(event: CellValueChangedEvent) { + if (this.rollback || !this.canEdit || event.oldValue === event.newValue || !event.data) return; + const scenarioId = this.scenarioId(event.column.getColId()); + if (scenarioId && 'requiredQuantity' in event.data) { + this.saveScenarioSelection(event, scenarioId, event.data); + return; + } + const row = event.data; + this.savingRow.set(row.id); + this.error.set(null); + const request: Observable = + 'requiredQuantity' in row + ? this.api.updateFurnitureRequirement(this.projectId, row, this.requirementBody(row)) + : this.api.updateFurnitureOption(this.projectId, row, this.optionBody(row)); + request.subscribe({ + next: (saved) => { + event.node.setData(saved); + this.savingRow.set(null); + this.dataChanged.emit(); + }, + error: (error: unknown) => { + this.rollback = true; + event.node.setDataValue(event.column.getColId(), event.oldValue); + this.rollback = false; + this.savingRow.set(null); + this.fail(error); + if (this.status(error) === 409) this.load(this.page()); + }, + }); + } + cellClicked(event: CellClickedEvent) { + if (!event.data) return; + const id = event.column.getColId(); + if ('requiredQuantity' in event.data) { + if (id === 'actions') this.editRequirement.emit(event.data); + if (id === 'addOption') this.addOption.emit(event.data); + return; + } + if (id === 'actions') this.editOption.emit(event.data); + if (!this.canEdit) return; + if (id === 'favorite') + this.api.favoriteFurnitureOption(this.projectId, event.data.id).subscribe({ + next: () => { + this.load(this.page()); + this.dataChanged.emit(); + }, + error: (e: unknown) => this.fail(e), + }); + if (id === 'currentlySelected') + this.api.selectFurnitureOption(this.projectId, event.data).subscribe({ + next: () => { + this.load(this.page()); + this.dataChanged.emit(); + }, + error: (e: unknown) => { + this.fail(e); + this.load(this.page()); + }, + }); + } + exportCsv() { + this.gridApi?.exportDataAsCsv({ fileName: `hauspilot-${this.view()}.csv` }); + } + saveState() { + this.persistState(); + } + private requirementColumns(): ColDef[] { + return [ + { + field: 'roomId', + headerName: 'Raum', + valueFormatter: (p) => this.rooms.find((r) => r.id === p.value)?.name ?? '–', + editable: this.canEdit, + cellEditor: 'agSelectCellEditor', + cellEditorParams: { values: this.rooms.map((r) => r.id) }, + sort: 'asc', + colId: 'room', + }, + { field: 'category', headerName: 'Kategorie', editable: this.canEdit }, + { field: 'name', headerName: 'Möbelbedarf', editable: this.canEdit, minWidth: 180 }, + { + field: 'priority', + headerName: 'Priorität', + editable: this.canEdit, + cellEditor: 'agSelectCellEditor', + cellEditorParams: { values: Object.keys(requirementPriorities) }, + valueFormatter: (params) => this.present(params.value, requirementPriorities), + cellStyle: (params) => this.presentationStyle(params.value, requirementPriorities), + minWidth: 155, + }, + { + field: 'requiredQuantity', + headerName: 'Menge', + editable: this.canEdit, + type: 'numericColumn', + }, + { + field: 'status', + headerName: 'Status', + editable: this.canEdit, + cellEditor: 'agSelectCellEditor', + cellEditorParams: { values: Object.keys(requirementStatuses) }, + valueFormatter: (params) => this.present(params.value, requirementStatuses), + cellStyle: (params) => this.presentationStyle(params.value, requirementStatuses), + minWidth: 205, + }, + { + field: 'maximumBudget', + headerName: 'Maximalbudget', + editable: this.canEdit, + valueFormatter: (p) => money(p.value), + valueParser: (p) => parseGermanNumber(p.newValue), + }, + { field: 'optionCount', headerName: 'Alternativen' }, + { + field: 'cheapestOption', + headerName: 'Günstigste', + valueFormatter: (p) => this.optionLabel(p.value, 'price'), + minWidth: 190, + }, + { + field: 'favoriteOption', + headerName: 'Favorit', + valueFormatter: (p) => this.optionLabel(p.value, 'favorite'), + }, + { + field: 'selectedOption', + headerName: 'Ausgewählt', + valueFormatter: (p) => this.optionLabel(p.value, 'selected'), + }, + { + field: 'selectedTotalPrice', + headerName: 'Gesamtpreis', + valueFormatter: (p) => (p.value ? money(p.value) : '–'), + }, + { + field: 'budgetVariance', + headerName: 'Abweichung', + valueFormatter: (p) => + p.value === null ? '–' : `${Number(p.value) > 0 ? '⚠ ' : '✓ '}${money(p.value)}`, + }, + { + field: 'orderStatus', + headerName: 'Bestellung', + valueFormatter: (params) => this.present(params.value, deliveryStatuses), + cellStyle: (params) => this.presentationStyle(params.value, deliveryStatuses), + minWidth: 180, + }, + { field: 'expectedDelivery', headerName: 'Lieferung' }, + { + field: 'updatedAt', + headerName: 'Geändert', + valueFormatter: (p) => this.formatDate(p.value), + }, + { + colId: 'addOption', + headerName: 'Alternative', + valueGetter: () => (this.canEdit ? '+ hinzufügen' : 'anzeigen'), + sortable: false, + }, + { + colId: 'actions', + headerName: 'Aktionen', + valueGetter: () => (this.canEdit ? 'Details bearbeiten' : 'Details'), + sortable: false, + pinned: 'right', + }, + ]; + } + private refreshColumns() { + this.columns.set( + this.view() === 'requirements' + ? this.requirementColumns() + : this.view() === 'scenarios' + ? this.scenarioColumns() + : this.optionColumns(), + ); + } + private allowedSorts() { + return new Set( + this.view() === 'requirements' || this.view() === 'scenarios' + ? [ + 'name', + 'room', + 'category', + 'priority', + 'status', + 'updatedAt', + 'sortOrder', + 'price', + 'deliveryDate', + ] + : [ + 'name', + 'retailer', + 'unitPrice', + 'totalPrice', + 'status', + 'availability', + 'expectedDeliveryDate', + 'updatedAt', + 'room', + 'requirement', + ], + ); + } + private optionColumns(): ColDef[] { + return [ + { field: 'roomName', headerName: 'Raum', colId: 'room' }, + { field: 'requirementName', headerName: 'Möbelbedarf', colId: 'requirement', minWidth: 170 }, + { field: 'name', headerName: 'Produkt', editable: this.canEdit, minWidth: 180 }, + { field: 'manufacturer', headerName: 'Hersteller', editable: this.canEdit }, + { field: 'retailer', headerName: 'Händler', editable: this.canEdit }, + { field: 'articleNumber', headerName: 'Artikelnummer' }, + ...(['unitPrice', 'quantity', 'shippingCost', 'additionalCost', 'discount'] as const).map( + (field) => ({ + field, + headerName: { + unitPrice: 'Einzelpreis', + quantity: 'Menge', + shippingCost: 'Versand', + additionalCost: 'Zusatzkosten', + discount: 'Rabatt', + }[field], + editable: this.canEdit, + valueFormatter: + field === 'quantity' ? undefined : (p: { value: unknown }) => money(p.value), + valueParser: (p: { newValue: unknown }) => parseGermanNumber(p.newValue), + }), + ), + { field: 'totalPrice', headerName: 'Gesamtpreis', valueFormatter: (p) => money(p.value) }, + { field: 'color', headerName: 'Farbe', editable: this.canEdit }, + { field: 'material', headerName: 'Material', editable: this.canEdit }, + { field: 'width', headerName: 'Breite', editable: this.canEdit }, + { field: 'height', headerName: 'Höhe', editable: this.canEdit }, + { field: 'depth', headerName: 'Tiefe', editable: this.canEdit }, + { + field: 'availability', + headerName: 'Verfügbarkeit', + editable: this.canEdit, + cellEditor: 'agSelectCellEditor', + cellEditorParams: { values: Object.keys(availabilityValues) }, + valueFormatter: (params) => this.present(params.value, availabilityValues), + cellStyle: (params) => this.presentationStyle(params.value, availabilityValues), + minWidth: 180, + }, + { field: 'expectedDeliveryDate', headerName: 'Erwartet', editable: this.canEdit }, + { + field: 'favorite', + headerName: 'Favorit', + valueFormatter: (p) => (p.value ? '★ Favorit' : '☆ setzen'), + sortable: false, + }, + { + field: 'currentlySelected', + headerName: 'Auswahl', + valueFormatter: (p) => (p.value ? '◉ Ausgewählt' : '○ auswählen'), + sortable: false, + }, + { + field: 'status', + headerName: 'Status', + editable: this.canEdit, + cellEditor: 'agSelectCellEditor', + cellEditorParams: { values: Object.keys(optionStatuses) }, + valueFormatter: (params) => this.present(params.value, optionStatuses), + cellStyle: (params) => this.presentationStyle(params.value, optionStatuses), + minWidth: 165, + }, + { + field: 'deliveryStatus', + headerName: 'Lieferstatus', + valueFormatter: (params) => this.present(params.value, deliveryStatuses), + cellStyle: (params) => this.presentationStyle(params.value, deliveryStatuses), + minWidth: 185, + }, + { field: 'orderNumber', headerName: 'Bestellnummer' }, + { + field: 'orderedAt', + headerName: 'Bestellt am', + valueFormatter: (p) => this.formatDate(p.value), + }, + { field: 'actualDeliveryDate', headerName: 'Geliefert am' }, + { + colId: 'actions', + headerName: 'Aktionen', + valueGetter: () => (this.canEdit ? 'Details bearbeiten' : 'Details'), + sortable: false, + pinned: 'right', + }, + ] as ColDef[]; + } + private scenarioColumns(): ColDef[] { + const scenarioColumns: ColDef[] = this.scenarios.map( + (scenario): ColDef => ({ + colId: `scenario:${scenario.id}`, + field: `scenarioSelections.${scenario.id}` as never, + headerName: `${scenario.name} · ${money(scenario.total)}`, + valueFormatter: (params) => { + const row = params.data; + if (!row || !('requiredQuantity' in row) || typeof params.value !== 'string') return '—'; + const option = row.options.find((entry) => entry.id === params.value); + return option ? `✓ ${option.name} · ${money(option.totalPrice)}` : '? Nicht zugewiesen'; + }, + editable: (params) => + this.canEdit && + !!params.data && + 'requiredQuantity' in params.data && + this.selectableOptions(params.data).length > 0, + cellEditor: 'agSelectCellEditor', + cellEditorParams: (params: { data?: FurnitureRow }) => ({ + values: + params.data && 'requiredQuantity' in params.data + ? ['', ...this.selectableOptions(params.data).map((option) => option.id)] + : [''], + }), + cellStyle: (params) => + params.value + ? { + color: 'var(--color-success)', + backgroundColor: 'var(--color-success-subtle)', + fontWeight: '650', + } + : { + color: 'var(--color-warning)', + backgroundColor: 'var(--color-warning-subtle)', + }, + minWidth: 245, + }), + ); + return [ + { + field: 'roomId', + headerName: 'Raum', + valueFormatter: (p) => this.rooms.find((r) => r.id === p.value)?.name ?? '–', + }, + { field: 'category', headerName: 'Kategorie' }, + { field: 'name', headerName: 'Möbelbedarf', minWidth: 180 }, + ...scenarioColumns, + ]; + } + private requirementBody(row: FurnitureRequirement) { + return { + roomId: row.roomId, + name: row.name, + description: row.description ?? '', + category: row.category, + priority: row.priority, + requiredQuantity: Number(row.requiredQuantity), + status: row.status, + ...(row.responsibleUserId ? { responsibleUserId: row.responsibleUserId } : {}), + ...(row.maximumBudget !== null ? { maximumBudget: Number(row.maximumBudget) } : {}), + sortOrder: row.sortOrder, + }; + } + private withScenarioSelections(items: FurnitureRow[]): FurnitureRow[] { + return items.map((item) => { + if (!('requiredQuantity' in item)) return item; + return { + ...item, + scenarioSelections: Object.fromEntries( + this.scenarios.map((scenario) => [ + scenario.id, + scenario.selections.find((selection) => selection.requirementId === item.id) + ?.optionId ?? '', + ]), + ), + }; + }); + } + private scenarioId(columnId: string) { + return columnId.startsWith('scenario:') ? columnId.slice('scenario:'.length) : null; + } + private selectableOptions(requirement: FurnitureRequirement) { + return requirement.options.filter( + (option) => + !['archived', 'unavailable', 'rejected', 'returned'].includes(option.status) && + !['unavailable', 'discontinued'].includes(option.availability), + ); + } + private saveScenarioSelection( + event: CellValueChangedEvent, + scenarioId: string, + requirement: FurnitureRequirement, + ) { + const scenario = this.scenarios.find((entry) => entry.id === scenarioId); + if (!scenario) { + this.rollbackCell(event); + return; + } + const optionId = typeof event.newValue === 'string' ? event.newValue : ''; + const option = optionId + ? this.selectableOptions(requirement).find((entry) => entry.id === optionId) + : undefined; + if (optionId && !option) { + this.rollbackCell(event); + this.error.set('Diese Alternative kann dem Szenario nicht zugewiesen werden.'); + return; + } + const selections = scenario.selections + .filter((selection) => selection.requirementId !== requirement.id) + .map((selection) => ({ ...selection })); + if (option) + selections.push({ + requirementId: requirement.id, + optionId: option.id, + quantity: option.quantity, + }); + this.savingRow.set(requirement.id); + this.error.set(null); + this.api.updateFurnitureScenarioSelections(this.projectId, scenario, selections).subscribe({ + next: (saved) => { + this.scenarios = this.scenarios.map((entry) => (entry.id === saved.id ? saved : entry)); + this.savingRow.set(null); + this.refreshColumns(); + this.dataChanged.emit(); + }, + error: (error: unknown) => { + this.rollbackCell(event); + this.savingRow.set(null); + this.fail(error); + if (this.status(error) === 409) this.load(this.page()); + }, + }); + } + private rollbackCell(event: CellValueChangedEvent) { + this.rollback = true; + event.node.setDataValue(event.column.getColId(), event.oldValue); + this.rollback = false; + } + private optionBody(row: FurnitureOption) { + return { + name: row.name, + manufacturer: row.manufacturer ?? '', + model: row.model ?? '', + description: row.description ?? '', + retailer: row.retailer ?? '', + ...(row.productUrl ? { productUrl: row.productUrl } : {}), + articleNumber: row.articleNumber ?? '', + unitPrice: Number(row.unitPrice), + shippingCost: Number(row.shippingCost), + additionalCost: Number(row.additionalCost), + discount: Number(row.discount), + currency: row.currency, + quantity: Number(row.quantity), + ...(row.width !== null ? { width: Number(row.width) } : {}), + ...(row.height !== null ? { height: Number(row.height) } : {}), + ...(row.depth !== null ? { depth: Number(row.depth) } : {}), + color: row.color ?? '', + material: row.material ?? '', + ...(row.deliveryDays !== null ? { deliveryDays: row.deliveryDays } : {}), + ...(row.expectedDeliveryDate ? { expectedDeliveryDate: row.expectedDeliveryDate } : {}), + availability: row.availability, + favorite: row.favorite, + status: row.status, + notes: row.notes ?? '', + existingItem: row.existingItem, + movingCost: Number(row.movingCost), + refurbishmentCost: Number(row.refurbishmentCost), + }; + } + private stateKey() { + return `hauspilot:grid:furniture:${this.auth.user()?.id ?? 'anonymous'}:${this.projectId}:${this.view()}`; + } + private optionLabel(value: unknown, kind: 'price' | 'favorite' | 'selected') { + if (!this.isOption(value)) + return kind === 'price' ? '⚠ Preis offen' : kind === 'selected' ? '⚠ offen' : '–'; + if (kind === 'price') return `${value.name} · ${money(value.totalPrice)}`; + return `${kind === 'favorite' ? '★' : '✓'} ${value.name}`; + } + private present(value: unknown, presentations: Record) { + if (typeof value !== 'string' || !value) return '—'; + const presentation = presentations[value]; + return presentation ? `${presentation.icon} ${presentation.label}` : value; + } + private presentationStyle( + value: unknown, + presentations: Record, + ): CellStyle { + const tone = typeof value === 'string' ? presentations[value]?.tone : undefined; + const colors: Record = { + neutral: { + color: 'var(--color-text-muted)', + backgroundColor: 'var(--color-neutral-subtle)', + }, + info: { color: 'var(--color-info)', backgroundColor: 'var(--color-info-subtle)' }, + success: { + color: 'var(--color-success)', + backgroundColor: 'var(--color-success-subtle)', + }, + warning: { + color: 'var(--color-warning)', + backgroundColor: 'var(--color-warning-subtle)', + }, + danger: { color: 'var(--color-danger)', backgroundColor: 'var(--color-danger-subtle)' }, + }; + return tone ? { ...colors[tone], fontWeight: '650' } : { color: 'var(--color-text-muted)' }; + } + private isOption(value: unknown): value is FurnitureOption { + return typeof value === 'object' && value !== null && 'name' in value && 'totalPrice' in value; + } + private formatDate(value: unknown) { + return typeof value === 'string' || typeof value === 'number' || value instanceof Date + ? new Date(value).toLocaleDateString('de-DE') + : '–'; + } + private persistState() { + if (this.gridApi) + localStorage.setItem(this.stateKey(), JSON.stringify(this.gridApi.getColumnState())); + } + private restoreState() { + const saved = localStorage.getItem(this.stateKey()); + if (saved && this.gridApi) { + try { + this.gridApi.applyColumnState({ state: JSON.parse(saved) as never[], applyOrder: true }); + } catch { + localStorage.removeItem(this.stateKey()); + } + } + } + private status(error: unknown) { + return typeof error === 'object' && + error !== null && + 'status' in error && + typeof error.status === 'number' + ? error.status + : 0; + } + private fail(error: unknown) { + this.loading.set(false); + const fallback = + typeof error === 'object' && + error !== null && + 'error' in error && + typeof error.error === 'object' && + error.error !== null && + 'message' in error.error && + typeof error.error.message === 'string' + ? error.error.message + : 'Die Möbelansicht konnte nicht aktualisiert werden.'; + this.error.set(conflictMessage(this.status(error), fallback)); + } +} diff --git a/apps/frontend/src/app/features/projects/furniture-planning.component.spec.ts b/apps/frontend/src/app/features/projects/furniture-planning.component.spec.ts new file mode 100644 index 0000000..1b0b823 --- /dev/null +++ b/apps/frontend/src/app/features/projects/furniture-planning.component.spec.ts @@ -0,0 +1,80 @@ +import { provideHttpClient } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; +import { vi } from 'vitest'; +import { FurniturePlanningComponent } from './furniture-planning.component'; + +describe('FurniturePlanningComponent', () => { + it('calculates option totals including quantity, shipping, extras and discounts', async () => { + await TestBed.configureTestingModule({ + imports: [FurniturePlanningComponent], + providers: [provideHttpClient()], + }).compileComponents(); + const fixture = TestBed.createComponent(FurniturePlanningComponent); + const component = fixture.componentInstance; + component.optionForm.patchValue({ + unitPrice: 100, + quantity: 2, + shippingCost: 25, + additionalCost: 10, + discount: 5, + movingCost: 0, + refurbishmentCost: 0, + }); + expect(component.calculatedTotal()).toBe(230); + }); + + it('does not include acquisition price for existing furniture', async () => { + await TestBed.configureTestingModule({ + imports: [FurniturePlanningComponent], + providers: [provideHttpClient()], + }).compileComponents(); + const component = TestBed.createComponent(FurniturePlanningComponent).componentInstance; + component.optionForm.patchValue({ + existingItem: true, + unitPrice: 900, + quantity: 1, + shippingCost: 0, + additionalCost: 0, + discount: 0, + movingCost: 80, + refurbishmentCost: 35, + }); + expect(component.calculatedTotal()).toBe(115); + }); + + it('opens furniture details in a modal dialog', async () => { + await TestBed.configureTestingModule({ + imports: [FurniturePlanningComponent], + providers: [provideHttpClient()], + }).compileComponents(); + const fixture = TestBed.createComponent(FurniturePlanningComponent); + fixture.detectChanges(); + const root: unknown = fixture.nativeElement; + if (!(root instanceof HTMLElement)) throw new Error('Test-Hostelement fehlt.'); + const dialog = root.querySelector('dialog'); + if (!(dialog instanceof HTMLDialogElement)) throw new Error('Möbeldialog fehlt.'); + const showModal = vi.fn(); + Object.defineProperty(dialog, 'showModal', { value: showModal }); + fixture.componentInstance.editRequirement({ + id: 'requirement-1', + version: 1, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + projectId: 'project-1', + roomId: 'room-1', + name: 'Sofa', + description: null, + category: 'seating', + priority: 'normal', + requiredQuantity: 1, + status: 'identified', + responsibleUserId: null, + maximumBudget: null, + sortOrder: 0, + options: [], + }); + await Promise.resolve(); + expect(showModal).toHaveBeenCalledOnce(); + expect(fixture.componentInstance.showRequirementForm()).toBe(true); + }); +}); diff --git a/apps/frontend/src/app/features/projects/furniture-planning.component.ts b/apps/frontend/src/app/features/projects/furniture-planning.component.ts new file mode 100644 index 0000000..fbf8984 --- /dev/null +++ b/apps/frontend/src/app/features/projects/furniture-planning.component.ts @@ -0,0 +1,1161 @@ +import { CurrencyPipe, DatePipe } from '@angular/common'; +import { Component, Input, ViewChild, computed, inject, signal } from '@angular/core'; +import type { ElementRef, OnChanges } from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { forkJoin } from 'rxjs'; +import type { + FurnitureOption, + FurnitureRequirement, + FurnitureScenario, + FurnitureSummary, + Room, +} from './hauspilot-api.service'; +import { HauspilotApiService } from './hauspilot-api.service'; +import { conflictMessage } from './project-workspace.helpers'; +import { FurnitureGridComponent } from './furniture-grid.component'; + +const categoryLabels: Record = { + seating: 'Sitzmöbel', + tables: 'Tische', + chairs: 'Stühle', + beds: 'Betten', + cabinets: 'Schränke', + shelves: 'Regale', + office: 'Büro', + lighting: 'Beleuchtung', + textiles: 'Textilien', + decoration: 'Dekoration', + appliances: 'Elektrogeräte', + kitchen: 'Küche', + bathroom: 'Badezimmer', + garden: 'Garten', + other: 'Sonstiges', +}; +const statusLabels: Record = { + identified: 'Bedarf erkannt', + research: 'Recherche', + has_options: 'Alternativen vorhanden', + decision_open: 'Entscheidung offen', + selected: 'Ausgewählt', + ordered: 'Bestellt', + partially_delivered: 'Teilweise geliefert', + delivered: 'Geliefert', + assembled: 'Aufgebaut', + omitted: 'Entfällt', +}; + +@Component({ + selector: 'app-furniture-planning', + standalone: true, + imports: [CurrencyPipe, DatePipe, ReactiveFormsModule, FurnitureGridComponent], + template: ` +
+
+

Möbel & Einrichtung

+

Bedarfe, Produktalternativen und Einrichtungsszenarien gemeinsam planen.

+
+ @if (canEdit) { + + } +
+ @if (error()) { + + } + @if (loading()) { +

Möbelplanung wird geladen …

+ } + @if (summary(); as data) { +
+
+ Ausgewählte Möbel{{ data.selectedCost | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' }}{{ data.selected }} ausgewählt +
+
+ Günstigste Variante{{ data.cheapestCost | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' }}{{ data.openPrices }} Preise noch offen +
+
+ Tatsächliche Ausgaben{{ + data.actualExpenseCost | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' + }}Verknüpfte Ausgaben, nicht doppelt gezählt +
+
+ Entscheidungen{{ data.withoutDecision }}{{ data.withoutOption }} ohne Alternative · {{ data.delayed }} verspätet +
+
+ } + +
+ + + + + + +
+ + + @if (showRequirementForm()) { +
+

+ {{ editingRequirement() ? 'Möbelbedarf bearbeiten' : 'Möbelbedarf hinzufügen' }} +

+ + + + + + + + + +
+ +
+
+ } +
+ +
+ +
+ +
+ Kompakte Kartenansicht +
+ @for (requirement of requirements(); track requirement.id) { +
+
+
+

{{ requirement.name }}

+

{{ roomName(requirement.roomId) }} · {{ category(requirement.category) }}

+
+ {{ status(requirement.status) }} +
+
+ Priorität: {{ requirement.priority }}Menge: {{ requirement.requiredQuantity }}Budget: + {{ + requirement.maximumBudget || 0 | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' + }}Alternativen: {{ requirement.options.length }} +
+ @if (requirement.description) { +

{{ requirement.description }}

+ } +
+ + @if (canEdit) { + + } +
+ @if (expandedRequirementId() === requirement.id) { +
+ @if (!requirement.options.length) { +

Noch keine Alternative erfasst.

+ } + @for (option of requirement.options; track option.id) { +
+
+

{{ option.name }}

+
+ @if (option.favorite) { + ★ Favorit + } + @if (option.currentlySelected) { + ✓ Ausgewählt + } + @if (option.existingItem) { + ↺ Vorhanden + } +
+
+ {{ + option.totalPrice | currency: option.currency : 'symbol' : '1.2-2' : 'de' + }} +
+
Händler
+
{{ option.retailer || '–' }}
+
Maße B × H × T
+
+ {{ option.width || '–' }} × {{ option.height || '–' }} × + {{ option.depth || '–' }} cm +
+
Lieferzeit
+
+ {{ + option.deliveryDays === null ? 'Unbekannt' : option.deliveryDays + ' Tage' + }} +
+
Status
+
{{ option.status }}
+
+ @if (option.expectedDeliveryDate) { +

Lieferung: {{ option.expectedDeliveryDate | date: 'dd.MM.yyyy' }}

+ } +
+ @if (option.productUrl) { + Produkt öffnen + } + @if (canEdit) { + + + + @if (option.currentlySelected && !option.orderedAt) { + + } + @if (option.orderedAt && option.deliveryStatus !== 'delivered') { + + } + } +
+
+ } +
+ } +
+ } @empty { + @if (!loading()) { +
+

Noch keine Möbelbedarfe

+

Beginnen Sie mit einem benötigten Möbelstück für einen Raum.

+
+ } + } +
+ @if (totalPages() > 1) { + + } +
+ + + @if (showOptionForm()) { +
+

+ {{ editingOption() ? 'Alternative bearbeiten' : 'Alternative hinzufügen' }} +

+ + + + Gesamtpreis: + {{ + calculatedTotal() | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' + }} + + + + + @if (optionForm.controls.existingItem.value) { + + } + +
+ +
+
+ } +
+ +
+
+
+

Einrichtungsszenarien

+

Budget-, Wunsch- und Premiumvarianten vergleichen.

+
+ @if (canEdit) { +
+ + +
+ } +
+ @if (showScenarioForm()) { +
+ +
+ } +
+ @for (scenario of scenarios(); track scenario.id) { +
+

+ {{ scenario.name }} + @if (scenario.isDefault) { + Standard + } +

+ {{ scenario.total | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' }} +

+ {{ scenario.selectedRequirements }} gewählt · {{ scenario.openRequirements }} offen +

+
+ } @empty { +

Noch kein Szenario vorhanden.

+ } +
+ @if (scenarios().length > 1) { +

Szenariovergleich

+
+ @for (scenario of scenarios(); track scenario.id) { +
+

{{ scenario.name }}

+ {{ + scenario.total | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' + }} + @for (room of rooms; track room.id) { +

+ {{ room.name }}: + {{ scenario.byRoom[room.id] || 0 | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' }} +

+ } +
+ } +
+ } +
+ `, + styles: [ + ` + :host { + display: grid; + gap: var(--space-6); + } + h2, + h3, + h4, + p { + margin-block: 0; + } + .section-head, + .actions, + .facts, + .filters, + .inline-form { + display: flex; + gap: var(--space-4); + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + } + .metric-grid, + .scenario-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr)); + gap: var(--space-4); + } + .metric, + .requirement, + .scenarios { + display: grid; + gap: var(--space-4); + } + .metric strong, + .price { + font-size: var(--font-size-xl); + } + .filters { + justify-content: flex-start; + align-items: end; + } + label { + display: grid; + gap: var(--space-2); + color: var(--color-text-secondary); + } + input, + select, + textarea { + min-height: var(--input-height); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-3); + background: var(--color-surface); + color: var(--color-text-primary); + } + input:focus-visible, + select:focus-visible, + textarea:focus-visible, + button:focus-visible, + a:focus-visible { + outline: 3px solid var(--color-focus); + outline-offset: 2px; + } + .form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr)); + gap: var(--space-4); + align-items: end; + } + .form-grid h3, + .form-grid .wide, + .form-grid .actions { + grid-column: 1/-1; + } + .editor-dialog { + width: min(48rem, calc(100vw - 2 * var(--space-5))); + max-width: none; + max-height: calc(100dvh - 2 * var(--space-5)); + margin: auto; + padding: 0; + overflow: auto; + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-lg); + background: var(--color-surface-elevated); + color: var(--color-text-primary); + box-shadow: var(--shadow-md); + } + .editor-dialog--wide { + width: min(64rem, calc(100vw - 2 * var(--space-5))); + } + .editor-dialog::backdrop { + background: color-mix(in srgb, var(--color-text-primary) 48%, transparent); + } + .editor-dialog .form-grid { + border: 0; + padding: var(--space-6); + } + .check { + display: flex; + align-items: center; + } + .check input { + min-height: auto; + } + .requirement-list { + display: grid; + gap: var(--space-4); + } + .status, + .markers span { + background: var(--color-neutral-subtle); + border-radius: var(--radius-pill); + padding: var(--space-2) var(--space-3); + } + .comparison { + display: flex; + gap: var(--space-4); + overflow-x: auto; + scroll-snap-type: x mandatory; + padding-block: var(--space-2); + } + .option { + flex: 1 0 min(20rem, 85vw); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-4); + display: grid; + gap: var(--space-3); + scroll-snap-align: start; + } + .option.selected { + border-color: var(--color-primary); + background: var(--color-primary-subtle); + } + .markers { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + font-size: var(--font-size-sm); + } + dl { + display: grid; + grid-template-columns: auto 1fr; + gap: var(--space-2) var(--space-4); + margin: 0; + } + dd { + margin: 0; + text-align: right; + } + dt { + color: var(--color-text-secondary); + } + .calculated { + align-self: center; + } + .scenario-grid article { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-4); + } + .pagination { + display: flex; + justify-content: center; + gap: var(--space-4); + align-items: center; + } + .error { + color: var(--color-danger); + } + .empty { + text-align: center; + } + @media (max-width: 40rem) { + .actions .ui-button { + flex: 1 1 100%; + } + .facts { + justify-content: flex-start; + } + .editor-dialog, + .editor-dialog--wide { + width: calc(100vw - 2 * var(--space-3)); + max-height: calc(100dvh - 2 * var(--space-3)); + } + .editor-dialog .form-grid { + padding: var(--space-4); + } + } + `, + ], +}) +export class FurniturePlanningComponent implements OnChanges { + @Input({ required: true }) projectId = ''; + @Input() rooms: Room[] = []; + @Input() canEdit = false; + @ViewChild('requirementDialog') private requirementDialog?: ElementRef; + @ViewChild('optionDialog') private optionDialog?: ElementRef; + @ViewChild('furnitureGridHost') private furnitureGridHost?: ElementRef; + private readonly api = inject(HauspilotApiService); + readonly requirements = signal([]); + readonly scenarios = signal([]); + readonly summary = signal(null); + readonly loading = signal(false); + readonly saving = signal(false); + readonly error = signal(null); + readonly page = signal(1); + readonly totalPages = signal(1); + readonly expandedRequirementId = signal(null); + readonly showRequirementForm = signal(false); + readonly showOptionForm = signal(false); + readonly showScenarioForm = signal(false); + readonly editingRequirement = signal(null); + readonly editingOption = signal(null); + readonly optionRequirement = signal(null); + readonly categories = Object.entries(categoryLabels); + readonly requirementStatuses = Object.entries(statusLabels); + readonly filterForm = new FormGroup({ + search: new FormControl('', { nonNullable: true }), + roomId: new FormControl('', { nonNullable: true }), + status: new FormControl('', { nonNullable: true }), + sortBy: new FormControl('sortOrder', { nonNullable: true }), + }); + readonly requirementForm = new FormGroup({ + name: new FormControl('', { + nonNullable: true, + validators: [(control) => Validators.required(control)], + }), + roomId: new FormControl('', { + nonNullable: true, + validators: [(control) => Validators.required(control)], + }), + description: new FormControl('', { nonNullable: true }), + category: new FormControl('other', { nonNullable: true }), + priority: new FormControl('normal', { nonNullable: true }), + requiredQuantity: new FormControl(1, { + nonNullable: true, + validators: [(control) => Validators.min(1)(control)], + }), + maximumBudget: new FormControl(null, { + validators: [(control) => Validators.min(0)(control)], + }), + status: new FormControl('identified', { nonNullable: true }), + sortOrder: new FormControl(0, { nonNullable: true }), + }); + readonly optionForm = new FormGroup({ + name: new FormControl('', { + nonNullable: true, + validators: [(control) => Validators.required(control)], + }), + manufacturer: new FormControl('', { nonNullable: true }), + model: new FormControl('', { nonNullable: true }), + description: new FormControl('', { nonNullable: true }), + retailer: new FormControl('', { nonNullable: true }), + productUrl: new FormControl('', { nonNullable: true }), + articleNumber: new FormControl('', { nonNullable: true }), + unitPrice: new FormControl(0, { + nonNullable: true, + validators: [(control) => Validators.min(0)(control)], + }), + quantity: new FormControl(1, { + nonNullable: true, + validators: [(control) => Validators.min(1)(control)], + }), + shippingCost: new FormControl(0, { + nonNullable: true, + validators: [(control) => Validators.min(0)(control)], + }), + additionalCost: new FormControl(0, { + nonNullable: true, + validators: [(control) => Validators.min(0)(control)], + }), + discount: new FormControl(0, { + nonNullable: true, + validators: [(control) => Validators.min(0)(control)], + }), + width: new FormControl(null), + height: new FormControl(null), + depth: new FormControl(null), + color: new FormControl('', { nonNullable: true }), + material: new FormControl('', { nonNullable: true }), + deliveryDays: new FormControl(null), + expectedDeliveryDate: new FormControl('', { nonNullable: true }), + availability: new FormControl('unknown', { nonNullable: true }), + status: new FormControl('idea', { nonNullable: true }), + favorite: new FormControl(false, { nonNullable: true }), + existingItem: new FormControl(false, { nonNullable: true }), + movingCost: new FormControl(0, { nonNullable: true }), + refurbishmentCost: new FormControl(0, { nonNullable: true }), + currentLocation: new FormControl('', { nonNullable: true }), + notes: new FormControl('', { nonNullable: true }), + }); + readonly scenarioForm = new FormGroup({ + name: new FormControl('', { + nonNullable: true, + validators: [(control) => Validators.required(control)], + }), + automaticSelection: new FormControl('budget', { nonNullable: true }), + isDefault: new FormControl(false, { nonNullable: true }), + }); + readonly calculatedTotal = computed(() => { + const v = this.optionForm.getRawValue(); + const acquisition = v.existingItem ? 0 : v.unitPrice * v.quantity; + return Math.max( + 0, + acquisition + + v.shippingCost + + v.additionalCost + + v.movingCost + + v.refurbishmentCost - + v.discount, + ); + }); + ngOnChanges() { + if (this.projectId) this.load(1); + } + load(page = this.page()) { + this.loading.set(true); + this.error.set(null); + const q = { ...this.filterForm.getRawValue(), page, pageSize: 25, sortDirection: 'ASC' }; + forkJoin({ + requirements: this.api.furnitureRequirements(this.projectId, q), + summary: this.api.furnitureSummary(this.projectId), + scenarios: this.api.furnitureScenarios(this.projectId), + }).subscribe({ + next: ({ requirements, summary, scenarios }) => { + this.requirements.set(requirements.items); + this.page.set(requirements.page); + this.totalPages.set(requirements.totalPages); + this.summary.set(summary); + this.scenarios.set(scenarios); + this.loading.set(false); + }, + error: (e: unknown) => this.fail(e), + }); + } + resetFilters() { + this.filterForm.reset({ search: '', roomId: '', status: '', sortBy: 'sortOrder' }); + this.load(1); + } + roomName(id: string) { + return this.rooms.find((room) => room.id === id)?.name ?? 'Unbekannter Raum'; + } + category(value: string) { + return categoryLabels[value] ?? value; + } + status(value: string) { + return statusLabels[value] ?? value; + } + newRequirement() { + this.editingRequirement.set(null); + this.requirementForm.reset({ + name: '', + roomId: this.filterForm.controls.roomId.value || this.rooms[0]?.id || '', + description: '', + category: 'other', + priority: 'normal', + requiredQuantity: 1, + maximumBudget: null, + status: 'identified', + sortOrder: 0, + }); + this.showRequirementForm.set(true); + this.openDialog(this.requirementDialog); + } + editRequirement(r: FurnitureRequirement) { + this.editingRequirement.set(r); + this.requirementForm.reset({ + name: r.name, + roomId: r.roomId, + description: r.description ?? '', + category: r.category, + priority: r.priority, + requiredQuantity: r.requiredQuantity, + maximumBudget: r.maximumBudget === null ? null : Number(r.maximumBudget), + status: r.status, + sortOrder: r.sortOrder, + }); + this.showRequirementForm.set(true); + this.openDialog(this.requirementDialog); + } + closeRequirementForm() { + this.requirementDialog?.nativeElement.close(); + this.showRequirementForm.set(false); + this.editingRequirement.set(null); + } + saveRequirement() { + if (this.requirementForm.invalid) return this.requirementForm.markAllAsTouched(); + this.saving.set(true); + const existing = this.editingRequirement(); + const request = existing + ? this.api.updateFurnitureRequirement( + this.projectId, + existing, + this.requirementForm.getRawValue(), + ) + : this.api.createFurnitureRequirement(this.projectId, this.requirementForm.getRawValue()); + request.subscribe({ + next: () => { + this.saving.set(false); + this.closeRequirementForm(); + this.load(); + }, + error: (e: unknown) => this.fail(e), + }); + } + toggleOptions(r: FurnitureRequirement) { + this.expandedRequirementId.set(this.expandedRequirementId() === r.id ? null : r.id); + } + newOption(r: FurnitureRequirement) { + this.optionRequirement.set(r); + this.editingOption.set(null); + this.optionForm.reset({ + name: '', + manufacturer: '', + model: '', + description: '', + retailer: '', + productUrl: '', + articleNumber: '', + unitPrice: 0, + quantity: r.requiredQuantity, + shippingCost: 0, + additionalCost: 0, + discount: 0, + width: null, + height: null, + depth: null, + color: '', + material: '', + deliveryDays: null, + expectedDeliveryDate: '', + availability: 'unknown', + status: 'idea', + favorite: false, + existingItem: false, + movingCost: 0, + refurbishmentCost: 0, + currentLocation: '', + notes: '', + }); + this.showOptionForm.set(true); + this.openDialog(this.optionDialog); + } + editOption(r: FurnitureRequirement, o: FurnitureOption) { + this.optionRequirement.set(r); + this.editingOption.set(o); + this.optionForm.patchValue({ + ...o, + unitPrice: Number(o.unitPrice), + shippingCost: Number(o.shippingCost), + additionalCost: Number(o.additionalCost), + discount: Number(o.discount), + width: o.width === null ? null : Number(o.width), + height: o.height === null ? null : Number(o.height), + depth: o.depth === null ? null : Number(o.depth), + movingCost: Number(o.movingCost), + refurbishmentCost: Number(o.refurbishmentCost), + expectedDeliveryDate: o.expectedDeliveryDate ?? '', + manufacturer: o.manufacturer ?? '', + model: o.model ?? '', + description: o.description ?? '', + retailer: o.retailer ?? '', + productUrl: o.productUrl ?? '', + articleNumber: o.articleNumber ?? '', + color: o.color ?? '', + material: o.material ?? '', + notes: o.notes ?? '', + }); + this.showOptionForm.set(true); + this.openDialog(this.optionDialog); + } + closeOptionForm() { + this.optionDialog?.nativeElement.close(); + this.showOptionForm.set(false); + this.editingOption.set(null); + this.optionRequirement.set(null); + } + editOptionFromGrid(option: FurnitureOption) { + this.api.furnitureRequirement(this.projectId, option.requirementId).subscribe({ + next: (requirement) => this.editOption(requirement, option), + error: (error: unknown) => this.fail(error), + }); + } + saveOption() { + const requirement = this.optionRequirement(); + if (!requirement || this.optionForm.invalid) return this.optionForm.markAllAsTouched(); + this.saving.set(true); + const body = { ...this.optionForm.getRawValue(), currency: 'EUR' }; + const existing = this.editingOption(); + const request = existing + ? this.api.updateFurnitureOption(this.projectId, existing, body) + : this.api.createFurnitureOption(this.projectId, requirement.id, body); + request.subscribe({ + next: () => { + this.saving.set(false); + this.closeOptionForm(); + this.load(); + }, + error: (e: unknown) => this.fail(e), + }); + } + favorite(o: FurnitureOption) { + this.api + .favoriteFurnitureOption(this.projectId, o.id) + .subscribe({ next: () => this.load(), error: (e: unknown) => this.fail(e) }); + } + select(o: FurnitureOption) { + this.api + .selectFurnitureOption(this.projectId, o) + .subscribe({ next: () => this.load(), error: (e: unknown) => this.fail(e) }); + } + order(o: FurnitureOption) { + this.api + .orderFurnitureOption(this.projectId, o, { + deliveryStatus: 'ordered', + expectedDeliveryDate: o.expectedDeliveryDate, + }) + .subscribe({ next: () => this.load(), error: (e: unknown) => this.fail(e) }); + } + deliver(o: FurnitureOption) { + this.api + .deliverFurnitureOption(this.projectId, o, o.quantity) + .subscribe({ next: () => this.load(), error: (e: unknown) => this.fail(e) }); + } + openScenarioGrid(grid: FurnitureGridComponent) { + grid.setView('scenarios'); + queueMicrotask(() => + this.furnitureGridHost?.nativeElement.scrollIntoView({ behavior: 'smooth', block: 'start' }), + ); + } + createScenario() { + if (this.scenarioForm.invalid) return; + this.saving.set(true); + const value = this.scenarioForm.getRawValue(); + this.api + .createFurnitureScenario(this.projectId, { + name: value.name, + type: value.automaticSelection, + status: 'draft', + automaticSelection: value.automaticSelection, + isDefault: value.isDefault, + }) + .subscribe({ + next: () => { + this.saving.set(false); + this.showScenarioForm.set(false); + this.scenarioForm.reset({ name: '', automaticSelection: 'budget', isDefault: false }); + this.load(); + }, + error: (e: unknown) => this.fail(e), + }); + } + private fail(error: unknown) { + this.saving.set(false); + this.loading.set(false); + const status = + typeof error === 'object' && + error !== null && + 'status' in error && + typeof error.status === 'number' + ? error.status + : 0; + const fallback = + typeof error === 'object' && + error !== null && + 'error' in error && + typeof error.error === 'object' && + error.error !== null && + 'message' in error.error && + typeof error.error.message === 'string' + ? error.error.message + : 'Die Möbelplanung konnte nicht gespeichert werden.'; + this.error.set(conflictMessage(status, fallback)); + } + private openDialog(dialog?: ElementRef) { + queueMicrotask(() => { + if (dialog && !dialog.nativeElement.open) dialog.nativeElement.showModal(); + }); + } +} diff --git a/apps/frontend/src/app/features/projects/hauspilot-api.service.spec.ts b/apps/frontend/src/app/features/projects/hauspilot-api.service.spec.ts new file mode 100644 index 0000000..93863d7 --- /dev/null +++ b/apps/frontend/src/app/features/projects/hauspilot-api.service.spec.ts @@ -0,0 +1,47 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { HauspilotApiService } from './hauspilot-api.service'; + +describe('HauspilotApiService', () => { + it('überträgt Pagination, Whitelist-Sortierung und Aufgabenfilter an das Backend', () => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + const service = TestBed.inject(HauspilotApiService); + const http = TestBed.inject(HttpTestingController); + + service + .tasks('project-1', { + page: 2, + pageSize: 25, + sortBy: 'dueDate', + statuses: ['planned', 'blocked'], + mine: true, + }) + .subscribe((result) => expect(result.totalItems).toBe(1)); + const request = http.expectOne( + (candidate) => candidate.url === '/api/projects/project-1/tasks', + ); + expect(request.request.params.get('page')).toBe('2'); + expect(request.request.params.get('statuses')).toBe('planned,blocked'); + expect(request.request.params.get('mine')).toBe('true'); + request.flush({ items: [], page: 2, pageSize: 25, totalItems: 1, totalPages: 1 }); + http.verify(); + }); + + it('lädt Kalenderdaten ausschließlich für den angeforderten Zeitraum', () => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + const service = TestBed.inject(HauspilotApiService); + const http = TestBed.inject(HttpTestingController); + service.calendar('project-1', '2026-07-01', '2026-07-31', { roomId: 'room-1' }).subscribe(); + const request = http.expectOne((candidate) => candidate.url.endsWith('/calendar')); + expect(request.request.params.get('from')).toBe('2026-07-01'); + expect(request.request.params.get('to')).toBe('2026-07-31'); + expect(request.request.params.get('roomId')).toBe('room-1'); + request.flush([]); + http.verify(); + }); +}); diff --git a/apps/frontend/src/app/features/projects/hauspilot-api.service.ts b/apps/frontend/src/app/features/projects/hauspilot-api.service.ts new file mode 100644 index 0000000..b5d73de --- /dev/null +++ b/apps/frontend/src/app/features/projects/hauspilot-api.service.ts @@ -0,0 +1,568 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; + +export interface Versioned { + id: string; + version: number; + createdAt: string; + updatedAt: string; +} +export interface PageResult { + items: T[]; + page: number; + pageSize: number; + totalItems: number; + totalPages: number; +} +export interface Building extends Versioned { + projectId: string; + name: string; + description: string | null; + type: string; + sortOrder: number; +} +export interface Floor extends Versioned { + projectId: string; + buildingId: string; + name: string; + description: string | null; + sortOrder: number; +} +export interface Room extends Versioned { + projectId: string; + floorId: string; + name: string; + type: string; + status: string; + area: string | null; + plannedBudget: string | null; + sortOrder: number; + description: string | null; + previewDocumentId: string | null; +} +export interface RenovationTask extends Versioned { + projectId: string; + roomId: string | null; + title: string; + category: string; + status: string; + priority: string; + assigneeUserId: string | null; + dueDate: string | null; + estimatedCost: string | null; + actualCost: string | null; + blockedByDependencies: boolean; + description: string | null; + plannedStartDate: string | null; + actualCompletionDate: string | null; + estimatedEffortHours: string | null; + blockingReason: string | null; + weight: string; +} +export interface ChecklistItem { + id: string; + text: string; + completed: boolean; + sortOrder: number; + completedByUserId: string | null; + completedAt: string | null; +} +export interface TaskDependency { + id: string; + predecessorTaskId: string; + successorTaskId: string; + predecessor?: RenovationTask; + successor?: RenovationTask; +} +export interface TaskComment { + id: string; + text: string; + authorUserId: string; + authorName?: string; + mentionedUserIds?: string[]; + createdAt: string; + updatedAt: string; +} +export interface TaskDetail extends RenovationTask { + checklist: ChecklistItem[]; + dependencies: TaskDependency[]; + comments: TaskComment[]; + documents: ProjectDocument[]; + activities: ProjectActivity[]; + assigneeName: string | null; +} +export type TaskPatch = Partial< + Omit +> & { + estimatedEffortHours?: number | string | null; + estimatedCost?: number | string | null; + actualCost?: number | string | null; + weight?: number | string; +}; +export interface Milestone extends Versioned { + projectId: string; + title: string; + description: string | null; + date: string; + status: string; + type: string; + responsibleUserId: string | null; +} +export interface BudgetCategory extends Versioned { + projectId: string; + name: string; + plannedBudget: string; + sortOrder: number; + active: boolean; +} +export interface Expense extends Versioned { + projectId: string; + budgetCategoryId: string; + roomId: string | null; + taskId: string | null; + title: string; + description: string | null; + amount: string; + currency: string; + paymentStatus: string; + expenseDate: string; + dueDate: string | null; + supplier: string | null; + invoiceNumber: string | null; + documentId: string | null; + furnitureRequirementId?: string | null; + furnitureOptionId?: string | null; +} +export interface ProjectDocument extends Versioned { + title: string; + type: string; + originalFilename: string; + mimeType: string; + fileSize: number; + uploadedAt: string; + description: string | null; + roomId: string | null; + taskId: string | null; +} +export interface ProjectDashboard { + progress: number; + tasks: { + open: number; + overdue: number; + blocked: number; + critical: number; + unassigned: number; + mine: number; + }; + rooms: { total: number; done: number; renovating: number }; + budget: { planned: number; actual: number; paid: number; open: number; remaining: number }; + milestones: (Milestone & { atRisk: boolean })[]; + activeMembers: number; + furniture: FurnitureSummary; + hints: string[]; +} +export interface ProjectActivity { + id: string; + action: string; + actorName: string; + metadata: Record | null; + createdAt: string; +} +export interface CalendarEvent { + id: string; + entityId: string; + type: 'task_start' | 'task_due' | 'milestone' | 'expense_due'; + title: string; + date: string; + completed: boolean; + overdue: boolean; + roomId?: string | null; + assigneeUserId?: string | null; +} +export interface ProjectTemplate { + id: string; + name: string; + description: string; + requiresRoom: boolean; +} +export interface FurnitureOption extends Versioned { + projectId: string; + requirementId: string; + name: string; + manufacturer: string | null; + model: string | null; + description: string | null; + retailer: string | null; + productUrl: string | null; + articleNumber: string | null; + unitPrice: string; + originalPrice: string | null; + shippingCost: string; + additionalCost: string; + discount: string; + totalPrice: string; + currency: string; + quantity: number; + width: string | null; + height: string | null; + depth: string | null; + weight: string | null; + color: string | null; + material: string | null; + deliveryDays: number | null; + expectedDeliveryDate: string | null; + availability: string; + favorite: boolean; + currentlySelected: boolean; + status: string; + notes: string | null; + budgetCategoryId: string | null; + existingItem: boolean; + movingCost: string; + refurbishmentCost: string; + deliveryStatus: string; + deliveredQuantity: number; + orderNumber: string | null; + orderedAt: string | null; + actualDeliveryDate?: string | null; + requirementName?: string; + roomId?: string; + roomName?: string; +} +export interface FurnitureRequirement extends Versioned { + projectId: string; + roomId: string; + name: string; + description: string | null; + category: string; + priority: string; + requiredQuantity: number; + status: string; + responsibleUserId: string | null; + maximumBudget: string | null; + sortOrder: number; + options: FurnitureOption[]; + optionCount?: number; + cheapestOption?: FurnitureOption | null; + favoriteOption?: FurnitureOption | null; + selectedOption?: FurnitureOption | null; + selectedTotalPrice?: string | null; + budgetVariance?: string | null; + orderStatus?: string | null; + expectedDelivery?: string | null; + hasOpenDecision?: boolean; + isOverBudget?: boolean; +} +export interface FurnitureScenario extends Versioned { + projectId: string; + name: string; + description: string | null; + type: string; + status: string; + isDefault: boolean; + total: string; + selectedRequirements: number; + openRequirements: number; + byRoom: Record; + selections: Array<{ requirementId: string; optionId: string; quantity: number }>; +} +export interface FurnitureSummary { + requirements: number; + withoutOption: number; + withoutDecision: number; + selected: number; + ordered: number; + delivered: number; + delayed: number; + budget: string; + cheapestCost: string; + favoriteCost: string; + selectedCost: string; + actualExpenseCost: string; + openPrices: number; + overBudget: number; + scenarios: Array<{ id: string; name: string; total: string }>; +} + +export type ListQuery = Record; + +@Injectable({ providedIn: 'root' }) +export class HauspilotApiService { + private readonly http = inject(HttpClient); + private readonly base = '/api/projects'; + dashboard(id: string) { + return this.http.get(`${this.base}/${id}/dashboard`); + } + buildings(id: string) { + return this.http.get(`${this.base}/${id}/buildings`); + } + floors(id: string) { + return this.http.get(`${this.base}/${id}/floors`); + } + ensureDefaultFloors(id: string) { + return this.http.post<{ created: Floor[]; floors: Floor[] }>( + `${this.base}/${id}/floors/defaults`, + {}, + ); + } + private params(query: ListQuery = {}) { + let params = new HttpParams(); + for (const [key, value] of Object.entries(query)) { + if (value === '' || value === undefined) continue; + params = params.set(key, Array.isArray(value) ? value.join(',') : String(value)); + } + return params; + } + rooms(id: string, query: ListQuery = {}) { + return this.http.get>(`${this.base}/${id}/rooms`, { + params: this.params(query), + }); + } + tasks(id: string, query: ListQuery = {}) { + return this.http.get>(`${this.base}/${id}/tasks`, { + params: this.params(query), + }); + } + task(id: string, taskId: string) { + return this.http.get(`${this.base}/${id}/tasks/${taskId}`); + } + milestones(id: string, query: ListQuery = {}) { + return this.http.get>(`${this.base}/${id}/milestones`, { + params: this.params(query), + }); + } + budgets(id: string) { + return this.http.get(`${this.base}/${id}/budget-categories`); + } + expenses(id: string, query: ListQuery = {}) { + return this.http.get>(`${this.base}/${id}/expenses`, { + params: this.params(query), + }); + } + documents(id: string, query: ListQuery = {}) { + return this.http.get>(`${this.base}/${id}/documents`, { + params: this.params(query), + }); + } + activities(id: string, query: ListQuery = {}) { + return this.http.get>(`${this.base}/${id}/activities`, { + params: this.params(query), + }); + } + calendar(id: string, from: string, to: string, query: ListQuery = {}) { + return this.http.get(`${this.base}/${id}/calendar`, { + params: this.params({ ...query, from, to }), + }); + } + templates() { + return this.http.get('/api/templates'); + } + applyTemplate( + id: string, + templateId: string, + body: { roomId?: string; confirmDuplicate?: boolean } = {}, + ) { + return this.http.post<{ applied: true }>( + `${this.base}/${id}/apply-template/${templateId}`, + body, + ); + } + createBuilding(id: string, body: { name: string; type: string }) { + return this.http.post(`${this.base}/${id}/buildings`, body); + } + updateBuilding(id: string, building: Building, body: object) { + return this.http.patch(`${this.base}/${id}/buildings/${building.id}`, { + ...body, + version: building.version, + }); + } + createFloor(id: string, body: { buildingId: string; name: string }) { + return this.http.post(`${this.base}/${id}/floors`, body); + } + updateFloor(id: string, floor: Floor, body: object) { + return this.http.patch(`${this.base}/${id}/floors/${floor.id}`, { + ...body, + version: floor.version, + }); + } + createRoom(id: string, body: { floorId: string; name: string; type: string; status: string }) { + return this.http.post(`${this.base}/${id}/rooms`, body); + } + updateRoom(id: string, room: Room, body: object) { + return this.http.patch(`${this.base}/${id}/rooms/${room.id}`, { + ...body, + version: room.version, + }); + } + createTask( + id: string, + body: TaskPatch & { title: string; category: string; status: string; priority: string }, + ) { + return this.http.post(`${this.base}/${id}/tasks`, body); + } + updateTask(id: string, task: RenovationTask, patch: TaskPatch) { + return this.http.patch(`${this.base}/${id}/tasks/${task.id}`, { + ...task, + ...patch, + version: task.version, + }); + } + addChecklist(id: string, taskId: string, text: string) { + return this.http.post(`${this.base}/${id}/tasks/${taskId}/checklist`, { text }); + } + updateChecklist(id: string, taskId: string, itemId: string, body: Partial) { + return this.http.patch( + `${this.base}/${id}/tasks/${taskId}/checklist/${itemId}`, + body, + ); + } + addDependency(id: string, taskId: string, predecessorTaskId: string) { + return this.http.post(`${this.base}/${id}/tasks/${taskId}/dependencies`, { + predecessorTaskId, + }); + } + removeDependency(id: string, taskId: string, dependencyId: string) { + return this.http.delete( + `${this.base}/${id}/tasks/${taskId}/dependencies/${dependencyId}`, + ); + } + addComment(id: string, taskId: string, text: string, mentionedUserIds: string[]) { + return this.http.post(`${this.base}/${id}/tasks/${taskId}/comments`, { + text, + mentionedUserIds, + }); + } + createBudget(id: string, body: { name: string; plannedBudget: number }) { + return this.http.post(`${this.base}/${id}/budget-categories`, body); + } + updateBudget(id: string, category: BudgetCategory, body: object) { + return this.http.patch(`${this.base}/${id}/budget-categories/${category.id}`, { + ...body, + version: category.version, + }); + } + createMilestone(id: string, body: object) { + return this.http.post(`${this.base}/${id}/milestones`, body); + } + updateMilestone(id: string, milestone: Milestone, body: object) { + return this.http.patch(`${this.base}/${id}/milestones/${milestone.id}`, { + ...body, + version: milestone.version, + }); + } + createExpense(id: string, body: object) { + return this.http.post(`${this.base}/${id}/expenses`, body); + } + updateExpense(id: string, expense: Expense, body: object) { + return this.http.patch(`${this.base}/${id}/expenses/${expense.id}`, { + ...body, + version: expense.version, + }); + } + upload(id: string, data: FormData) { + return this.http.post(`${this.base}/${id}/documents`, data); + } + updateDocument(id: string, document: ProjectDocument, body: object) { + return this.http.patch(`${this.base}/${id}/documents/${document.id}`, { + ...body, + version: document.version, + }); + } + downloadUrl(id: string, documentId: string) { + return `${this.base}/${id}/documents/${documentId}/download`; + } + furnitureRequirements(id: string, query: ListQuery = {}) { + return this.http.get>( + `${this.base}/${id}/furniture-requirements`, + { params: this.params(query) }, + ); + } + furnitureRequirement(id: string, requirementId: string) { + return this.http.get( + `${this.base}/${id}/furniture-requirements/${requirementId}`, + ); + } + createFurnitureRequirement(id: string, body: object) { + return this.http.post(`${this.base}/${id}/furniture-requirements`, body); + } + updateFurnitureRequirement(id: string, requirement: FurnitureRequirement, body: object) { + return this.http.patch( + `${this.base}/${id}/furniture-requirements/${requirement.id}`, + { ...body, version: requirement.version }, + ); + } + furnitureOptions(id: string, requirementId: string) { + return this.http.get( + `${this.base}/${id}/furniture-requirements/${requirementId}/options`, + ); + } + furnitureProjectOptions(id: string, query: ListQuery = {}) { + return this.http.get>(`${this.base}/${id}/furniture-options`, { + params: this.params(query), + }); + } + createFurnitureOption(id: string, requirementId: string, body: object) { + return this.http.post( + `${this.base}/${id}/furniture-requirements/${requirementId}/options`, + body, + ); + } + updateFurnitureOption(id: string, option: FurnitureOption, body: object) { + return this.http.patch(`${this.base}/${id}/furniture-options/${option.id}`, { + ...body, + version: option.version, + }); + } + favoriteFurnitureOption(id: string, optionId: string) { + return this.http.post( + `${this.base}/${id}/furniture-options/${optionId}/favorite`, + {}, + ); + } + selectFurnitureOption(id: string, option: FurnitureOption) { + return this.http.post( + `${this.base}/${id}/furniture-options/${option.id}/select`, + { version: option.version }, + ); + } + orderFurnitureOption(id: string, option: FurnitureOption, body: object) { + return this.http.post( + `${this.base}/${id}/furniture-options/${option.id}/order`, + { ...body, version: option.version }, + ); + } + deliverFurnitureOption(id: string, option: FurnitureOption, deliveredQuantity: number) { + return this.http.post( + `${this.base}/${id}/furniture-options/${option.id}/deliver`, + { version: option.version, deliveredQuantity }, + ); + } + furnitureSummary(id: string, roomId?: string) { + const url = roomId + ? `${this.base}/${id}/rooms/${roomId}/furniture-summary` + : `${this.base}/${id}/furniture-summary`; + return this.http.get(url); + } + furnitureScenarios(id: string) { + return this.http.get(`${this.base}/${id}/furniture-scenarios`); + } + createFurnitureScenario(id: string, body: object) { + return this.http.post(`${this.base}/${id}/furniture-scenarios`, body); + } + updateFurnitureScenarioSelections( + id: string, + scenario: FurnitureScenario, + selections: Array<{ requirementId: string; optionId: string; quantity: number }>, + ) { + return this.http.put( + `${this.base}/${id}/furniture-scenarios/${scenario.id}/selections`, + { version: scenario.version, selections }, + ); + } + compareFurnitureScenarios(id: string, ids: readonly string[]) { + return this.http.get<{ + scenarios: FurnitureScenario[]; + rooms: Array<{ id: string; name: string; costs: Record }>; + }>(`${this.base}/${id}/furniture-scenarios/compare`, { params: this.params({ ids }) }); + } +} diff --git a/apps/frontend/src/app/features/projects/invitations.page.ts b/apps/frontend/src/app/features/projects/invitations.page.ts new file mode 100644 index 0000000..7726be5 --- /dev/null +++ b/apps/frontend/src/app/features/projects/invitations.page.ts @@ -0,0 +1,225 @@ +import { Component, inject, signal } from '@angular/core'; +import type { OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import type { ApiErrorBody, ProjectInvitationDto, ProjectRole } from '@boilerplate/api-client'; +import { ApiClientService } from '@boilerplate/api-client'; +import { UiEmptyStateComponent, UiPageHeaderComponent } from '../../shared/ui'; + +@Component({ + standalone: true, + imports: [UiEmptyStateComponent, UiPageHeaderComponent], + template: ` + + + @if (loading()) { +

Einladung wird geprueft ...

+ } @else if (error()) { + + } @else if (tokenInvitation(); as invitation) { +
+ @if (invitation.reason === 'email_mismatch') { +

Andere E-Mail-Adresse erforderlich

+

+ Diese Einladung wurde an eine andere E-Mail-Adresse gesendet. Melden Sie sich mit der + eingeladenen Adresse an oder bitten Sie den Projekteigentuemer um eine neue Einladung. +

+

Eingeladene Adresse: {{ invitation.invitedEmailMasked }}

+ Abmelden und Benutzer wechseln + } @else if (invitation.reason === 'email_unverified') { +

E-Mail-Adresse noch nicht verifiziert

+

+ Verifizieren Sie die Adresse beim bestehenden Identity Provider und melden Sie sich + erneut an. +

+ Erneut anmelden + } @else if (invitation.status !== 'pending') { +

Einladung {{ statusLabel(invitation.status) }}

+

Diese Einladung kann nicht mehr verwendet werden.

+ } @else { +

{{ invitation.projectName }}

+

+ Vorgesehene Rolle: {{ roleLabel(invitation.role) }} +

+

{{ roleDescription(invitation.role) }}

+
+ + +
+ } +
+ } @else if (pending().length === 0) { + + } @else { +
+ @for (invitation of pending(); track invitation.id) { +
+

{{ invitation.projectName }}

+

+ Rolle: {{ roleLabel(invitation.role) }} +

+ @if (invitation.canRespond) { +
+ + +
+ } @else { +

Die E-Mail-Adresse muss beim Identity Provider verifiziert sein.

+ } +
+ } +
+ } + `, + styles: [ + ` + :host, + .invitation-list, + .invitation-card, + .status-card { + display: grid; + gap: var(--space-4); + } + .invitation-card, + .status-card { + max-width: 44rem; + } + .invitation-card h2, + .invitation-card p, + .status-card h2, + .status-card p { + margin: 0; + } + .actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + } + `, + ], +}) +export class InvitationsPageComponent implements OnInit { + private readonly api = inject(ApiClientService); + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + private token: string | null = null; + readonly loading = signal(true); + readonly error = signal(null); + readonly tokenInvitation = signal(null); + readonly pending = signal([]); + + ngOnInit(): void { + this.token = this.route.snapshot.paramMap.get('token'); + if (this.token) { + this.loadToken(this.token); + } else { + this.loadPending(); + } + } + + acceptToken(): void { + if (!this.token) return; + this.api.acceptProjectInvitation(this.token).subscribe({ + next: (project) => void this.router.navigate(['/projekte', project.id]), + error: (error: { error?: ApiErrorBody }) => this.showError(error), + }); + } + + declineToken(): void { + if (!this.token) return; + this.api.declineProjectInvitation(this.token).subscribe({ + next: () => void this.router.navigate(['/einladungen']), + error: (error: { error?: ApiErrorBody }) => this.showError(error), + }); + } + + acceptPending(invitation: ProjectInvitationDto): void { + this.api.acceptPendingProjectInvitation(invitation.id).subscribe({ + next: (project) => void this.router.navigate(['/projekte', project.id]), + error: (error: { error?: ApiErrorBody }) => this.showError(error), + }); + } + + declinePending(invitation: ProjectInvitationDto): void { + this.api.declinePendingProjectInvitation(invitation.id).subscribe({ + next: () => this.pending.update((items) => items.filter((item) => item.id !== invitation.id)), + error: (error: { error?: ApiErrorBody }) => this.showError(error), + }); + } + + roleLabel(role: ProjectRole): string { + return { + owner: 'Eigentuemer', + administrator: 'Projektadministrator', + editor: 'Bearbeiter', + reader: 'Leser', + }[role]; + } + + roleDescription(role: ProjectRole): string { + return { + owner: 'Eigentuemer verwalten das Projekt und seine Mitglieder.', + administrator: 'Projektadministratoren verwalten Inhalte und Mitglieder.', + editor: 'Bearbeiter koennen Projektinhalte lesen und aendern.', + reader: 'Leser koennen Projektinhalte ansehen.', + }[role]; + } + + statusLabel(status: ProjectInvitationDto['status']): string { + return { + pending: 'offen', + accepted: 'angenommen', + declined: 'abgelehnt', + revoked: 'widerrufen', + expired: 'abgelaufen', + }[status]; + } + + private loadToken(token: string): void { + this.api.projectInvitation(token).subscribe({ + next: (invitation) => this.tokenInvitation.set(invitation), + error: (error: { error?: ApiErrorBody }) => this.showError(error), + complete: () => this.loading.set(false), + }); + } + + private loadPending(): void { + this.api.pendingProjectInvitations().subscribe({ + next: (invitations) => this.pending.set(invitations), + error: (error: { error?: ApiErrorBody }) => this.showError(error), + complete: () => this.loading.set(false), + }); + } + + private showError(error: { error?: ApiErrorBody }): void { + this.error.set(error.error?.message ?? 'Die Einladung ist ungueltig oder abgelaufen.'); + this.loading.set(false); + } +} diff --git a/apps/frontend/src/app/features/projects/project-calendar.component.ts b/apps/frontend/src/app/features/projects/project-calendar.component.ts new file mode 100644 index 0000000..db8ec36 --- /dev/null +++ b/apps/frontend/src/app/features/projects/project-calendar.component.ts @@ -0,0 +1,342 @@ +import { DatePipe } from '@angular/common'; +import { Component, computed, inject, input, signal } from '@angular/core'; +import type { OnInit } from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { RouterLink } from '@angular/router'; +import type { ProjectMemberDto } from '@boilerplate/api-client'; +import { ApiClientService } from '@boilerplate/api-client'; +import { HauspilotApiService } from './hauspilot-api.service'; +import type { CalendarEvent, Room } from './hauspilot-api.service'; + +@Component({ + selector: 'app-project-calendar', + standalone: true, + imports: [DatePipe, ReactiveFormsModule, RouterLink], + template: ` +
+
+ +
+

{{ cursor() | date: 'MMMM yyyy' }}

+
+ +
+
+
+ + + +
+ @if (loading()) { +

Kalender wird geladen …

+ } + @if (error()) { + + } + @if (view() === 'month') { + +
+ @for (day of days(); track day.key) { +
+ + @for (event of eventsFor(day.key); track event.id) { + @if (event.type === 'task_start' || event.type === 'task_due') { + {{ typeLabel(event.type) }}: {{ event.title }} + } @else { + {{ typeLabel(event.type) }}: {{ event.title }} + } + } +
+ } +
+ } @else { +
+ @for (event of filteredEvents(); track event.id) { +
+ + @if (event.type === 'task_start' || event.type === 'task_due') { + {{ + event.title + }} + } @else { + {{ event.title }} + } + {{ typeLabel(event.type) }} + @if (event.overdue) { + Überfällig + } +
+ } @empty { +

In diesem Zeitraum gibt es keine passenden Termine.

+ } +
+ } + `, + styles: [ + ` + :host { + display: grid; + gap: var(--space-4); + } + .calendar-toolbar, + .actions, + .filters, + .agenda-row { + display: flex; + align-items: center; + gap: var(--space-3); + flex-wrap: wrap; + } + .calendar-toolbar { + justify-content: space-between; + } + .calendar-toolbar h2 { + margin: 0; + text-transform: capitalize; + } + .filters label { + display: grid; + gap: var(--space-1); + min-inline-size: 11rem; + } + .weekdays, + .month-grid { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + } + .weekdays { + text-align: center; + color: var(--color-text-secondary); + } + .day { + min-block-size: 8rem; + border: 1px solid var(--color-border); + padding: var(--space-2); + background: var(--color-surface); + overflow: hidden; + } + .day.outside { + opacity: 0.55; + } + .day.today { + outline: 0.2rem solid var(--color-primary); + outline-offset: -0.2rem; + } + .day time { + display: block; + font-weight: 700; + margin-block-end: var(--space-2); + } + .event { + display: flex; + gap: var(--space-1); + padding: var(--space-1); + margin-block: var(--space-1); + border-radius: var(--radius-sm); + background: var(--color-primary-subtle); + color: var(--color-text); + font-size: var(--font-size-xs); + text-decoration: none; + overflow-wrap: anywhere; + } + .event.overdue, + .flag { + color: var(--color-danger); + font-weight: 700; + } + .event.completed { + text-decoration: line-through; + } + .agenda { + display: grid; + gap: var(--space-2); + } + .agenda-row time { + min-inline-size: 9rem; + } + .error { + color: var(--color-danger); + } + @media (max-width: 48rem) { + .weekdays { + display: none; + } + .month-grid { + grid-template-columns: 1fr; + gap: var(--space-2); + } + .day.outside { + display: none; + } + .day { + min-block-size: auto; + } + } + `, + ], +}) +export class ProjectCalendarComponent implements OnInit { + readonly projectId = input.required(); + private readonly api = inject(HauspilotApiService); + private readonly projectsApi = inject(ApiClientService); + readonly cursor = signal(new Date()); + readonly view = signal<'month' | 'agenda'>('month'); + readonly events = signal([]); + readonly rooms = signal([]); + readonly members = signal([]); + readonly loading = signal(false); + readonly error = signal(null); + readonly roomFilter = new FormControl('', { nonNullable: true }); + readonly assigneeFilter = new FormControl('', { nonNullable: true }); + readonly typeFilter = new FormControl('', { nonNullable: true }); + readonly weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; + readonly filteredEvents = computed(() => + this.events().filter( + (event) => + (!this.roomFilter.value || event.roomId === this.roomFilter.value) && + (!this.assigneeFilter.value || event.assigneeUserId === this.assigneeFilter.value) && + (!this.typeFilter.value || event.type === this.typeFilter.value), + ), + ); + readonly days = computed(() => { + const cursor = this.cursor(); + const first = new Date(cursor.getFullYear(), cursor.getMonth(), 1); + const start = new Date(first); + start.setDate(start.getDate() - ((first.getDay() + 6) % 7)); + return Array.from({ length: 42 }, (_, index) => { + const date = new Date(start); + date.setDate(start.getDate() + index); + const now = new Date(); + return { + date, + key: this.key(date), + currentMonth: date.getMonth() === cursor.getMonth(), + today: this.key(date) === this.key(now), + }; + }); + }); + ngOnInit() { + this.projectsApi + .projectMembers(this.projectId()) + .subscribe((members) => this.members.set(members.filter((member) => member.active))); + this.api + .rooms(this.projectId(), { pageSize: 100 }) + .subscribe((page) => this.rooms.set(page.items)); + this.reload(); + } + move(offset: number) { + const next = new Date(this.cursor()); + next.setMonth(next.getMonth() + offset); + this.cursor.set(next); + this.reload(); + } + today() { + this.cursor.set(new Date()); + this.reload(); + } + eventsFor(key: string) { + return this.filteredEvents().filter((event) => event.date.slice(0, 10) === key); + } + icon(type: CalendarEvent['type']) { + return ({ task_start: '▶', task_due: '✓', milestone: '◆', expense_due: '€' } as const)[type]; + } + typeLabel(type: CalendarEvent['type']) { + return ( + { + task_start: 'Start', + task_due: 'Fällig', + milestone: 'Meilenstein', + expense_due: 'Zahlung', + } as const + )[type]; + } + private reload() { + const cursor = this.cursor(); + const from = new Date(cursor.getFullYear(), cursor.getMonth() - 1, 22); + const to = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 8); + this.loading.set(true); + this.api.calendar(this.projectId(), this.key(from), this.key(to)).subscribe({ + next: (events) => this.events.set(events), + error: () => this.error.set('Die Kalenderdaten konnten nicht geladen werden.'), + complete: () => this.loading.set(false), + }); + } + private key(date: Date) { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + } +} diff --git a/apps/frontend/src/app/features/projects/project-detail.page.ts b/apps/frontend/src/app/features/projects/project-detail.page.ts new file mode 100644 index 0000000..f74fc13 --- /dev/null +++ b/apps/frontend/src/app/features/projects/project-detail.page.ts @@ -0,0 +1,262 @@ +import { Component, inject, signal } from '@angular/core'; +import type { OnInit } from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import type { + ApiErrorBody, + ProjectDto, + ProjectInvitationDto, + ProjectMemberDto, + ProjectRole, +} from '@boilerplate/api-client'; +import { ApiClientService } from '@boilerplate/api-client'; +import { UiPageHeaderComponent, UiStatusBadgeComponent } from '../../shared/ui'; + +const assignableRoles: readonly ProjectRole[] = ['administrator', 'editor', 'reader']; + +@Component({ + standalone: true, + imports: [ReactiveFormsModule, RouterLink, UiPageHeaderComponent, UiStatusBadgeComponent], + template: ` + Zurueck zu den Projekten + @if (project(); as currentProject) { + +

+ Ihre Rolle: {{ roleLabel(currentProject.role) }} +

+ + @if (canManage()) { +
+

Projektmitglied einladen

+
+ + + +
+ @if (createdInvitation(); as invitation) { +
+ Einladung gespeichert +

+ Es ist kein Mailversand konfiguriert. Geben Sie diesen internen Pfad sicher weiter: +

+ {{ invitation.invitationPath }} +
+ } +
+ } + +
+

Mitglieder

+
+ @for (member of members(); track member.userId) { +
+
+ {{ member.name }} +

{{ member.email || 'Keine E-Mail-Adresse' }}

+
+ + @if (canManage() && member.role !== 'owner') { + + + } +
+ } +
+
+ } @else if (!error()) { +

Projekt wird geladen ...

+ } + @if (error()) { + + } + `, + styles: [ + ` + :host, + .invite-card, + .members-card, + .member-list, + .invitation-result { + display: grid; + gap: var(--space-4); + } + .invite-card, + .members-card { + margin-top: var(--space-6); + } + .invite-card h2, + .members-card h2, + .member-row p, + .invitation-result p { + margin: 0; + } + .invite-form { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr)); + align-items: end; + } + .invitation-result { + padding: var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-primary-subtle); + } + code { + overflow-wrap: anywhere; + } + .member-row { + display: grid; + gap: var(--space-3); + padding: var(--space-4) 0; + border-bottom: 1px solid var(--color-border); + } + .member-row:last-child { + border-bottom: 0; + } + .member-row p { + color: var(--color-text-secondary); + } + .error { + color: var(--color-danger); + } + @media (min-width: 48rem) { + .member-row { + grid-template-columns: minmax(12rem, 1fr) auto minmax(10rem, auto) auto; + align-items: center; + } + } + `, + ], +}) +export class ProjectDetailPageComponent implements OnInit { + private readonly api = inject(ApiClientService); + private readonly route = inject(ActivatedRoute); + private projectId = ''; + readonly project = signal(null); + readonly members = signal([]); + readonly saving = signal(false); + readonly error = signal(null); + readonly createdInvitation = signal(null); + readonly roles = assignableRoles; + readonly inviteForm = new FormGroup({ + email: new FormControl('', { + nonNullable: true, + validators: [ + (control) => Validators.required(control), + (control) => Validators.email(control), + ], + }), + role: new FormControl('reader', { nonNullable: true }), + }); + + ngOnInit(): void { + this.projectId = this.route.snapshot.paramMap.get('id') ?? ''; + this.load(); + } + + canManage(): boolean { + return this.project()?.role === 'owner' || this.project()?.role === 'administrator'; + } + + invite(): void { + if (this.inviteForm.invalid || this.saving()) return; + this.saving.set(true); + this.error.set(null); + this.createdInvitation.set(null); + this.api.createProjectInvitation(this.projectId, this.inviteForm.getRawValue()).subscribe({ + next: (invitation) => { + this.createdInvitation.set(invitation); + this.inviteForm.reset(); + }, + error: (error: { error?: ApiErrorBody }) => { + this.error.set(error.error?.message ?? 'Die Einladung konnte nicht erstellt werden.'); + this.saving.set(false); + }, + complete: () => this.saving.set(false), + }); + } + + changeRole(member: ProjectMemberDto, event: Event): void { + const target = event.target; + if ( + !(target instanceof HTMLSelectElement) || + !assignableRoles.includes(target.value as ProjectRole) + ) + return; + this.api + .updateProjectMember(this.projectId, member.userId, target.value as ProjectRole) + .subscribe({ + next: (updated) => + this.members.update((members) => + members.map((entry) => (entry.userId === updated.userId ? updated : entry)), + ), + error: (error: { error?: ApiErrorBody }) => + this.error.set(error.error?.message ?? 'Die Rolle konnte nicht geaendert werden.'), + }); + } + + remove(member: ProjectMemberDto): void { + this.api.removeProjectMember(this.projectId, member.userId).subscribe({ + next: () => + this.members.update((members) => members.filter((entry) => entry.userId !== member.userId)), + error: (error: { error?: ApiErrorBody }) => + this.error.set(error.error?.message ?? 'Das Mitglied konnte nicht entfernt werden.'), + }); + } + + roleLabel(role: ProjectRole): string { + return { + owner: 'Eigentuemer', + administrator: 'Projektadministrator', + editor: 'Bearbeiter', + reader: 'Leser', + }[role]; + } + + private load(): void { + this.api.project(this.projectId).subscribe({ + next: (project) => this.project.set(project), + error: (error: { error?: ApiErrorBody }) => + this.error.set(error.error?.message ?? 'Das Projekt konnte nicht geladen werden.'), + }); + this.api.projectMembers(this.projectId).subscribe({ + next: (members) => this.members.set(members), + error: (error: { error?: ApiErrorBody }) => + this.error.set(error.error?.message ?? 'Mitglieder konnten nicht geladen werden.'), + }); + } +} diff --git a/apps/frontend/src/app/features/projects/project-workspace.helpers.spec.ts b/apps/frontend/src/app/features/projects/project-workspace.helpers.spec.ts new file mode 100644 index 0000000..69ff8da --- /dev/null +++ b/apps/frontend/src/app/features/projects/project-workspace.helpers.spec.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { conflictMessage, filterTasks } from './project-workspace.helpers'; + +describe('project workspace behavior', () => { + it('shows an actionable optimistic concurrency message', () => { + expect(conflictMessage(409, 'Fehler')).toContain('zwischenzeitlich'); + }); + + it('combines status and own-task filters', () => { + const tasks = [ + { status: 'planned', assigneeUserId: 'me' }, + { status: 'done', assigneeUserId: 'me' }, + { status: 'planned', assigneeUserId: 'other' }, + ]; + expect(filterTasks(tasks, 'planned', 'me', true)).toEqual([tasks[0]]); + }); +}); diff --git a/apps/frontend/src/app/features/projects/project-workspace.helpers.ts b/apps/frontend/src/app/features/projects/project-workspace.helpers.ts new file mode 100644 index 0000000..63332af --- /dev/null +++ b/apps/frontend/src/app/features/projects/project-workspace.helpers.ts @@ -0,0 +1,18 @@ +export function conflictMessage(status: number, fallback: string): string { + return status === 409 + ? 'Dieser Datensatz wurde zwischenzeitlich von einer anderen Person geändert. Lade die aktuellen Daten und übernimm deine Änderungen erneut.' + : fallback; +} + +export function filterTasks( + tasks: readonly T[], + status: string, + currentUserId: string | null, + mine: boolean, +): T[] { + return tasks.filter( + (task) => + (!status || task.status === status) && + (!mine || (!!currentUserId && task.assigneeUserId === currentUserId)), + ); +} diff --git a/apps/frontend/src/app/features/projects/project-workspace.page.ts b/apps/frontend/src/app/features/projects/project-workspace.page.ts new file mode 100644 index 0000000..dac6ed8 --- /dev/null +++ b/apps/frontend/src/app/features/projects/project-workspace.page.ts @@ -0,0 +1,1283 @@ +import { CurrencyPipe, DatePipe, DecimalPipe } from '@angular/common'; +import { Component, computed, inject, signal } from '@angular/core'; +import type { OnInit } from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import type { Observable } from 'rxjs'; +import type { ApiErrorBody, ProjectDto, ProjectMemberDto } from '@boilerplate/api-client'; +import { ApiClientService } from '@boilerplate/api-client'; +import { + UiEmptyStateComponent, + UiPageHeaderComponent, + UiPaginationComponent, + UiStatusBadgeComponent, +} from '../../shared/ui'; +import { AuthService } from '../../core/auth.service'; +import { HauspilotApiService } from './hauspilot-api.service'; +import { ProjectCalendarComponent } from './project-calendar.component'; +import { FurniturePlanningComponent } from './furniture-planning.component'; +import { conflictMessage, filterTasks } from './project-workspace.helpers'; +import type { + BudgetCategory, + Building, + Expense, + Floor, + Milestone, + ProjectActivity, + ProjectDashboard, + ProjectDocument, + RenovationTask, + Room, +} from './hauspilot-api.service'; + +const sections = [ + ['uebersicht', 'Übersicht'], + ['raeume', 'Räume'], + ['aufgaben', 'Aufgaben'], + ['zeitplan', 'Zeitplan'], + ['budget', 'Budget'], + ['moebel', 'Möbel & Einrichtung'], + ['dokumente', 'Dokumente'], + ['aktivitaeten', 'Aktivitäten'], +] as const; + +@Component({ + standalone: true, + imports: [ + CurrencyPipe, + DatePipe, + DecimalPipe, + ReactiveFormsModule, + RouterLink, + UiEmptyStateComponent, + UiPageHeaderComponent, + UiStatusBadgeComponent, + ProjectCalendarComponent, + UiPaginationComponent, + FurniturePlanningComponent, + ], + template: ` + Zurück zu den Projekten + @if (project(); as project) { + + + @if (loading()) { +

Projektdaten werden geladen …

+ } + @if (error()) { + + } + + @switch (section()) { + @case ('uebersicht') { + @if (dashboard(); as data) { +
+
+ Fortschritt{{ data.progress }} %{{ data.progress }} % +
+
+ Offene Aufgaben{{ data.tasks.open }}{{ data.tasks.overdue }} überfällig · {{ data.tasks.blocked }} blockiert +
+
+ Räume{{ data.rooms.done }} / {{ data.rooms.total }}{{ data.rooms.renovating }} in Renovierung +
+
+ Verbleibendes Budget{{ + data.budget.remaining | currency: 'EUR' : 'symbol' : '1.0-0' : 'de' + }}{{ + data.budget.actual | currency: 'EUR' : 'symbol' : '1.0-0' : 'de' + }} + erfasst +
+
+ Ausgewählte Möbel + {{ + data.furniture.selectedCost | currency: 'EUR' : 'symbol' : '1.0-0' : 'de' + }} + {{ data.furniture.withoutDecision }} Entscheidungen offen · + {{ data.furniture.delayed }} Lieferungen verspätet +
+
+ @if (data.hints.length) { +
+

Das braucht Aufmerksamkeit

+
    + @for (hint of data.hints; track hint) { +
  • {{ hint }}
  • + } +
+
+ } +
+

Nächste Termine

+ @for (milestone of data.milestones; track milestone.id) { +
+ {{ milestone.title }}{{ milestone.date | date: 'dd.MM.yyyy' }} + @if (milestone.atRisk) { + + } +
+ } @empty { +

Noch keine Meilensteine angelegt.

+ } +
+ } + } + @case ('raeume') { +
+

Etagen und Räume

+ @if (canEdit()) { + + + } +
+ @if (showRoomForm()) { +
+ +
+ } + @for (floor of floors(); track floor.id) { +
+

{{ floor.name }}

+
+ @for (room of roomsForFloor(floor.id); track room.id) { +
+
+ {{ room.name }} +
+ {{ taskCount(room.id) }} Aufgaben · + {{ + room.plannedBudget + ? (room.plannedBudget | currency: 'EUR') + : 'Kein Raumbudget' + }} + @if (canEdit()) { + + } +
+ } @empty { +

Noch keine Räume auf dieser Etage.

+ } +
+
+ } @empty { + + } + } + @case ('aufgaben') { +
+

Aufgaben

+ @if (canEdit()) { + + } +
+
+ + + +
+ + @if (showTaskForm()) { +
+ +
+ } +
+ @for (task of filteredTasks(); track task.id) { +
+
+ {{ task.title }} +

+ {{ roomName(task.roomId) }} · fällig + {{ task.dueDate ? (task.dueDate | date: 'dd.MM.yyyy') : 'ohne Termin' }} +

+
+ + @if (task.blockedByDependencies) { + Kann erst nach offenen Vorgängern starten. + } + @if (canEdit() && task.status !== 'done') { + + } +
+ } @empty { + + } +
+ } + @case ('zeitplan') { +

Zeitplan

+ @if (canEdit()) { +
+ Meilenstein anlegen oder bearbeiten +
+ + + + + + + +
+
+ } +
+ @for (m of milestones(); track m.id) { +
+ {{ m.title }} + @if (canEdit()) { + + } +
+ } @empty { + + } +
+ + } + @case ('budget') { + @if (dashboard(); as data) { +
+
+ Geplant{{ data.budget.planned | currency: 'EUR' }} +
+
+ Tatsächlich{{ data.budget.actual | currency: 'EUR' }} +
+
+ Offen{{ data.budget.open | currency: 'EUR' }} +
+
+ Bezahlt{{ data.budget.paid | currency: 'EUR' }} +
+
+ } +
+

Budgetkategorien

+ @if (canManage()) { +
+ + + +
+ } + @for (category of budgets(); track category.id) { +
+ {{ category.name }}{{ category.plannedBudget | currency: 'EUR' }} + @if (canManage()) { + + } +
+ } @empty { +

Noch keine Kategorien.

+ } +
+
+

Ausgaben

+ @for (expense of expenses(); track expense.id) { +
+ {{ expense.title }}{{ expense.supplier }}{{ + expense.amount | currency: 'EUR' + }} +
+ } @empty { +

Noch keine Ausgaben erfasst.

+ } +
+ } + @case ('moebel') { + + } + @case ('dokumente') { +
+

Dokumente und Fotos

+ @if (canEdit()) { + + } +
+
+ @for (document of documents(); track document.id) { +
+ {{ document.title }} +

+ {{ document.originalFilename }} · + {{ document.fileSize / 1024 | number: '1.0-0' }} KB +

+ Herunterladen +
+ } @empty { + + } +
+ } + @case ('aktivitaeten') { +

Letzte Änderungen

+
+ @for (activity of activities(); track activity.id) { +
+ {{ activity.actorName }}{{ activityLabel(activity.action) }} +
+ } @empty { + + } +
+ } + } + } + `, + styles: [ + ` + :host { + display: grid; + gap: var(--space-5); + } + .project-nav { + display: flex; + gap: var(--space-2); + overflow-x: auto; + padding-block: var(--space-2); + border-bottom: 1px solid var(--color-border); + } + .project-nav a { + white-space: nowrap; + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-md); + color: var(--color-text-secondary); + text-decoration: none; + } + .project-nav a.active { + background: var(--color-primary-subtle); + color: var(--color-primary); + font-weight: 600; + } + .metric-grid, + .card-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr)); + gap: var(--space-4); + } + .metric { + display: grid; + gap: var(--space-2); + } + .metric strong { + font-size: var(--font-size-2xl); + } + progress { + inline-size: 100%; + accent-color: var(--color-primary); + } + .section-head, + .list-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + flex-wrap: wrap; + } + .floor, + .task-list, + .timeline { + display: grid; + gap: var(--space-3); + } + .room-card, + .task { + display: grid; + gap: var(--space-3); + } + .task p, + .ui-card p { + margin: 0; + color: var(--color-text-secondary); + } + .form-grid { + display: grid; + gap: var(--space-4); + grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr)); + align-items: end; + } + .filters { + display: flex; + gap: var(--space-4); + align-items: end; + flex-wrap: wrap; + } + .file-button input { + position: absolute; + inline-size: 1px; + block-size: 1px; + clip-path: inset(50%); + } + .error { + color: var(--color-danger); + } + @media (max-width: 40rem) { + .task .ui-button { + inline-size: 100%; + } + } + `, + ], +}) +export class ProjectWorkspacePageComponent implements OnInit { + readonly api = inject(HauspilotApiService); + private readonly projectsApi = inject(ApiClientService); + private readonly auth = inject(AuthService); + private readonly route = inject(ActivatedRoute); + projectId = ''; + readonly navigation = sections; + readonly section = signal('uebersicht'); + readonly project = signal(null); + readonly dashboard = signal(null); + readonly buildings = signal([]); + readonly floors = signal([]); + readonly rooms = signal([]); + readonly tasks = signal([]); + readonly milestones = signal([]); + readonly budgets = signal([]); + readonly expenses = signal([]); + readonly documents = signal([]); + readonly activities = signal([]); + readonly members = signal([]); + readonly loading = signal(true); + readonly saving = signal(false); + readonly error = signal(null); + readonly showRoomForm = signal(false); + readonly editingRoom = signal(null); + readonly showTaskForm = signal(false); + readonly statusFilter = new FormControl('', { nonNullable: true }); + readonly mineFilter = new FormControl(false, { nonNullable: true }); + readonly taskSearch = new FormControl('', { nonNullable: true }); + readonly taskSort = new FormControl('dueDate', { nonNullable: true }); + readonly taskPage = signal(1); + readonly taskTotal = signal(0); + readonly roomForm = new FormGroup({ + floorId: new FormControl('', { + nonNullable: true, + validators: [(control) => Validators.required(control)], + }), + name: new FormControl('', { + nonNullable: true, + validators: [(control) => Validators.required(control)], + }), + type: new FormControl('other', { nonNullable: true }), + status: new FormControl('unplanned', { nonNullable: true }), + description: new FormControl('', { nonNullable: true }), + area: new FormControl(null), + plannedBudget: new FormControl(null), + sortOrder: new FormControl(0, { nonNullable: true }), + }); + readonly taskForm = new FormGroup({ + title: new FormControl('', { + nonNullable: true, + validators: [(control) => Validators.required(control)], + }), + roomId: new FormControl('', { nonNullable: true }), + dueDate: new FormControl('', { nonNullable: true }), + description: new FormControl('', { nonNullable: true }), + status: new FormControl('planned', { nonNullable: true }), + priority: new FormControl('normal', { nonNullable: true }), + category: new FormControl('general', { nonNullable: true }), + assigneeUserId: new FormControl('', { nonNullable: true }), + plannedStartDate: new FormControl('', { nonNullable: true }), + estimatedEffortHours: new FormControl(null), + estimatedCost: new FormControl(null), + actualCost: new FormControl(null), + weight: new FormControl(1, { nonNullable: true }), + blockingReason: new FormControl('', { nonNullable: true }), + }); + readonly editingMilestone = signal(null); + readonly milestoneForm = new FormGroup({ + title: new FormControl('', { + nonNullable: true, + validators: [(control) => Validators.required(control)], + }), + description: new FormControl('', { nonNullable: true }), + date: new FormControl('', { + nonNullable: true, + validators: [(control) => Validators.required(control)], + }), + status: new FormControl('planned', { nonNullable: true }), + type: new FormControl('other', { nonNullable: true }), + responsibleUserId: new FormControl('', { nonNullable: true }), + }); + readonly editingBudget = signal(null); + readonly budgetForm = new FormGroup({ + name: new FormControl('', { + nonNullable: true, + validators: [(control) => Validators.required(control)], + }), + plannedBudget: new FormControl(null, { + validators: [ + (control) => Validators.required(control), + (control) => Validators.min(0)(control), + ], + }), + sortOrder: new FormControl(0, { nonNullable: true }), + }); + readonly editingExpense = signal(null); + readonly expenseForm = new FormGroup({ + title: new FormControl('', { + nonNullable: true, + validators: [(control) => Validators.required(control)], + }), + description: new FormControl('', { nonNullable: true }), + amount: new FormControl(null, { + validators: [ + (control) => Validators.required(control), + (control) => Validators.min(0)(control), + ], + }), + currency: new FormControl('EUR', { nonNullable: true }), + budgetCategoryId: new FormControl('', { + nonNullable: true, + validators: [(control) => Validators.required(control)], + }), + roomId: new FormControl('', { nonNullable: true }), + taskId: new FormControl('', { nonNullable: true }), + expenseDate: new FormControl(new Date().toISOString().slice(0, 10), { + nonNullable: true, + validators: [(control) => Validators.required(control)], + }), + paymentStatus: new FormControl('planned', { nonNullable: true }), + dueDate: new FormControl('', { nonNullable: true }), + supplier: new FormControl('', { nonNullable: true }), + invoiceNumber: new FormControl('', { nonNullable: true }), + documentId: new FormControl('', { nonNullable: true }), + }); + readonly assignableMembers = computed(() => + this.members().filter((member) => member.active && member.role !== 'reader'), + ); + readonly filteredTasks = computed(() => + filterTasks( + this.tasks(), + this.statusFilter.value, + this.auth.user()?.id ?? null, + this.mineFilter.value, + ), + ); + ngOnInit() { + this.projectId = this.route.snapshot.paramMap.get('id') ?? ''; + this.route.paramMap.subscribe((params) => + this.section.set(params.get('section') ?? 'uebersicht'), + ); + this.statusFilter.valueChanges.subscribe(() => this.loadTaskPage(1)); + this.mineFilter.valueChanges.subscribe(() => this.loadTaskPage(1)); + this.taskSearch.valueChanges.subscribe(() => this.loadTaskPage(1)); + this.taskSort.valueChanges.subscribe(() => this.loadTaskPage(1)); + this.load(); + } + canEdit() { + return this.project()?.role !== 'reader'; + } + canManage() { + return ['owner', 'administrator'].includes(this.project()?.role ?? ''); + } + roomsForFloor(id: string) { + return this.rooms().filter((room) => room.floorId === id); + } + taskCount(id: string) { + return this.tasks().filter( + (task) => task.roomId === id && !['done', 'omitted'].includes(task.status), + ).length; + } + roomProgress(id: string) { + const tasks = this.tasks().filter((task) => task.roomId === id && task.status !== 'omitted'); + return tasks.length + ? Math.round( + tasks.reduce( + (sum, task) => + sum + + (task.status === 'done' + ? 100 + : task.status === 'in_progress' + ? 50 + : task.status === 'acceptance' + ? 90 + : 10), + 0, + ) / tasks.length, + ) + : 0; + } + roomName(id: string | null) { + return this.rooms().find((room) => room.id === id)?.name ?? 'Allgemeine Aufgabe'; + } + roomStatus(value: string) { + return ( + ( + { + unplanned: 'Nicht geplant', + planning: 'Planung', + preparation: 'Vorbereitung', + renovating: 'In Renovierung', + acceptance: 'Abnahme', + done: 'Fertig', + omitted: 'Entfällt', + } as Record + )[value] ?? value + ); + } + taskStatus(value: string) { + return ( + ( + { + idea: 'Idee', + planned: 'Geplant', + commissioned: 'Beauftragt', + in_progress: 'In Arbeit', + blocked: 'Blockiert', + acceptance: 'Abnahme erforderlich', + done: 'Erledigt', + omitted: 'Entfällt', + } as Record + )[value] ?? value + ); + } + activityLabel(value: string) { + return ( + ( + { + 'building.created': 'hat ein Gebäude erstellt.', + 'floor.created': 'hat eine Etage erstellt.', + 'room.created': 'hat einen Raum erstellt.', + 'room.status-changed': 'hat einen Raumstatus geändert.', + 'task.created': 'hat eine Aufgabe erstellt.', + 'task.updated': 'hat eine Aufgabe bearbeitet.', + 'task.status-changed': 'hat einen Aufgabenstatus geändert.', + 'expense.created': 'hat eine Ausgabe erfasst.', + 'document.uploaded': 'hat ein Dokument hochgeladen.', + 'template.applied': 'hat eine Vorlage angewendet.', + } as Record + )[value] ?? value + ); + } + createRoom() { + if (this.roomForm.invalid) return; + const editing = this.editingRoom(); + const request = editing + ? this.api.updateRoom(this.projectId, editing, this.roomForm.getRawValue()) + : this.api.createRoom(this.projectId, this.roomForm.getRawValue()); + this.save(request, (room) => { + this.rooms.update((items) => + editing ? items.map((item) => (item.id === room.id ? room : item)) : [...items, room], + ); + this.showRoomForm.set(false); + this.editingRoom.set(null); + this.roomForm.reset({ + floorId: this.floors()[0]?.id ?? '', + name: '', + type: 'other', + status: 'unplanned', + description: '', + area: null, + plannedBudget: null, + sortOrder: 0, + }); + }); + } + editRoom(room: Room) { + this.editingRoom.set(room); + this.showRoomForm.set(true); + this.roomForm.reset({ + floorId: room.floorId, + name: room.name, + description: room.description ?? '', + type: room.type, + status: room.status, + area: room.area ? +room.area : null, + plannedBudget: room.plannedBudget ? +room.plannedBudget : null, + sortOrder: room.sortOrder, + }); + } + createDefaultFloors() { + this.save(this.api.ensureDefaultFloors(this.projectId), (result) => { + this.floors.set(result.floors); + this.roomForm.controls.floorId.setValue(result.floors[0]?.id ?? ''); + }); + } + createTask() { + if (this.taskForm.invalid) return; + const value = this.taskForm.getRawValue(); + this.save( + this.api.createTask(this.projectId, { + title: value.title, + description: value.description, + category: value.category, + status: value.status, + priority: value.priority, + ...(value.roomId ? { roomId: value.roomId } : {}), + ...(value.dueDate ? { dueDate: value.dueDate } : {}), + ...(value.assigneeUserId ? { assigneeUserId: value.assigneeUserId } : {}), + ...(value.plannedStartDate ? { plannedStartDate: value.plannedStartDate } : {}), + ...(value.estimatedEffortHours !== null + ? { estimatedEffortHours: value.estimatedEffortHours } + : {}), + ...(value.estimatedCost !== null ? { estimatedCost: value.estimatedCost } : {}), + ...(value.actualCost !== null ? { actualCost: value.actualCost } : {}), + weight: value.weight, + blockingReason: value.blockingReason, + }), + (task) => { + this.tasks.update((items) => [task, ...items]); + this.showTaskForm.set(false); + this.taskForm.reset(); + }, + ); + } + complete(task: RenovationTask) { + this.save(this.api.updateTask(this.projectId, task, { status: 'done' }), (updated) => + this.tasks.update((items) => items.map((item) => (item.id === updated.id ? updated : item))), + ); + } + loadTaskPage(page: number) { + this.taskPage.set(page); + this.api + .tasks(this.projectId, { + page, + pageSize: 25, + sortBy: this.taskSort.value, + ...(this.taskSearch.value ? { search: this.taskSearch.value } : {}), + ...(this.statusFilter.value ? { statuses: this.statusFilter.value } : {}), + ...(this.mineFilter.value ? { mine: true } : {}), + }) + .subscribe({ + next: (result) => { + this.tasks.set(result.items); + this.taskTotal.set(result.totalItems); + }, + error: (error: unknown) => this.fail(error), + }); + } + saveMilestone() { + if (this.milestoneForm.invalid) return; + const editing = this.editingMilestone(); + const value = this.milestoneForm.getRawValue(); + const request = editing + ? this.api.updateMilestone(this.projectId, editing, value) + : this.api.createMilestone(this.projectId, value); + this.save(request, (saved) => { + this.milestones.update((items) => + editing ? items.map((item) => (item.id === saved.id ? saved : item)) : [...items, saved], + ); + this.editingMilestone.set(null); + }); + } + editMilestone(item: Milestone) { + this.editingMilestone.set(item); + this.milestoneForm.reset({ + title: item.title, + description: item.description ?? '', + date: item.date.slice(0, 10), + status: item.status, + type: item.type, + responsibleUserId: item.responsibleUserId ?? '', + }); + } + saveBudget() { + if (this.budgetForm.invalid) return; + const editing = this.editingBudget(); + const value = this.budgetForm.getRawValue(); + if (value.plannedBudget === null) return; + const request = editing + ? this.api.updateBudget(this.projectId, editing, value) + : this.api.createBudget(this.projectId, { + name: value.name, + plannedBudget: value.plannedBudget, + }); + this.save(request, (saved) => { + this.budgets.update((items) => + editing ? items.map((item) => (item.id === saved.id ? saved : item)) : [...items, saved], + ); + this.editingBudget.set(null); + }); + } + editBudget(item: BudgetCategory) { + this.editingBudget.set(item); + this.budgetForm.reset({ + name: item.name, + plannedBudget: +item.plannedBudget, + sortOrder: item.sortOrder, + }); + } + saveExpense() { + if (this.expenseForm.invalid) return; + const editing = this.editingExpense(); + const value = this.expenseForm.getRawValue(); + if (value.amount === null) return; + const body = { + ...value, + roomId: value.roomId || undefined, + taskId: value.taskId || undefined, + dueDate: value.dueDate || undefined, + documentId: value.documentId || undefined, + }; + const request = editing + ? this.api.updateExpense(this.projectId, editing, body) + : this.api.createExpense(this.projectId, body); + this.save(request, (saved) => { + this.expenses.update((items) => + editing ? items.map((item) => (item.id === saved.id ? saved : item)) : [...items, saved], + ); + this.editingExpense.set(null); + }); + } + editExpense(item: Expense) { + this.editingExpense.set(item); + this.expenseForm.reset({ + title: item.title, + description: item.description ?? '', + amount: +item.amount, + currency: item.currency, + budgetCategoryId: item.budgetCategoryId, + roomId: item.roomId ?? '', + taskId: item.taskId ?? '', + expenseDate: item.expenseDate.slice(0, 10), + paymentStatus: item.paymentStatus, + dueDate: item.dueDate?.slice(0, 10) ?? '', + supplier: item.supplier ?? '', + invoiceNumber: item.invoiceNumber ?? '', + documentId: item.documentId ?? '', + }); + } + upload(event: Event) { + const input = event.target; + if (!(input instanceof HTMLInputElement) || !input.files?.[0]) return; + const file = input.files[0]; + const data = new FormData(); + data.append('file', file); + data.append('title', file.name); + data.append('type', file.type.startsWith('image/') ? 'photo' : 'other'); + this.save(this.api.upload(this.projectId, data), (document) => + this.documents.update((items) => [document, ...items]), + ); + input.value = ''; + } + private save(request: Observable, next: (value: T) => void) { + this.saving.set(true); + this.error.set(null); + request.subscribe({ + next, + error: (e: { status?: number; error?: ApiErrorBody }) => { + this.error.set( + conflictMessage( + e.status ?? 0, + e.error?.message ?? 'Die Änderung konnte nicht gespeichert werden.', + ), + ); + this.saving.set(false); + }, + complete: () => this.saving.set(false), + }); + } + private load() { + this.projectsApi.project(this.projectId).subscribe({ + next: (value) => this.project.set(value), + error: (error: unknown) => this.fail(error), + }); + this.projectsApi.projectMembers(this.projectId).subscribe({ + next: (value) => this.members.set(value), + error: (error: unknown) => this.fail(error), + }); + const requests = [ + this.api + .dashboard(this.projectId) + .subscribe({ next: (v) => this.dashboard.set(v), error: (e: unknown) => this.fail(e) }), + this.api + .buildings(this.projectId) + .subscribe({ next: (v) => this.buildings.set(v), error: (e: unknown) => this.fail(e) }), + this.api.floors(this.projectId).subscribe({ + next: (v) => { + this.floors.set(v); + this.roomForm.controls.floorId.setValue(v[0]?.id ?? ''); + }, + error: (e: unknown) => this.fail(e), + }), + this.api + .rooms(this.projectId) + .subscribe({ next: (v) => this.rooms.set(v.items), error: (e: unknown) => this.fail(e) }), + this.api.tasks(this.projectId).subscribe({ + next: (v) => { + this.tasks.set(v.items); + this.taskTotal.set(v.totalItems); + }, + error: (e: unknown) => this.fail(e), + }), + this.api.milestones(this.projectId).subscribe({ + next: (v) => this.milestones.set(v.items), + error: (e: unknown) => this.fail(e), + }), + this.api + .budgets(this.projectId) + .subscribe({ next: (v) => this.budgets.set(v), error: (e: unknown) => this.fail(e) }), + this.api.expenses(this.projectId).subscribe({ + next: (v) => this.expenses.set(v.items), + error: (e: unknown) => this.fail(e), + }), + this.api.documents(this.projectId).subscribe({ + next: (v) => this.documents.set(v.items), + error: (e: unknown) => this.fail(e), + }), + this.api.activities(this.projectId).subscribe({ + next: (v) => this.activities.set(v.items), + error: (e: unknown) => this.fail(e), + }), + ]; + void Promise.resolve(requests).then(() => this.loading.set(false)); + } + private fail(error: unknown) { + const response = error as { error?: ApiErrorBody }; + this.error.set(response.error?.message ?? 'Projektdaten konnten nicht geladen werden.'); + this.loading.set(false); + } +} diff --git a/apps/frontend/src/app/features/projects/projects.page.ts b/apps/frontend/src/app/features/projects/projects.page.ts new file mode 100644 index 0000000..5c36500 --- /dev/null +++ b/apps/frontend/src/app/features/projects/projects.page.ts @@ -0,0 +1,150 @@ +import { Component, inject, signal } from '@angular/core'; +import type { OnInit } from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { RouterLink } from '@angular/router'; +import type { ApiErrorBody, ProjectDto } from '@boilerplate/api-client'; +import { ApiClientService } from '@boilerplate/api-client'; +import { UiEmptyStateComponent, UiPageHeaderComponent } from '../../shared/ui'; + +@Component({ + standalone: true, + imports: [ReactiveFormsModule, RouterLink, UiEmptyStateComponent, UiPageHeaderComponent], + template: ` + + +
+

Projekt anlegen

+

Sie werden serverseitig automatisch als Projekteigentuemer eingetragen.

+
+ + + +
+ @if (error()) { + + } +
+ + @if (loading()) { +

Projekte werden geladen ...

+ } @else if (projects().length === 0) { + + } @else { +
+ @for (project of projects(); track project.id) { + +

{{ project.name }}

+

{{ project.description || 'Keine Beschreibung' }}

+ Rolle: {{ roleLabel(project.role) }} +
+ } +
+ } + `, + styles: [ + ` + :host, + .create-card, + .project-grid, + .project-card { + display: grid; + gap: var(--space-4); + } + .create-card { + margin-bottom: var(--space-6); + } + .create-card h2, + .create-card p, + .project-card h2, + .project-card p { + margin: 0; + } + .project-grid { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr)); + } + .project-card { + color: var(--color-text-primary); + text-decoration: none; + } + .project-card:hover { + border-color: var(--color-primary); + } + .project-card p, + .project-card span, + .create-card > p { + color: var(--color-text-secondary); + } + .error { + color: var(--color-danger); + } + `, + ], +}) +export class ProjectsPageComponent implements OnInit { + private readonly api = inject(ApiClientService); + readonly projects = signal([]); + readonly loading = signal(true); + readonly saving = signal(false); + readonly error = signal(null); + readonly form = new FormGroup({ + name: new FormControl('', { + nonNullable: true, + validators: [(control) => Validators.required(control), Validators.maxLength(160)], + }), + description: new FormControl('', { + nonNullable: true, + validators: [Validators.maxLength(4000)], + }), + }); + + ngOnInit(): void { + this.load(); + } + + create(): void { + if (this.form.invalid || this.saving()) return; + this.saving.set(true); + this.error.set(null); + this.api.createProject(this.form.getRawValue()).subscribe({ + next: (project) => { + this.projects.update((projects) => [project, ...projects]); + this.form.reset(); + }, + error: (error: { error?: ApiErrorBody }) => { + this.error.set(error.error?.message ?? 'Das Projekt konnte nicht angelegt werden.'); + this.saving.set(false); + }, + complete: () => this.saving.set(false), + }); + } + + roleLabel(role: ProjectDto['role']): string { + return { + owner: 'Eigentuemer', + administrator: 'Projektadministrator', + editor: 'Bearbeiter', + reader: 'Leser', + }[role]; + } + + private load(): void { + this.api.projects().subscribe({ + next: (projects) => this.projects.set(projects), + error: (error: { error?: ApiErrorBody }) => + this.error.set(error.error?.message ?? 'Projekte konnten nicht geladen werden.'), + complete: () => this.loading.set(false), + }); + } +} diff --git a/apps/frontend/src/app/features/projects/task-detail.page.ts b/apps/frontend/src/app/features/projects/task-detail.page.ts new file mode 100644 index 0000000..cbccca0 --- /dev/null +++ b/apps/frontend/src/app/features/projects/task-detail.page.ts @@ -0,0 +1,797 @@ +import { CurrencyPipe, DatePipe } from '@angular/common'; +import { Component, computed, inject, signal } from '@angular/core'; +import type { OnInit } from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import type { Observable } from 'rxjs'; +import type { ApiErrorBody, ProjectMemberDto } from '@boilerplate/api-client'; +import { ApiClientService } from '@boilerplate/api-client'; +import { UiEmptyStateComponent, UiStatusBadgeComponent } from '../../shared/ui'; +import { AuthService } from '../../core/auth.service'; +import { HauspilotApiService } from './hauspilot-api.service'; +import type { RenovationTask, Room, TaskDetail, TaskPatch } from './hauspilot-api.service'; +import { conflictMessage } from './project-workspace.helpers'; + +@Component({ + standalone: true, + imports: [ + CurrencyPipe, + DatePipe, + ReactiveFormsModule, + RouterLink, + UiEmptyStateComponent, + UiStatusBadgeComponent, + ], + template: ` + Zurück zu den Aufgaben + @if (error()) { + + } + @if (loading()) { +

Aufgabe wird geladen …

+ } + @if (task(); as task) { +
+
+

{{ roomName(task.roomId) }} · {{ task.category }}

+

{{ task.title }}

+
+ + + @if (isOverdue(task)) { + ⚠ Überfällig + } + @if (task.blockedByDependencies) { + ⛔ Blockiert + } +
+
+ @if (canEdit()) { +
+ + @if (task.status !== 'done') { + + } +
+ } +
+ + @if (editing()) { +
+ + + + + + + + + + + + + + +
+ +
+
+ } @else { +
+
+

Beschreibung

+

{{ task.description || 'Keine Beschreibung hinterlegt.' }}

+
+
+ Verantwortlich{{ task.assigneeName || 'Nicht zugewiesen' }} +
+
+ Start{{ + task.plannedStartDate ? (task.plannedStartDate | date: 'dd.MM.yyyy') : '–' + }} +
+
+ Fälligkeit{{ task.dueDate ? (task.dueDate | date: 'dd.MM.yyyy') : '–' }} +
+
+ Abgeschlossen{{ + task.actualCompletionDate ? (task.actualCompletionDate | date: 'dd.MM.yyyy') : '–' + }} +
+
+ Aufwand{{ task.estimatedEffortHours || '–' }} h +
+
+ Kosten{{ +(task.actualCost || task.estimatedCost || 0) | currency: 'EUR' }} +
+ @if (task.blockingReason) { +
+ Blockierungsgrund{{ task.blockingReason }} +
+ } +
+ } + +
+
+

Checkliste

+ {{ checklistProgress() }} % +
    + @for (item of task.checklist; track item.id) { +
  • + +
  • + } +
+ @if (canEdit()) { +
+ +
+ } +
+
+

Abhängigkeiten

+

Vorgänger

+ @for (dep of predecessors(); track dep.id) { +
+ {{ + dep.predecessor?.title || dep.predecessorTaskId + }}{{ statusLabel(dep.predecessor?.status || '') }} + @if (canEdit()) { + + } +
+ } @empty { +

Keine Vorgänger.

+ } +

Nachfolger

+ @for (dep of successors(); track dep.id) { +
+ {{ + dep.successor?.title || dep.successorTaskId + }}{{ statusLabel(dep.successor?.status || '') }} +
+ } @empty { +

Keine Nachfolger.

+ } + @if (canEdit()) { +
+ +
+ } +
+
+ +
+

Kommentare

+
+ @for (comment of task.comments; track comment.id) { +
+
+ {{ comment.authorName }} + +
+

{{ comment.text }}

+
+ } @empty { + + } +
+ @if (canEdit()) { +
+ +
+ Erwähnen +
+ @for (member of members(); track member.userId) { + + } +
+
+ +
+ } +
+ +
+
+

Dokumente

+ @for (document of task.documents; track document.id) { +
+ {{ document.title }}Herunterladen +
+ } @empty { +

Noch keine Dokumente zugeordnet.

+ } +
+
+

Aktivitäten

+ @for (activity of task.activities; track activity.id) { +
+ {{ activity.actorName }}{{ activity.action }} +
+ } @empty { +

Noch keine aufgabenbezogenen Aktivitäten.

+ } +
+
+ } + `, + styles: [ + ` + :host { + display: grid; + gap: var(--space-5); + } + .task-header, + .actions, + .badges, + .dependency, + .activity, + .inline-form { + display: flex; + align-items: center; + gap: var(--space-3); + flex-wrap: wrap; + } + .task-header { + justify-content: space-between; + } + .task-header h1 { + margin-block: var(--space-1) var(--space-3); + } + .task-header p, + .prewrap { + margin: 0; + } + .text-flag { + font-weight: 700; + color: var(--color-danger); + } + .form-grid, + .detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-4); + } + label { + display: grid; + gap: var(--space-2); + } + .wide { + grid-column: 1 / -1; + } + .columns { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-4); + } + .label { + display: block; + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + } + .prewrap { + white-space: pre-wrap; + } + progress { + inline-size: 100%; + accent-color: var(--color-primary); + } + .plain-list { + list-style: none; + padding: 0; + display: grid; + gap: var(--space-2); + } + .plain-list label { + display: flex; + align-items: center; + } + .completed { + text-decoration: line-through; + color: var(--color-text-secondary); + } + .dependency { + justify-content: space-between; + border-block-end: 1px solid var(--color-border); + padding-block: var(--space-2); + } + .link-button { + border: 0; + background: transparent; + color: var(--color-danger); + text-decoration: underline; + cursor: pointer; + } + .comments { + display: grid; + gap: var(--space-3); + } + .comments article { + border-inline-start: 0.25rem solid var(--color-border); + padding-inline-start: var(--space-3); + } + .comments time, + .activity time { + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + } + .comment-form { + display: grid; + gap: var(--space-3); + margin-block-start: var(--space-4); + } + .mention-list { + display: flex; + gap: var(--space-2); + flex-wrap: wrap; + } + .mention { + border: 1px solid var(--color-border); + border-radius: var(--radius-full); + background: var(--color-surface); + color: var(--color-text); + padding: var(--space-1) var(--space-3); + cursor: pointer; + } + .mention.selected { + border-color: var(--color-primary); + background: var(--color-primary-subtle); + } + .activity { + align-items: baseline; + border-block-end: 1px solid var(--color-border); + padding-block: var(--space-2); + } + .error { + color: var(--color-danger); + } + @media (max-width: 48rem) { + .form-grid, + .detail-grid, + .columns { + grid-template-columns: 1fr; + } + .wide { + grid-column: auto; + } + .actions .ui-button { + inline-size: 100%; + } + } + `, + ], +}) +export class TaskDetailPageComponent implements OnInit { + readonly api = inject(HauspilotApiService); + private readonly projectsApi = inject(ApiClientService); + private readonly auth = inject(AuthService); + private readonly route = inject(ActivatedRoute); + projectId = ''; + taskId = ''; + readonly task = signal(null); + readonly rooms = signal([]); + readonly allTasks = signal([]); + readonly members = signal([]); + readonly loading = signal(true); + readonly saving = signal(false); + readonly editing = signal(false); + readonly error = signal(null); + readonly selectedMentions = signal([]); + readonly checklistText = new FormControl('', { + nonNullable: true, + validators: [ + (control) => Validators.required(control), + (control) => Validators.maxLength(500)(control), + ], + }); + readonly predecessorId = new FormControl('', { nonNullable: true }); + readonly commentText = new FormControl('', { + nonNullable: true, + validators: [ + (control) => Validators.required(control), + (control) => Validators.maxLength(4000)(control), + ], + }); + readonly statuses = [ + ['idea', 'Idee'], + ['planned', 'Geplant'], + ['commissioned', 'Beauftragt'], + ['in_progress', 'In Arbeit'], + ['blocked', 'Blockiert'], + ['acceptance', 'Abnahme erforderlich'], + ['done', 'Erledigt'], + ['omitted', 'Entfällt'], + ] as const; + readonly form = new FormGroup({ + title: new FormControl('', { + nonNullable: true, + validators: [ + (control) => Validators.required(control), + (control) => Validators.maxLength(200)(control), + ], + }), + description: new FormControl('', { nonNullable: true }), + category: new FormControl('general', { + nonNullable: true, + validators: [(control) => Validators.required(control)], + }), + status: new FormControl('planned', { nonNullable: true }), + priority: new FormControl('normal', { nonNullable: true }), + roomId: new FormControl('', { nonNullable: true }), + assigneeUserId: new FormControl('', { nonNullable: true }), + plannedStartDate: new FormControl('', { nonNullable: true }), + dueDate: new FormControl('', { nonNullable: true }), + estimatedEffortHours: new FormControl(null), + estimatedCost: new FormControl(null), + actualCost: new FormControl(null), + weight: new FormControl(1, { nonNullable: true, validators: [Validators.min(0.01)] }), + blockingReason: new FormControl('', { nonNullable: true }), + }); + readonly assignableMembers = computed(() => + this.members().filter((member) => member.active && member.role !== 'reader'), + ); + readonly predecessors = computed( + () => this.task()?.dependencies.filter((item) => item.successorTaskId === this.taskId) ?? [], + ); + readonly successors = computed( + () => this.task()?.dependencies.filter((item) => item.predecessorTaskId === this.taskId) ?? [], + ); + readonly dependencyCandidates = computed(() => + this.allTasks().filter( + (item) => + item.id !== this.taskId && + !this.predecessors().some((dep) => dep.predecessorTaskId === item.id), + ), + ); + + ngOnInit() { + this.projectId = this.route.snapshot.paramMap.get('id') ?? ''; + this.taskId = this.route.snapshot.paramMap.get('taskId') ?? ''; + this.load(); + } + canEdit() { + return this.members().some( + (member) => + member.userId === this.auth.user()?.id && member.active && member.role !== 'reader', + ); + } + roomName(id: string | null) { + return this.rooms().find((room) => room.id === id)?.name ?? 'Allgemeine Aufgabe'; + } + isOverdue(task: RenovationTask) { + return ( + !!task.dueDate && + !['done', 'omitted'].includes(task.status) && + new Date(task.dueDate) < new Date() + ); + } + statusLabel(value: string) { + return this.statuses.find((item) => item[0] === value)?.[1] ?? value; + } + priorityLabel(value: string) { + return ( + ( + { low: 'Niedrig', normal: 'Normal', high: 'Hoch', critical: 'Kritisch' } as Record< + string, + string + > + )[value] ?? value + ); + } + checklistProgress() { + const items = this.task()?.checklist ?? []; + return items.length + ? Math.round((items.filter((item) => item.completed).length / items.length) * 100) + : 0; + } + quickComplete() { + const task = this.task(); + if (task) this.updateTask({ status: 'done' }); + } + saveTask() { + if (this.form.invalid) return; + const value = this.form.getRawValue(); + this.updateTask({ + ...value, + roomId: value.roomId || null, + assigneeUserId: value.assigneeUserId || null, + plannedStartDate: value.plannedStartDate || null, + dueDate: value.dueDate || null, + }); + } + toggleChecklist(itemId: string, completed: boolean) { + this.run(this.api.updateChecklist(this.projectId, this.taskId, itemId, { completed }), () => + this.reloadTask(), + ); + } + addChecklist() { + if (this.checklistText.invalid) return; + this.run(this.api.addChecklist(this.projectId, this.taskId, this.checklistText.value), () => { + this.checklistText.reset(); + this.reloadTask(); + }); + } + addDependency() { + if (!this.predecessorId.value) return; + this.run(this.api.addDependency(this.projectId, this.taskId, this.predecessorId.value), () => { + this.predecessorId.reset(); + this.reloadTask(); + }); + } + removeDependency(id: string) { + this.run(this.api.removeDependency(this.projectId, this.taskId, id), () => this.reloadTask()); + } + toggleMention(member: ProjectMemberDto) { + this.selectedMentions.update((items) => + items.includes(member.userId) + ? items.filter((id) => id !== member.userId) + : [...items, member.userId], + ); + if (!this.commentText.value.includes(`@${member.name}`)) + this.commentText.setValue( + `${this.commentText.value}${this.commentText.value ? ' ' : ''}@${member.name} `, + ); + } + addComment() { + if (this.commentText.invalid) return; + this.run( + this.api.addComment( + this.projectId, + this.taskId, + this.commentText.value, + this.selectedMentions(), + ), + (comment) => { + this.task.update((task) => + task + ? { + ...task, + comments: [ + ...task.comments, + { ...comment, authorName: this.auth.user()?.name ?? 'Ich' }, + ], + } + : task, + ); + this.commentText.reset(); + this.selectedMentions.set([]); + }, + ); + } + private updateTask(patch: TaskPatch) { + const task = this.task(); + if (!task) return; + this.run(this.api.updateTask(this.projectId, task, patch), () => { + this.editing.set(false); + this.reloadTask(); + }); + } + private load() { + this.projectsApi.projectMembers(this.projectId).subscribe({ + next: (members) => this.members.set(members), + error: (error: unknown) => this.fail(error), + }); + this.api.rooms(this.projectId, { pageSize: 100 }).subscribe({ + next: (page) => this.rooms.set(page.items), + error: (error: unknown) => this.fail(error), + }); + this.api.tasks(this.projectId, { pageSize: 100, sortBy: 'title' }).subscribe({ + next: (page) => this.allTasks.set(page.items), + error: (error: unknown) => this.fail(error), + }); + this.reloadTask(); + } + private reloadTask() { + this.api.task(this.projectId, this.taskId).subscribe({ + next: (task) => { + this.task.set(task); + this.form.reset({ + title: task.title, + description: task.description ?? '', + category: task.category, + status: task.status, + priority: task.priority, + roomId: task.roomId ?? '', + assigneeUserId: task.assigneeUserId ?? '', + plannedStartDate: task.plannedStartDate?.slice(0, 10) ?? '', + dueDate: task.dueDate?.slice(0, 10) ?? '', + estimatedEffortHours: task.estimatedEffortHours ? +task.estimatedEffortHours : null, + estimatedCost: task.estimatedCost ? +task.estimatedCost : null, + actualCost: task.actualCost ? +task.actualCost : null, + weight: +task.weight, + blockingReason: task.blockingReason ?? '', + }); + this.loading.set(false); + }, + error: (error: unknown) => this.fail(error), + }); + } + private run(request: Observable, next: (value: T) => void) { + this.saving.set(true); + this.error.set(null); + request.subscribe({ + next, + error: (error: { status?: number; error?: ApiErrorBody }) => { + this.error.set( + conflictMessage( + error.status ?? 0, + error.error?.message ?? 'Die Änderung konnte nicht gespeichert werden.', + ), + ); + this.saving.set(false); + }, + complete: () => this.saving.set(false), + }); + } + private fail(error: unknown) { + this.error.set( + (error as { error?: ApiErrorBody }).error?.message ?? + 'Die Aufgabe konnte nicht geladen werden.', + ); + this.loading.set(false); + } +} diff --git a/apps/frontend/src/app/layout/app-shell.ts b/apps/frontend/src/app/layout/app-shell.ts index 45d621a..9c0e926 100644 --- a/apps/frontend/src/app/layout/app-shell.ts +++ b/apps/frontend/src/app/layout/app-shell.ts @@ -30,7 +30,7 @@ interface NavItem { @if (auth.user()) { } - Business App + HausPilot @if (auth.user()) {