This commit is contained in:
Bastian Wagner
2026-07-20 09:01:36 +02:00
parent 8cf57d7878
commit e62673ac11
98 changed files with 17372 additions and 80 deletions

View File

@@ -1,4 +1,5 @@
{
"singleQuote": true,
"trailingComma": "all"
"trailingComma": "all",
"endOfLine": "auto"
}

View File

@@ -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"
}
}

View File

@@ -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,

View File

@@ -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')

View File

@@ -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<OidcLoginStateEntity>,
);
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<number>>(() => Promise.resolve(1));
const getIdTokenForLogout = vi.fn<() => Promise<string | undefined>>(() =>

View File

@@ -31,7 +31,7 @@ export class AuthService {
private readonly loginStates: Repository<OidcLoginStateEntity>,
) {}
async createLoginUrl(): Promise<string> {
async createLoginUrl(returnTo?: string): Promise<string> {
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<string> {
@@ -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<OidcUserInfo>(
@@ -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;
}
}

View File

@@ -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;

View File

@@ -20,4 +20,5 @@ export interface OidcUserInfo {
sub: string;
name?: string;
email?: string;
email_verified?: boolean;
}

View File

@@ -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;
}
}

View File

@@ -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',
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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<string, unknown>): 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,

View File

@@ -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,
];

View File

@@ -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<void>>(() =>
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');
});
});

View File

@@ -4,13 +4,17 @@ export class AddRoleDescription1720000002000 implements MigrationInterface {
name = 'AddRoleDescription1720000002000';
async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query('ALTER TABLE roles DROP COLUMN description');
async down(): Promise<void> {
// The current baseline schema already owns this column. Removing it here
// would corrupt databases created by InitialSchema1720000000000.
}
}

View File

@@ -0,0 +1,92 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddHausPilotProjects1720000003000 implements MigrationInterface {
name = 'AddHausPilotProjects1720000003000';
async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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');
}
}

View File

@@ -0,0 +1,64 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddRenovationDomain1720000004000 implements MigrationInterface {
name = 'AddRenovationDomain1720000004000';
async up(q: QueryRunner): Promise<void> {
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<void> {
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',
);
}
}

View File

@@ -0,0 +1,25 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddMentionsAndReminders1720000005000
implements MigrationInterface
{
name = 'AddMentionsAndReminders1720000005000';
async up(q: QueryRunner): Promise<void> {
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<void> {
await q.query('DROP TABLE applied_project_templates');
await q.query('DROP TABLE reminder_deliveries');
await q.query('DROP TABLE task_comment_mentions');
}
}

View File

@@ -0,0 +1,44 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddDefaultProjectFloors1720000006000
implements MigrationInterface
{
name = 'AddDefaultProjectFloors1720000006000';
async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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)`,
);
}
}

View File

@@ -0,0 +1,95 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddFurniturePlanning1720000007000 implements MigrationInterface {
name = 'AddFurniturePlanning1720000007000';
async up(q: QueryRunner): Promise<void> {
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<void> {
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');
}
}

View File

@@ -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<void> {
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();

View File

@@ -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,
],
});

View File

@@ -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,
],
};
}

View File

@@ -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 =

View File

@@ -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;
}

View File

@@ -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<string, string | number | boolean | null> | null;
@CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 3 })
createdAt!: Date;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<ProjectAction, readonly ProjectRole[]> = {
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;
}
}

View File

@@ -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;
}
}

View File

@@ -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 {}

View File

@@ -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<void> {
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<void> {
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<ProjectEntity> {
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<UserEntity> {
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<ProjectInvitationEntity> {
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<string, string | null>,
manager?: EntityManager,
) {
const activity = new ProjectActivityEntity();
activity.projectId = projectId;
activity.actorUserId = actorUserId;
activity.action = action;
activity.metadata = metadata;
return this.projects.saveActivity(activity, manager);
}
}

View File

@@ -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<ProjectEntity>,
@InjectRepository(ProjectMembershipEntity)
private readonly memberships: Repository<ProjectMembershipEntity>,
@InjectRepository(ProjectInvitationEntity)
private readonly invitations: Repository<ProjectInvitationEntity>,
@InjectRepository(ProjectActivityEntity)
private readonly activities: Repository<ProjectActivityEntity>,
) {}
listForUser(userId: string): Promise<ProjectEntity[]> {
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<ProjectEntity | null> {
return (manager?.getRepository(ProjectEntity) ?? this.projects).findOne({
where: { id },
});
}
findMembership(
projectId: string,
userId: string,
manager?: EntityManager,
): Promise<ProjectMembershipEntity | null> {
return (
manager?.getRepository(ProjectMembershipEntity) ?? this.memberships
).findOne({
where: { projectId, userId },
relations: { user: true },
});
}
listMembers(projectId: string): Promise<ProjectMembershipEntity[]> {
return this.memberships.find({
where: { projectId, active: true },
relations: { user: true },
order: { createdAt: 'ASC' },
});
}
findPendingInvitation(
projectId: string,
email: string,
): Promise<ProjectInvitationEntity | null> {
return this.invitations.findOne({
where: {
projectId,
invitedEmail: email,
status: InvitationStatus.Pending,
},
});
}
findInvitationById(
id: string,
manager?: EntityManager,
lock = false,
): Promise<ProjectInvitationEntity | null> {
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<ProjectInvitationEntity | null> {
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<ProjectInvitationEntity[]> {
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<ProjectEntity> {
return (manager?.getRepository(ProjectEntity) ?? this.projects).save(
project,
);
}
saveMembership(
membership: ProjectMembershipEntity,
manager?: EntityManager,
): Promise<ProjectMembershipEntity> {
return (
manager?.getRepository(ProjectMembershipEntity) ?? this.memberships
).save(membership);
}
saveInvitation(
invitation: ProjectInvitationEntity,
manager?: EntityManager,
): Promise<ProjectInvitationEntity> {
return (
manager?.getRepository(ProjectInvitationEntity) ?? this.invitations
).save(invitation);
}
saveActivity(
activity: ProjectActivityEntity,
manager?: EntityManager,
): Promise<ProjectActivityEntity> {
return (
manager?.getRepository(ProjectActivityEntity) ?? this.activities
).save(activity);
}
listActivities(
projectId: string,
limit = 50,
): Promise<ProjectActivityEntity[]> {
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),
};
}
}

View File

@@ -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');
});
});

View File

@@ -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> = {}): 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: <T extends object>(value: T) => value,
save: <T extends object>(value: T | T[]) =>
Promise.resolve(
Array.isArray(value) ? value : { ...value, id: 'generated-id' },
),
};
return {
transaction: <T>(action: (manager: EntityManager) => Promise<T>) =>
action({ getRepository: () => repository } as unknown as EntityManager),
} as DataSource;
}
function invitation(
token: string,
overrides: Partial<ProjectInvitationEntity> = {},
) {
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();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -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<string, readonly string[]> = {
'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;
}
}

View File

@@ -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';
}

View File

@@ -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[];
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<number>((sum, value) => sum + cents(value), 0);
return `${Math.floor(total / 100)}.${String(total % 100).padStart(2, '0')}`;
}

View File

@@ -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);
}
}

View File

@@ -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<FurnitureRequirementEntity>,
@InjectRepository(FurnitureOptionEntity)
readonly options: Repository<FurnitureOptionEntity>,
@InjectRepository(FurnitureScenarioEntity)
readonly scenarios: Repository<FurnitureScenarioEntity>,
@InjectRepository(FurnitureScenarioSelectionEntity)
readonly selections: Repository<FurnitureScenarioSelectionEntity>,
@InjectRepository(FurnitureOptionDocumentEntity)
readonly optionDocuments: Repository<FurnitureOptionDocumentEntity>,
) {}
async pageRequirements(projectId: string, query: FurnitureListQueryDto) {
const allowedSort: Record<string, string> = {
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<string, string> = {
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' },
});
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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),
};
}

View File

@@ -0,0 +1,65 @@
import {
TaskStatus,
type RenovationTaskEntity,
} from './entities/renovation.entities';
const statusProgress: Record<TaskStatus, number> = {
[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<RenovationTaskEntity, 'status' | 'weight'>[],
): 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<string, string[]>();
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<string>();
const visited = new Set<string>();
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);
}

View File

@@ -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<number> {
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);
}
}

View File

@@ -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;
}
}

View File

@@ -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 {}

View File

@@ -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<BuildingEntity>,
@InjectRepository(FloorEntity) readonly floors: Repository<FloorEntity>,
@InjectRepository(RoomEntity) readonly rooms: Repository<RoomEntity>,
@InjectRepository(RenovationTaskEntity)
readonly tasks: Repository<RenovationTaskEntity>,
@InjectRepository(ChecklistItemEntity)
readonly checklist: Repository<ChecklistItemEntity>,
@InjectRepository(TaskDependencyEntity)
readonly dependencies: Repository<TaskDependencyEntity>,
@InjectRepository(TaskCommentEntity)
readonly comments: Repository<TaskCommentEntity>,
@InjectRepository(MilestoneEntity)
readonly milestones: Repository<MilestoneEntity>,
@InjectRepository(BudgetCategoryEntity)
readonly budgets: Repository<BudgetCategoryEntity>,
@InjectRepository(ExpenseEntity)
readonly expenses: Repository<ExpenseEntity>,
@InjectRepository(ProjectDocumentEntity)
readonly documents: Repository<ProjectDocumentEntity>,
@InjectRepository(TaskCommentMentionEntity)
readonly mentions: Repository<TaskCommentMentionEntity>,
@InjectRepository(ReminderDeliveryEntity)
readonly reminders: Repository<ReminderDeliveryEntity>,
@InjectRepository(AppliedProjectTemplateEntity)
readonly appliedTemplates: Repository<AppliedProjectTemplateEntity>,
) {}
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<T extends ObjectLiteral>(
qb: SelectQueryBuilder<T>,
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),
};
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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 } : {}),
}));

View File

@@ -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,
});
});
});

View File

@@ -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');
});
});

View File

@@ -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 });
});
});

View File

@@ -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);
});
});

View File

@@ -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<EntityTarget<object>, object[]>([
[RenovationTaskEntity, [task]],
[MilestoneEntity, []],
[ExpenseEntity, []],
[FurnitureOptionEntity, []],
]);
const delivered = new Set<string>();
const createForUser = vi.fn().mockResolvedValue({});
const manager = {
getRepository: (target: EntityTarget<object>) => ({
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<object>) => ({
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<unknown>,
) => 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();
});
});

View File

@@ -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();
}
}
});
});

View File

@@ -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);

View File

@@ -189,6 +189,7 @@ export class RolesService {
Permission.SessionsReadOwn,
Permission.NotificationsReadOwn,
Permission.NotificationsUpdateOwn,
Permission.ProjectsUse,
],
true,
manager,

View File

@@ -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;

View File

@@ -23,6 +23,19 @@ export class UsersRepository {
return this.repo.findOne({ where: { issuer, subject } });
}
async findActiveByNormalizedEmail(
normalizedEmail: string,
manager?: EntityManager,
): Promise<UserEntity | null> {
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,

View File

@@ -7,7 +7,7 @@
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2023"],
"types": ["node", "vitest"],
"types": ["node", "vitest", "multer"],
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"isolatedModules": false,

View File

@@ -1,6 +1,7 @@
{
"printWidth": 100,
"singleQuote": true,
"endOfLine": "auto",
"overrides": [
{
"files": "*.html",

View File

@@ -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"
}

View File

@@ -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,
],

View File

@@ -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',

View File

@@ -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);
});
});

View File

@@ -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);
}
}

View File

@@ -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);
}),
);
};

View File

@@ -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 },

View File

@@ -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<FurnitureRequirement> = {
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<Record<string, string | number | boolean | readonly string[]>> = [];
const api = {
furnitureRequirements: (
_id: string,
query: Record<string, string | number | boolean | readonly string[]>,
) => {
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<Record<string, string | number | boolean | readonly string[]>> = [];
const api = {
furnitureRequirements: (
_id: string,
query: Record<string, string | number | boolean | readonly string[]>,
) => {
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 },
]);
});
});

View File

@@ -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<string, string>;
};
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<string, GridValuePresentation> = {
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<string, GridValuePresentation> = {
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<string, GridValuePresentation> = {
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<string, GridValuePresentation> = {
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<string, GridValuePresentation> = {
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: `
<section class="grid-shell" aria-labelledby="furniture-grid-title">
<div class="grid-tabs" role="tablist" aria-label="Möbelansichten">
@for (entry of views; track entry.id) {
<button
type="button"
role="tab"
[attr.aria-selected]="view() === entry.id"
[class.active]="view() === entry.id"
(click)="setView(entry.id)"
>
{{ entry.label }}
</button>
}
</div>
<div class="toolbar ui-card">
<label
>Suche
<input
[(ngModel)]="search"
(ngModelChange)="searchChanges.next($event)"
placeholder="Möbel, Hersteller oder Händler"
/></label>
<label
>Raum
<select [(ngModel)]="roomId" (ngModelChange)="load(1)">
<option value="">Alle Räume</option>
@for (room of rooms; track room.id) {
<option [value]="room.id">{{ room.name }}</option>
}
</select></label
>
@if (view() === 'requirements') {
<label
><input type="checkbox" [(ngModel)]="openOnly" (ngModelChange)="load(1)" /> Nur offene
Entscheidungen</label
>
<label
><input type="checkbox" [(ngModel)]="overBudget" (ngModelChange)="load(1)" /> Nur über
Budget</label
>
}
@if (view() === 'options' || view() === 'orders') {
<label
><input type="checkbox" [(ngModel)]="favoriteOnly" (ngModelChange)="load(1)" /> Nur
Favoriten</label
>
<label
><input type="checkbox" [(ngModel)]="delayedOnly" (ngModelChange)="load(1)" /> Nur
verspätet</label
>
}
<button type="button" class="ui-button ui-button--ghost" (click)="exportCsv()">
CSV exportieren
</button>
</div>
@if (error()) {
<p class="row-error" role="alert">{{ error() }}</p>
}
@if (savingRow()) {
<p class="save-state" role="status">Änderung wird gespeichert …</p>
}
@if (view() === 'scenarios') {
<p class="scenario-help">
Szenariozuweisung: Klicken Sie in eine Szenariospalte und wählen Sie eine Alternative aus.
„Nicht zugewiesen“ lässt den Bedarf im Szenario offen.
</p>
}
<ag-grid-angular
class="furniture-grid"
[theme]="gridTheme"
[rowData]="rows()"
[columnDefs]="columns()"
[defaultColDef]="defaultColDef"
[getRowId]="getRowId"
[rowSelection]="rowSelection"
[loading]="loading()"
[animateRows]="true"
[singleClickEdit]="true"
[stopEditingWhenCellsLoseFocus]="true"
(gridReady)="gridReady($event)"
(sortChanged)="sortChanged()"
(cellValueChanged)="cellChanged($event)"
(cellClicked)="cellClicked($event)"
(columnMoved)="saveState()"
(columnResized)="saveState()"
(columnVisible)="saveState()"
/>
<nav class="pager" aria-label="Grid-Seitennavigation">
<button type="button" [disabled]="page() <= 1" (click)="load(page() - 1)">Zurück</button>
<span>Seite {{ page() }} von {{ totalPages() }} · {{ totalItems() }} Einträge</span>
<button type="button" [disabled]="page() >= totalPages()" (click)="load(page() + 1)">
Weiter
</button>
</nav>
@if (view() === 'scenarios') {
<div class="scenario-summary">
@for (scenario of scenarios; track scenario.id) {
<article class="ui-card">
<strong>{{ scenario.name }}</strong
><span>{{ scenario.total | currency: 'EUR' : 'symbol' : '1.2-2' : 'de' }}</span
><small>{{ scenario.openRequirements }} offene Bedarfe</small>
</article>
}
</div>
}
</section>
`,
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<FurnitureRequirement>();
@Output() addOption = new EventEmitter<FurnitureRequirement>();
@Output() editOption = new EventEmitter<FurnitureOption>();
@Output() dataChanged = new EventEmitter<void>();
private readonly api = inject(HauspilotApiService);
private readonly auth = inject(AuthService);
private gridApi?: GridApi<FurnitureRow>;
private rollback = false;
readonly view = signal<View>('requirements');
readonly rows = signal<FurnitureRow[]>([]);
readonly loading = signal(false);
readonly savingRow = signal<string | null>(null);
readonly error = signal<string | null>(null);
readonly page = signal(1);
readonly totalPages = signal(1);
readonly totalItems = signal(0);
readonly searchChanges = new Subject<string>();
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<FurnitureRow> = { 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<ColDef<FurnitureRow>[]>([]);
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<FurnitureRow>) {
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<PageResult<FurnitureRow>> =
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<FurnitureRow>) {
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<FurnitureRow> =
'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<FurnitureRow>) {
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<FurnitureRow>[] {
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<FurnitureRow>[] {
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<FurnitureRow>[];
}
private scenarioColumns(): ColDef<FurnitureRow>[] {
const scenarioColumns: ColDef<FurnitureRow>[] = this.scenarios.map(
(scenario): ColDef<FurnitureRow> => ({
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<FurnitureRow>,
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<FurnitureRow>) {
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<string, GridValuePresentation>) {
if (typeof value !== 'string' || !value) return '—';
const presentation = presentations[value];
return presentation ? `${presentation.icon} ${presentation.label}` : value;
}
private presentationStyle(
value: unknown,
presentations: Record<string, GridValuePresentation>,
): CellStyle {
const tone = typeof value === 'string' ? presentations[value]?.tone : undefined;
const colors: Record<GridValuePresentation['tone'], CellStyle> = {
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));
}
}

View File

@@ -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);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -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();
});
});

View File

@@ -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<T> {
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<RenovationTask, 'estimatedEffortHours' | 'estimatedCost' | 'actualCost' | 'weight'>
> & {
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<string, string | number | boolean | null> | 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<string, string>;
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<string, string | number | boolean | readonly string[]>;
@Injectable({ providedIn: 'root' })
export class HauspilotApiService {
private readonly http = inject(HttpClient);
private readonly base = '/api/projects';
dashboard(id: string) {
return this.http.get<ProjectDashboard>(`${this.base}/${id}/dashboard`);
}
buildings(id: string) {
return this.http.get<Building[]>(`${this.base}/${id}/buildings`);
}
floors(id: string) {
return this.http.get<Floor[]>(`${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<PageResult<Room>>(`${this.base}/${id}/rooms`, {
params: this.params(query),
});
}
tasks(id: string, query: ListQuery = {}) {
return this.http.get<PageResult<RenovationTask>>(`${this.base}/${id}/tasks`, {
params: this.params(query),
});
}
task(id: string, taskId: string) {
return this.http.get<TaskDetail>(`${this.base}/${id}/tasks/${taskId}`);
}
milestones(id: string, query: ListQuery = {}) {
return this.http.get<PageResult<Milestone>>(`${this.base}/${id}/milestones`, {
params: this.params(query),
});
}
budgets(id: string) {
return this.http.get<BudgetCategory[]>(`${this.base}/${id}/budget-categories`);
}
expenses(id: string, query: ListQuery = {}) {
return this.http.get<PageResult<Expense>>(`${this.base}/${id}/expenses`, {
params: this.params(query),
});
}
documents(id: string, query: ListQuery = {}) {
return this.http.get<PageResult<ProjectDocument>>(`${this.base}/${id}/documents`, {
params: this.params(query),
});
}
activities(id: string, query: ListQuery = {}) {
return this.http.get<PageResult<ProjectActivity>>(`${this.base}/${id}/activities`, {
params: this.params(query),
});
}
calendar(id: string, from: string, to: string, query: ListQuery = {}) {
return this.http.get<CalendarEvent[]>(`${this.base}/${id}/calendar`, {
params: this.params({ ...query, from, to }),
});
}
templates() {
return this.http.get<ProjectTemplate[]>('/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<Building>(`${this.base}/${id}/buildings`, body);
}
updateBuilding(id: string, building: Building, body: object) {
return this.http.patch<Building>(`${this.base}/${id}/buildings/${building.id}`, {
...body,
version: building.version,
});
}
createFloor(id: string, body: { buildingId: string; name: string }) {
return this.http.post<Floor>(`${this.base}/${id}/floors`, body);
}
updateFloor(id: string, floor: Floor, body: object) {
return this.http.patch<Floor>(`${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<Room>(`${this.base}/${id}/rooms`, body);
}
updateRoom(id: string, room: Room, body: object) {
return this.http.patch<Room>(`${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<RenovationTask>(`${this.base}/${id}/tasks`, body);
}
updateTask(id: string, task: RenovationTask, patch: TaskPatch) {
return this.http.patch<RenovationTask>(`${this.base}/${id}/tasks/${task.id}`, {
...task,
...patch,
version: task.version,
});
}
addChecklist(id: string, taskId: string, text: string) {
return this.http.post<ChecklistItem>(`${this.base}/${id}/tasks/${taskId}/checklist`, { text });
}
updateChecklist(id: string, taskId: string, itemId: string, body: Partial<ChecklistItem>) {
return this.http.patch<ChecklistItem>(
`${this.base}/${id}/tasks/${taskId}/checklist/${itemId}`,
body,
);
}
addDependency(id: string, taskId: string, predecessorTaskId: string) {
return this.http.post<TaskDependency>(`${this.base}/${id}/tasks/${taskId}/dependencies`, {
predecessorTaskId,
});
}
removeDependency(id: string, taskId: string, dependencyId: string) {
return this.http.delete<void>(
`${this.base}/${id}/tasks/${taskId}/dependencies/${dependencyId}`,
);
}
addComment(id: string, taskId: string, text: string, mentionedUserIds: string[]) {
return this.http.post<TaskComment>(`${this.base}/${id}/tasks/${taskId}/comments`, {
text,
mentionedUserIds,
});
}
createBudget(id: string, body: { name: string; plannedBudget: number }) {
return this.http.post<BudgetCategory>(`${this.base}/${id}/budget-categories`, body);
}
updateBudget(id: string, category: BudgetCategory, body: object) {
return this.http.patch<BudgetCategory>(`${this.base}/${id}/budget-categories/${category.id}`, {
...body,
version: category.version,
});
}
createMilestone(id: string, body: object) {
return this.http.post<Milestone>(`${this.base}/${id}/milestones`, body);
}
updateMilestone(id: string, milestone: Milestone, body: object) {
return this.http.patch<Milestone>(`${this.base}/${id}/milestones/${milestone.id}`, {
...body,
version: milestone.version,
});
}
createExpense(id: string, body: object) {
return this.http.post<Expense>(`${this.base}/${id}/expenses`, body);
}
updateExpense(id: string, expense: Expense, body: object) {
return this.http.patch<Expense>(`${this.base}/${id}/expenses/${expense.id}`, {
...body,
version: expense.version,
});
}
upload(id: string, data: FormData) {
return this.http.post<ProjectDocument>(`${this.base}/${id}/documents`, data);
}
updateDocument(id: string, document: ProjectDocument, body: object) {
return this.http.patch<ProjectDocument>(`${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<PageResult<FurnitureRequirement>>(
`${this.base}/${id}/furniture-requirements`,
{ params: this.params(query) },
);
}
furnitureRequirement(id: string, requirementId: string) {
return this.http.get<FurnitureRequirement>(
`${this.base}/${id}/furniture-requirements/${requirementId}`,
);
}
createFurnitureRequirement(id: string, body: object) {
return this.http.post<FurnitureRequirement>(`${this.base}/${id}/furniture-requirements`, body);
}
updateFurnitureRequirement(id: string, requirement: FurnitureRequirement, body: object) {
return this.http.patch<FurnitureRequirement>(
`${this.base}/${id}/furniture-requirements/${requirement.id}`,
{ ...body, version: requirement.version },
);
}
furnitureOptions(id: string, requirementId: string) {
return this.http.get<FurnitureOption[]>(
`${this.base}/${id}/furniture-requirements/${requirementId}/options`,
);
}
furnitureProjectOptions(id: string, query: ListQuery = {}) {
return this.http.get<PageResult<FurnitureOption>>(`${this.base}/${id}/furniture-options`, {
params: this.params(query),
});
}
createFurnitureOption(id: string, requirementId: string, body: object) {
return this.http.post<FurnitureOption>(
`${this.base}/${id}/furniture-requirements/${requirementId}/options`,
body,
);
}
updateFurnitureOption(id: string, option: FurnitureOption, body: object) {
return this.http.patch<FurnitureOption>(`${this.base}/${id}/furniture-options/${option.id}`, {
...body,
version: option.version,
});
}
favoriteFurnitureOption(id: string, optionId: string) {
return this.http.post<FurnitureOption>(
`${this.base}/${id}/furniture-options/${optionId}/favorite`,
{},
);
}
selectFurnitureOption(id: string, option: FurnitureOption) {
return this.http.post<FurnitureOption>(
`${this.base}/${id}/furniture-options/${option.id}/select`,
{ version: option.version },
);
}
orderFurnitureOption(id: string, option: FurnitureOption, body: object) {
return this.http.post<FurnitureOption>(
`${this.base}/${id}/furniture-options/${option.id}/order`,
{ ...body, version: option.version },
);
}
deliverFurnitureOption(id: string, option: FurnitureOption, deliveredQuantity: number) {
return this.http.post<FurnitureOption>(
`${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<FurnitureSummary>(url);
}
furnitureScenarios(id: string) {
return this.http.get<FurnitureScenario[]>(`${this.base}/${id}/furniture-scenarios`);
}
createFurnitureScenario(id: string, body: object) {
return this.http.post<FurnitureScenario>(`${this.base}/${id}/furniture-scenarios`, body);
}
updateFurnitureScenarioSelections(
id: string,
scenario: FurnitureScenario,
selections: Array<{ requirementId: string; optionId: string; quantity: number }>,
) {
return this.http.put<FurnitureScenario>(
`${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<string, string> }>;
}>(`${this.base}/${id}/furniture-scenarios/compare`, { params: this.params({ ids }) });
}
}

View File

@@ -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: `
<ui-page-header
title="Projekteinladungen"
description="Einladungen werden nie automatisch angenommen."
/>
@if (loading()) {
<p>Einladung wird geprueft ...</p>
} @else if (error()) {
<section class="ui-card status-card" role="alert">
<h2>Einladung nicht verfuegbar</h2>
<p>{{ error() }}</p>
<p>Bitten Sie den Projekteigentuemer gegebenenfalls um eine neue Einladung.</p>
</section>
} @else if (tokenInvitation(); as invitation) {
<section class="ui-card invitation-card">
@if (invitation.reason === 'email_mismatch') {
<h2>Andere E-Mail-Adresse erforderlich</h2>
<p>
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.
</p>
<p>Eingeladene Adresse: {{ invitation.invitedEmailMasked }}</p>
<a class="ui-button ui-button--ghost" href="/api/auth/logout"
>Abmelden und Benutzer wechseln</a
>
} @else if (invitation.reason === 'email_unverified') {
<h2>E-Mail-Adresse noch nicht verifiziert</h2>
<p>
Verifizieren Sie die Adresse beim bestehenden Identity Provider und melden Sie sich
erneut an.
</p>
<a class="ui-button ui-button--ghost" href="/api/auth/logout">Erneut anmelden</a>
} @else if (invitation.status !== 'pending') {
<h2>Einladung {{ statusLabel(invitation.status) }}</h2>
<p>Diese Einladung kann nicht mehr verwendet werden.</p>
} @else {
<h2>{{ invitation.projectName }}</h2>
<p>
Vorgesehene Rolle: <strong>{{ roleLabel(invitation.role) }}</strong>
</p>
<p>{{ roleDescription(invitation.role) }}</p>
<div class="actions">
<button class="ui-button ui-button--primary" type="button" (click)="acceptToken()">
Einladung annehmen
</button>
<button class="ui-button ui-button--ghost" type="button" (click)="declineToken()">
Ablehnen
</button>
</div>
}
</section>
} @else if (pending().length === 0) {
<ui-empty-state
title="Keine offenen Einladungen"
message="Derzeit wartet keine Projekteinladung auf Ihre Entscheidung."
/>
} @else {
<section class="invitation-list" aria-label="Offene Einladungen">
@for (invitation of pending(); track invitation.id) {
<article class="ui-card invitation-card">
<h2>{{ invitation.projectName }}</h2>
<p>
Rolle: <strong>{{ roleLabel(invitation.role) }}</strong>
</p>
@if (invitation.canRespond) {
<div class="actions">
<button
class="ui-button ui-button--primary"
type="button"
(click)="acceptPending(invitation)"
>
Annehmen
</button>
<button
class="ui-button ui-button--ghost"
type="button"
(click)="declinePending(invitation)"
>
Ablehnen
</button>
</div>
} @else {
<p role="alert">Die E-Mail-Adresse muss beim Identity Provider verifiziert sein.</p>
}
</article>
}
</section>
}
`,
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<string | null>(null);
readonly tokenInvitation = signal<ProjectInvitationDto | null>(null);
readonly pending = signal<ProjectInvitationDto[]>([]);
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);
}
}

View File

@@ -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: `
<div class="calendar-toolbar">
<div class="actions">
<button
class="ui-button ui-button--secondary"
type="button"
(click)="move(-1)"
aria-label="Vorheriger Monat"
>
</button
><button class="ui-button ui-button--secondary" type="button" (click)="today()">
Heute</button
><button
class="ui-button ui-button--secondary"
type="button"
(click)="move(1)"
aria-label="Nächster Monat"
>
</button>
</div>
<h2>{{ cursor() | date: 'MMMM yyyy' }}</h2>
<div class="actions">
<button
class="ui-button ui-button--secondary"
type="button"
[attr.aria-pressed]="view() === 'month'"
(click)="view.set('month')"
>
Monat</button
><button
class="ui-button ui-button--secondary"
type="button"
[attr.aria-pressed]="view() === 'agenda'"
(click)="view.set('agenda')"
>
Agenda
</button>
</div>
</div>
<div class="filters">
<label
>Raum<select class="ui-control" [formControl]="roomFilter">
<option value="">Alle</option>
@for (room of rooms(); track room.id) {
<option [value]="room.id">{{ room.name }}</option>
}
</select></label
>
<label
>Verantwortlich<select class="ui-control" [formControl]="assigneeFilter">
<option value="">Alle</option>
@for (member of members(); track member.userId) {
<option [value]="member.userId">{{ member.name }}</option>
}
</select></label
>
<label
>Ereignis<select class="ui-control" [formControl]="typeFilter">
<option value="">Alle</option>
<option value="task_start">Aufgabenstart</option>
<option value="task_due">Aufgabenfälligkeit</option>
<option value="milestone">Meilenstein</option>
<option value="expense_due">Ausgabe fällig</option>
</select></label
>
</div>
@if (loading()) {
<p role="status">Kalender wird geladen …</p>
}
@if (error()) {
<p class="error" role="alert">{{ error() }}</p>
}
@if (view() === 'month') {
<div class="weekdays" aria-hidden="true">
@for (day of weekdays; track day) {
<strong>{{ day }}</strong>
}
</div>
<div class="month-grid">
@for (day of days(); track day.key) {
<section
class="day"
[class.outside]="!day.currentMonth"
[class.today]="day.today"
[attr.aria-label]="day.date | date: 'fullDate'"
>
<time>{{ day.date | date: 'd' }}</time>
@for (event of eventsFor(day.key); track event.id) {
@if (event.type === 'task_start' || event.type === 'task_due') {
<a
class="event"
[class.overdue]="event.overdue"
[class.completed]="event.completed"
[routerLink]="['/projekte', projectId(), 'aufgaben', event.entityId]"
><span aria-hidden="true">{{ icon(event.type) }}</span
><span>{{ typeLabel(event.type) }}: {{ event.title }}</span></a
>
} @else {
<span
class="event"
[class.overdue]="event.overdue"
[class.completed]="event.completed"
><span aria-hidden="true">{{ icon(event.type) }}</span
><span>{{ typeLabel(event.type) }}: {{ event.title }}</span></span
>
}
}
</section>
}
</div>
} @else {
<div class="agenda">
@for (event of filteredEvents(); track event.id) {
<article class="ui-card agenda-row">
<time>{{ event.date | date: 'EEE, dd.MM.yyyy' }}</time
><span aria-hidden="true">{{ icon(event.type) }}</span>
@if (event.type === 'task_start' || event.type === 'task_due') {
<a [routerLink]="['/projekte', projectId(), 'aufgaben', event.entityId]">{{
event.title
}}</a>
} @else {
<strong>{{ event.title }}</strong>
}
<span>{{ typeLabel(event.type) }}</span>
@if (event.overdue) {
<span class="flag">Überfällig</span>
}
</article>
} @empty {
<p>In diesem Zeitraum gibt es keine passenden Termine.</p>
}
</div>
}
`,
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<string>();
private readonly api = inject(HauspilotApiService);
private readonly projectsApi = inject(ApiClientService);
readonly cursor = signal(new Date());
readonly view = signal<'month' | 'agenda'>('month');
readonly events = signal<CalendarEvent[]>([]);
readonly rooms = signal<Room[]>([]);
readonly members = signal<ProjectMemberDto[]>([]);
readonly loading = signal(false);
readonly error = signal<string | null>(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')}`;
}
}

View File

@@ -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: `
<a routerLink="/projekte">Zurueck zu den Projekten</a>
@if (project(); as currentProject) {
<ui-page-header
[title]="currentProject.name"
[description]="currentProject.description || 'Keine Beschreibung'"
/>
<p>
Ihre Rolle: <strong>{{ roleLabel(currentProject.role) }}</strong>
</p>
@if (canManage()) {
<section class="ui-card invite-card" aria-labelledby="invite-title">
<h2 id="invite-title">Projektmitglied einladen</h2>
<form class="ui-form invite-form" [formGroup]="inviteForm" (ngSubmit)="invite()">
<label class="ui-form-field">
<span class="ui-label">E-Mail-Adresse</span>
<input class="ui-control" type="email" autocomplete="email" formControlName="email" />
</label>
<label class="ui-form-field">
<span class="ui-label">Projektrolle</span>
<select class="ui-control" formControlName="role">
@for (role of roles; track role) {
<option [value]="role">{{ roleLabel(role) }}</option>
}
</select>
</label>
<button
class="ui-button ui-button--primary"
type="submit"
[disabled]="inviteForm.invalid || saving()"
>
Einladung erstellen
</button>
</form>
@if (createdInvitation(); as invitation) {
<div class="invitation-result" role="status">
<strong>Einladung gespeichert</strong>
<p>
Es ist kein Mailversand konfiguriert. Geben Sie diesen internen Pfad sicher weiter:
</p>
<code>{{ invitation.invitationPath }}</code>
</div>
}
</section>
}
<section class="ui-card members-card" aria-labelledby="members-title">
<h2 id="members-title">Mitglieder</h2>
<div class="member-list">
@for (member of members(); track member.userId) {
<article class="member-row">
<div>
<strong>{{ member.name }}</strong>
<p>{{ member.email || 'Keine E-Mail-Adresse' }}</p>
</div>
<ui-status-badge
[label]="roleLabel(member.role)"
[tone]="member.active ? 'success' : 'neutral'"
/>
@if (canManage() && member.role !== 'owner') {
<label>
<span class="visually-hidden">Rolle fuer {{ member.name }}</span>
<select
class="ui-control"
[value]="member.role"
(change)="changeRole(member, $event)"
>
@for (role of roles; track role) {
<option [value]="role">{{ roleLabel(role) }}</option>
}
</select>
</label>
<button class="ui-button ui-button--danger" type="button" (click)="remove(member)">
Entfernen
</button>
}
</article>
}
</div>
</section>
} @else if (!error()) {
<p>Projekt wird geladen ...</p>
}
@if (error()) {
<p class="error" role="alert">{{ error() }}</p>
}
`,
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<ProjectDto | null>(null);
readonly members = signal<ProjectMemberDto[]>([]);
readonly saving = signal(false);
readonly error = signal<string | null>(null);
readonly createdInvitation = signal<ProjectInvitationDto | null>(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<ProjectRole>('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.'),
});
}
}

View File

@@ -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]]);
});
});

View File

@@ -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<T extends { status: string; assigneeUserId: string | null }>(
tasks: readonly T[],
status: string,
currentUserId: string | null,
mine: boolean,
): T[] {
return tasks.filter(
(task) =>
(!status || task.status === status) &&
(!mine || (!!currentUserId && task.assigneeUserId === currentUserId)),
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -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: `
<ui-page-header title="Projekte" description="Ihre privaten HausPilot-Projekte" />
<section class="ui-card create-card" aria-labelledby="create-project-title">
<h2 id="create-project-title">Projekt anlegen</h2>
<p>Sie werden serverseitig automatisch als Projekteigentuemer eingetragen.</p>
<form class="ui-form" [formGroup]="form" (ngSubmit)="create()">
<label class="ui-form-field">
<span class="ui-label">Projektname</span>
<input class="ui-control" formControlName="name" maxlength="160" />
</label>
<label class="ui-form-field">
<span class="ui-label">Beschreibung</span>
<textarea class="ui-control" formControlName="description" rows="3"></textarea>
</label>
<button
class="ui-button ui-button--primary"
type="submit"
[disabled]="form.invalid || saving()"
>
{{ saving() ? 'Wird angelegt ...' : 'Projekt anlegen' }}
</button>
</form>
@if (error()) {
<p class="error" role="alert">{{ error() }}</p>
}
</section>
@if (loading()) {
<p>Projekte werden geladen ...</p>
} @else if (projects().length === 0) {
<ui-empty-state title="Noch keine Projekte" message="Legen Sie Ihr erstes Projekt an." />
} @else {
<section class="project-grid" aria-label="Projektliste">
@for (project of projects(); track project.id) {
<a class="ui-card project-card" [routerLink]="['/projekte', project.id, 'uebersicht']">
<h2>{{ project.name }}</h2>
<p>{{ project.description || 'Keine Beschreibung' }}</p>
<span>Rolle: {{ roleLabel(project.role) }}</span>
</a>
}
</section>
}
`,
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<ProjectDto[]>([]);
readonly loading = signal(true);
readonly saving = signal(false);
readonly error = signal<string | null>(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),
});
}
}

View File

@@ -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: `
<a [routerLink]="['/projekte', projectId, 'aufgaben']">Zurück zu den Aufgaben</a>
@if (error()) {
<p class="error" role="alert">{{ error() }}</p>
}
@if (loading()) {
<p role="status">Aufgabe wird geladen …</p>
}
@if (task(); as task) {
<header class="task-header ui-card">
<div>
<p>{{ roomName(task.roomId) }} · {{ task.category }}</p>
<h1>{{ task.title }}</h1>
<div class="badges">
<ui-status-badge
[label]="statusLabel(task.status)"
[tone]="
task.status === 'done'
? 'success'
: task.blockedByDependencies
? 'danger'
: 'neutral'
"
/>
<ui-status-badge
[label]="priorityLabel(task.priority)"
[tone]="task.priority === 'critical' ? 'danger' : 'neutral'"
/>
@if (isOverdue(task)) {
<span class="text-flag">⚠ Überfällig</span>
}
@if (task.blockedByDependencies) {
<span class="text-flag">⛔ Blockiert</span>
}
</div>
</div>
@if (canEdit()) {
<div class="actions">
<button
class="ui-button ui-button--secondary"
type="button"
(click)="editing.set(!editing())"
>
{{ editing() ? 'Abbrechen' : 'Bearbeiten' }}
</button>
@if (task.status !== 'done') {
<button
class="ui-button ui-button--primary"
type="button"
[disabled]="saving()"
(click)="quickComplete()"
>
Als erledigt markieren
</button>
}
</div>
}
</header>
@if (editing()) {
<form class="ui-card form-grid" [formGroup]="form" (ngSubmit)="saveTask()">
<label><span>Titel *</span><input class="ui-control" formControlName="title" /></label>
<label class="wide"
><span>Beschreibung</span
><textarea class="ui-control" rows="5" formControlName="description"></textarea>
</label>
<label
><span>Status</span
><select class="ui-control" formControlName="status">
@for (option of statuses; track option[0]) {
<option [value]="option[0]">{{ option[1] }}</option>
}
</select></label
>
<label
><span>Priorität</span
><select class="ui-control" formControlName="priority">
<option value="low">Niedrig</option>
<option value="normal">Normal</option>
<option value="high">Hoch</option>
<option value="critical">Kritisch</option>
</select></label
>
<label
><span>Kategorie</span><input class="ui-control" formControlName="category"
/></label>
<label
><span>Raum</span
><select class="ui-control" formControlName="roomId">
<option value="">Allgemein</option>
@for (room of rooms(); track room.id) {
<option [value]="room.id">{{ room.name }}</option>
}
</select></label
>
<label
><span>Verantwortlich</span
><select class="ui-control" formControlName="assigneeUserId">
<option value="">Nicht zugewiesen</option>
@for (member of assignableMembers(); track member.userId) {
<option [value]="member.userId">{{ member.name }}</option>
}
</select></label
>
<label
><span>Geplanter Start</span
><input class="ui-control" type="date" formControlName="plannedStartDate"
/></label>
<label
><span>Fällig</span><input class="ui-control" type="date" formControlName="dueDate"
/></label>
<label
><span>Aufwand (Stunden)</span
><input
class="ui-control"
type="number"
min="0"
step="0.25"
formControlName="estimatedEffortHours"
/></label>
<label
><span>Geschätzte Kosten</span
><input
class="ui-control"
type="number"
min="0"
step="0.01"
formControlName="estimatedCost"
/></label>
<label
><span>Tatsächliche Kosten</span
><input
class="ui-control"
type="number"
min="0"
step="0.01"
formControlName="actualCost"
/></label>
<label
><span>Fortschrittsgewicht</span
><input class="ui-control" type="number" min="0.01" step="0.1" formControlName="weight"
/></label>
<label class="wide"
><span>Blockierungsgrund</span
><textarea class="ui-control" formControlName="blockingReason"></textarea>
</label>
<div class="wide actions">
<button class="ui-button ui-button--primary" [disabled]="form.invalid || saving()">
{{ saving() ? 'Speichert …' : 'Änderungen speichern' }}
</button>
</div>
</form>
} @else {
<section class="ui-card detail-grid">
<div class="wide">
<h2>Beschreibung</h2>
<p class="prewrap">{{ task.description || 'Keine Beschreibung hinterlegt.' }}</p>
</div>
<div>
<span class="label">Verantwortlich</span
><strong>{{ task.assigneeName || 'Nicht zugewiesen' }}</strong>
</div>
<div>
<span class="label">Start</span
><strong>{{
task.plannedStartDate ? (task.plannedStartDate | date: 'dd.MM.yyyy') : ''
}}</strong>
</div>
<div>
<span class="label">Fälligkeit</span
><strong>{{ task.dueDate ? (task.dueDate | date: 'dd.MM.yyyy') : '' }}</strong>
</div>
<div>
<span class="label">Abgeschlossen</span
><strong>{{
task.actualCompletionDate ? (task.actualCompletionDate | date: 'dd.MM.yyyy') : ''
}}</strong>
</div>
<div>
<span class="label">Aufwand</span
><strong>{{ task.estimatedEffortHours || '' }} h</strong>
</div>
<div>
<span class="label">Kosten</span
><strong>{{ +(task.actualCost || task.estimatedCost || 0) | currency: 'EUR' }}</strong>
</div>
@if (task.blockingReason) {
<div class="wide">
<span class="label">Blockierungsgrund</span><strong>{{ task.blockingReason }}</strong>
</div>
}
</section>
}
<div class="columns">
<section class="ui-card">
<h2>Checkliste</h2>
<progress max="100" [value]="checklistProgress()">{{ checklistProgress() }} %</progress>
<ul class="plain-list">
@for (item of task.checklist; track item.id) {
<li>
<label
><input
type="checkbox"
[checked]="item.completed"
[disabled]="!canEdit() || saving()"
(change)="toggleChecklist(item.id, !item.completed)"
/>
<span [class.completed]="item.completed">{{ item.text }}</span></label
>
</li>
}
</ul>
@if (canEdit()) {
<form class="inline-form" (ngSubmit)="addChecklist()">
<input
class="ui-control"
[formControl]="checklistText"
placeholder="Neuer Eintrag"
aria-label="Neuer Checklisteneintrag"
/><button
class="ui-button ui-button--secondary"
[disabled]="checklistText.invalid || saving()"
>
Hinzufügen
</button>
</form>
}
</section>
<section class="ui-card">
<h2>Abhängigkeiten</h2>
<h3>Vorgänger</h3>
@for (dep of predecessors(); track dep.id) {
<div class="dependency">
<a [routerLink]="['/projekte', projectId, 'aufgaben', dep.predecessorTaskId]">{{
dep.predecessor?.title || dep.predecessorTaskId
}}</a
><span>{{ statusLabel(dep.predecessor?.status || '') }}</span>
@if (canEdit()) {
<button type="button" class="link-button" (click)="removeDependency(dep.id)">
Entfernen
</button>
}
</div>
} @empty {
<p>Keine Vorgänger.</p>
}
<h3>Nachfolger</h3>
@for (dep of successors(); track dep.id) {
<div class="dependency">
<a [routerLink]="['/projekte', projectId, 'aufgaben', dep.successorTaskId]">{{
dep.successor?.title || dep.successorTaskId
}}</a
><span>{{ statusLabel(dep.successor?.status || '') }}</span>
</div>
} @empty {
<p>Keine Nachfolger.</p>
}
@if (canEdit()) {
<form class="inline-form" (ngSubmit)="addDependency()">
<select class="ui-control" [formControl]="predecessorId">
<option value="">Vorgänger auswählen</option>
@for (candidate of dependencyCandidates(); track candidate.id) {
<option [value]="candidate.id">{{ candidate.title }}</option>
}</select
><button
class="ui-button ui-button--secondary"
[disabled]="!predecessorId.value || saving()"
>
Verknüpfen
</button>
</form>
}
</section>
</div>
<section class="ui-card" id="comments">
<h2>Kommentare</h2>
<div class="comments">
@for (comment of task.comments; track comment.id) {
<article [attr.id]="'comment-' + comment.id">
<div>
<strong>{{ comment.authorName }}</strong>
<time>{{ comment.createdAt | date: 'dd.MM.yyyy, HH:mm' }}</time>
</div>
<p class="prewrap">{{ comment.text }}</p>
</article>
} @empty {
<ui-empty-state
title="Noch keine Kommentare"
description="Halten Sie Entscheidungen und Rückfragen direkt an der Aufgabe fest."
/>
}
</div>
@if (canEdit()) {
<form class="comment-form" (ngSubmit)="addComment()">
<label
><span>Kommentar</span
><textarea
class="ui-control"
rows="4"
[formControl]="commentText"
placeholder="Mit @ ein Projektmitglied erwähnen"
></textarea>
</label>
<div>
<span class="label">Erwähnen</span>
<div class="mention-list">
@for (member of members(); track member.userId) {
<button
type="button"
class="mention"
[class.selected]="selectedMentions().includes(member.userId)"
(click)="toggleMention(member)"
>
&#64;{{ member.name }}
</button>
}
</div>
</div>
<button
class="ui-button ui-button--primary"
[disabled]="commentText.invalid || saving()"
>
Kommentar senden
</button>
</form>
}
</section>
<div class="columns">
<section class="ui-card">
<h2>Dokumente</h2>
@for (document of task.documents; track document.id) {
<div class="dependency">
<span>{{ document.title }}</span
><a [href]="api.downloadUrl(projectId, document.id)">Herunterladen</a>
</div>
} @empty {
<p>Noch keine Dokumente zugeordnet.</p>
}
</section>
<section class="ui-card">
<h2>Aktivitäten</h2>
@for (activity of task.activities; track activity.id) {
<div class="activity">
<strong>{{ activity.actorName }}</strong
><span>{{ activity.action }}</span
><time>{{ activity.createdAt | date: 'dd.MM.yyyy, HH:mm' }}</time>
</div>
} @empty {
<p>Noch keine aufgabenbezogenen Aktivitäten.</p>
}
</section>
</div>
}
`,
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<TaskDetail | null>(null);
readonly rooms = signal<Room[]>([]);
readonly allTasks = signal<RenovationTask[]>([]);
readonly members = signal<ProjectMemberDto[]>([]);
readonly loading = signal(true);
readonly saving = signal(false);
readonly editing = signal(false);
readonly error = signal<string | null>(null);
readonly selectedMentions = signal<string[]>([]);
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<number | null>(null),
estimatedCost: new FormControl<number | null>(null),
actualCost: new FormControl<number | null>(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<T>(request: Observable<T>, 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);
}
}

View File

@@ -30,7 +30,7 @@ interface NavItem {
@if (auth.user()) {
<ui-icon-button icon="menu" label="Navigation" (pressed)="toggleDrawer()" />
}
<strong class="brand">Business App</strong>
<strong class="brand">HausPilot</strong>
@if (auth.user()) {
<button
class="ui-icon-button notification-button"
@@ -47,7 +47,7 @@ interface NavItem {
</button>
<a class="ui-button ui-button--ghost logout" href="/api/auth/logout">Abmelden</a>
} @else {
<a class="ui-button ui-button--primary logout" href="/api/auth/login">Anmelden</a>
<a class="ui-button ui-button--primary logout" [href]="loginHref()">Anmelden</a>
}
@if (auth.user() && notificationPanelOpen()) {
<app-notification-panel
@@ -93,7 +93,7 @@ interface NavItem {
<section class="login-panel">
<h1>Anmelden</h1>
<p>Bitte melden Sie sich ueber den zentralen Identity Provider an.</p>
<a class="ui-button ui-button--primary ui-button--mobile-full" href="/api/auth/login"
<a class="ui-button ui-button--primary ui-button--mobile-full" [href]="loginHref()"
>Mit OIDC anmelden</a
>
</section>
@@ -243,6 +243,8 @@ export class AppShellComponent {
);
readonly nav: NavItem[] = [
{ label: 'Dashboard', path: '/' },
{ label: 'Projekte', path: '/projekte', permission: 'projects.use' },
{ label: 'Einladungen', path: '/einladungen', permission: 'projects.use' },
{ label: 'Profil', path: '/profil' },
{ label: 'Sicherheit', path: '/account/security' },
{
@@ -273,6 +275,11 @@ export class AppShellComponent {
return !item.permission || this.auth.has(item.permission);
}
loginHref(): string {
const returnTo = this.router.url.startsWith('/einladungen') ? this.router.url : '/';
return `/api/auth/login?returnTo=${encodeURIComponent(returnTo)}`;
}
toggleNotifications(): void {
this.notificationPanelOpen.update((open) => !open);
if (this.notificationPanelOpen()) {