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

@@ -31,6 +31,11 @@ CSRF_HEADER_NAME=X-CSRF-Token
LOG_LEVEL=info LOG_LEVEL=info
SWAGGER_ENABLED=true SWAGGER_ENABLED=true
DOCUMENT_STORAGE_PATH=storage/documents
DOCUMENT_MAX_FILE_SIZE_BYTES=10485760
REMINDER_INTERVAL_MS=900000
REMINDER_DUE_SOON_DAYS=3
RATE_LIMIT_WINDOW_SECONDS=60 RATE_LIMIT_WINDOW_SECONDS=60
RATE_LIMIT_MAX_REQUESTS=300 RATE_LIMIT_MAX_REQUESTS=300
RATE_LIMIT_SENSITIVE_WINDOW_SECONDS=60 RATE_LIMIT_SENSITIVE_WINDOW_SECONDS=60

View File

@@ -2,5 +2,6 @@
"singleQuote": true, "singleQuote": true,
"semi": true, "semi": true,
"printWidth": 100, "printWidth": 100,
"trailingComma": "all" "trailingComma": "all",
"endOfLine": "auto"
} }

View File

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

View File

@@ -11,14 +11,16 @@
"test": "vitest run --config vitest.config.ts", "test": "vitest run --config vitest.config.ts",
"migration:status": "typeorm-ts-node-commonjs -d src/database/typeorm-cli.datasource.ts migration:show", "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: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": { "dependencies": {
"@nestjs/common": "11.1.28", "@nestjs/common": "11.1.28",
"@nestjs/config": "4.0.4", "@nestjs/config": "4.0.4",
"@nestjs/core": "11.1.28", "@nestjs/core": "11.1.28",
"@nestjs/platform-express": "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/throttler": "6.4.0",
"@nestjs/typeorm": "11.0.0", "@nestjs/typeorm": "11.0.0",
"class-transformer": "0.5.1", "class-transformer": "0.5.1",
@@ -34,10 +36,11 @@
"pino-pretty": "13.1.2", "pino-pretty": "13.1.2",
"reflect-metadata": "0.2.2", "reflect-metadata": "0.2.2",
"rxjs": "7.8.2", "rxjs": "7.8.2",
"typeorm": "0.3.27", "typeorm": "^0.3.31",
"zod": "4.1.13" "zod": "4.1.13"
}, },
"devDependencies": { "devDependencies": {
"@types/multer": "^2.0.0",
"typeorm-ts-node-commonjs": "0.3.20" "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 { HealthModule } from './health/health.module';
import { ItemsModule } from './items/items.module'; import { ItemsModule } from './items/items.module';
import { NotificationsModule } from './notifications/notifications.module'; import { NotificationsModule } from './notifications/notifications.module';
import { ProjectsModule } from './projects/projects.module';
import { RolesModule } from './roles/roles.module'; import { RolesModule } from './roles/roles.module';
import { RenovationModule } from './renovation/renovation.module';
import { SessionsModule } from './sessions/sessions.module'; import { SessionsModule } from './sessions/sessions.module';
import { UsersModule } from './users/users.module'; import { UsersModule } from './users/users.module';
@@ -83,6 +85,8 @@ const generateGlobalKey: ThrottlerGenerateKeyFunction = (
RolesModule, RolesModule,
SessionsModule, SessionsModule,
NotificationsModule, NotificationsModule,
ProjectsModule,
RenovationModule,
AuditModule, AuditModule,
ItemsModule, ItemsModule,
HealthModule, HealthModule,

View File

@@ -17,8 +17,8 @@ export class AuthController {
@Public() @Public()
@SensitiveRateLimit() @SensitiveRateLimit()
@Redirect() @Redirect()
async login() { async login(@Query('returnTo') returnTo?: string) {
return { url: await this.auth.createLoginUrl() }; return { url: await this.auth.createLoginUrl(returnTo) };
} }
@Get('callback') @Get('callback')
@@ -30,7 +30,7 @@ export class AuthController {
@Req() req: AuthenticatedRequest, @Req() req: AuthenticatedRequest,
@Res() res: Response, @Res() res: Response,
) { ) {
const { session, csrfToken } = await this.auth.completeLogin( const { session, csrfToken, returnPath } = await this.auth.completeLogin(
code, code,
state, state,
req.get('user-agent'), req.get('user-agent'),
@@ -51,7 +51,9 @@ export class AuthController {
path: '/', path: '/',
expires: session.absoluteExpiresAt, expires: session.absoluteExpiresAt,
}); });
res.redirect(this.config.frontendBaseUrl); res.redirect(
new URL(returnPath ?? '/', this.config.frontendBaseUrl).toString(),
);
} }
@Get('logout') @Get('logout')

View File

@@ -9,6 +9,50 @@ import type { OidcLoginStateEntity } from './entities/oidc-login-state.entity';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
describe('AuthService', () => { 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 () => { it('revokes the local session and redirects to the OIDC logout endpoint', async () => {
const revoke = vi.fn<() => Promise<number>>(() => Promise.resolve(1)); const revoke = vi.fn<() => Promise<number>>(() => Promise.resolve(1));
const getIdTokenForLogout = vi.fn<() => Promise<string | undefined>>(() => const getIdTokenForLogout = vi.fn<() => Promise<string | undefined>>(() =>

View File

@@ -31,7 +31,7 @@ export class AuthService {
private readonly loginStates: Repository<OidcLoginStateEntity>, private readonly loginStates: Repository<OidcLoginStateEntity>,
) {} ) {}
async createLoginUrl(): Promise<string> { async createLoginUrl(returnTo?: string): Promise<string> {
const discovery = await this.discovery(); const discovery = await this.discovery();
const state = randomBytes(32).toString('hex'); const state = randomBytes(32).toString('hex');
const nonce = randomBytes(32).toString('base64url'); const nonce = randomBytes(32).toString('base64url');
@@ -45,6 +45,7 @@ export class AuthService {
loginState.state = state; loginState.state = state;
loginState.codeVerifier = codeVerifier; loginState.codeVerifier = codeVerifier;
loginState.nonce = nonce; loginState.nonce = nonce;
loginState.returnPath = this.safeReturnPath(returnTo);
loginState.expiresAt = new Date(Date.now() + 10 * 60 * 1000); loginState.expiresAt = new Date(Date.now() + 10 * 60 * 1000);
await this.loginStates.save(loginState); await this.loginStates.save(loginState);
@@ -96,7 +97,7 @@ export class AuthService {
); );
} }
return this.sessions.createSession( const result = await this.sessions.createSession(
user, user,
{ {
accessToken: tokens.access_token, accessToken: tokens.access_token,
@@ -109,6 +110,7 @@ export class AuthService {
userAgent, userAgent,
ip, ip,
); );
return { ...result, returnPath: loginState.returnPath };
} }
async logout(sessionId: string | undefined): Promise<string> { async logout(sessionId: string | undefined): Promise<string> {
@@ -146,6 +148,10 @@ export class AuthService {
} }
user.name = profile.name ?? profile.email ?? profile.sub; user.name = profile.name ?? profile.email ?? profile.sub;
user.email = profile.email ?? null; user.email = profile.email ?? null;
user.emailVerified =
typeof profile.email_verified === 'boolean'
? profile.email_verified
: null;
user.lastLoginAt = new Date(); user.lastLoginAt = new Date();
const savedUser = await manager.getRepository(UserEntity).save(user); const savedUser = await manager.getRepository(UserEntity).save(user);
savedUser.settings = await this.ensureUserSettings(manager, savedUser); savedUser.settings = await this.ensureUserSettings(manager, savedUser);
@@ -296,6 +302,9 @@ export class AuthService {
if (typeof payload['email'] === 'string') { if (typeof payload['email'] === 'string') {
fallback.email = payload['email']; fallback.email = payload['email'];
} }
if (typeof payload['email_verified'] === 'boolean') {
fallback.email_verified = payload['email_verified'];
}
return fallback; return fallback;
} }
const userInfo = await this.http.requestJson<OidcUserInfo>( const userInfo = await this.http.requestJson<OidcUserInfo>(
@@ -311,6 +320,21 @@ export class AuthService {
401, 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; return userInfo;
} }
@@ -325,4 +349,18 @@ export class AuthService {
private get callbackUrl(): string { private get callbackUrl(): string {
return new URL('/api/auth/callback', this.config.appBaseUrl).toString(); 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 }) @Column({ type: 'varchar', length: 160 })
nonce!: string; nonce!: string;
@Column({ name: 'return_path', type: 'varchar', length: 500, nullable: true })
returnPath!: string | null;
@Column({ name: 'expires_at', type: 'datetime', precision: 3 }) @Column({ name: 'expires_at', type: 'datetime', precision: 3 })
expiresAt!: Date; expiresAt!: Date;

View File

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

View File

@@ -46,9 +46,18 @@ export class ApiExceptionFilter implements ExceptionFilter {
? message ? message
: 'Die Anfrage konnte nicht verarbeitet werden.'; : 'Die Anfrage konnte nicht verarbeitet werden.';
} else if (exception instanceof QueryFailedError) { } else if (exception instanceof QueryFailedError) {
status = HttpStatus.CONFLICT; const driverCode = this.driverCode(exception.driverError as unknown);
code = ErrorCode.Conflict; if (
message = 'Die Aenderung steht im Konflikt mit bestehenden Daten.'; [
'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) { if (status >= 500) {
@@ -63,4 +72,13 @@ export class ApiExceptionFilter implements ExceptionFilter {
...(validation ? { validation } : {}), ...(validation ? { validation } : {}),
} satisfies ApiErrorBody); } 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', SystemRoleProtected = 'SYSTEM_ROLE_PROTECTED',
UnknownPermission = 'UNKNOWN_PERMISSION', UnknownPermission = 'UNKNOWN_PERMISSION',
SessionNotFound = 'SESSION_NOT_FOUND', 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', InternalError = 'INTERNAL_ERROR',
} }

View File

@@ -26,6 +26,8 @@ export class AppConfigService {
swaggerEnabled: this.nestConfig.getOrThrow('swaggerEnabled', { swaggerEnabled: this.nestConfig.getOrThrow('swaggerEnabled', {
infer: true, infer: true,
}), }),
documents: this.nestConfig.getOrThrow('documents', { infer: true }),
reminders: this.nestConfig.getOrThrow('reminders', { infer: true }),
rateLimit: this.nestConfig.getOrThrow('rateLimit', { infer: true }), rateLimit: this.nestConfig.getOrThrow('rateLimit', { infer: true }),
}; };
} }
@@ -82,6 +84,14 @@ export class AppConfigService {
return this.config.swaggerEnabled; return this.config.swaggerEnabled;
} }
get documents(): AppConfig['documents'] {
return this.config.documents;
}
get reminders(): AppConfig['reminders'] {
return this.config.reminders;
}
get rateLimit(): AppConfig['rateLimit'] { get rateLimit(): AppConfig['rateLimit'] {
return this.config.rateLimit; return this.config.rateLimit;
} }

View File

@@ -39,6 +39,14 @@ export interface AppConfig {
csrfHeaderName: string; csrfHeaderName: string;
logLevel: string; logLevel: string;
swaggerEnabled: boolean; swaggerEnabled: boolean;
documents: {
storagePath: string;
maxFileSizeBytes: number;
};
reminders: {
intervalMs: number;
dueSoonDays: number;
};
rateLimit: { rateLimit: {
global: RateLimitRuleConfig; global: RateLimitRuleConfig;
sensitive: RateLimitRuleConfig; sensitive: RateLimitRuleConfig;

View File

@@ -71,6 +71,15 @@ const envSchema = z.object({
CSRF_HEADER_NAME: z.string().min(1).default('X-CSRF-Token'), CSRF_HEADER_NAME: z.string().min(1).default('X-CSRF-Token'),
LOG_LEVEL: z.string().min(1).default('info'), LOG_LEVEL: z.string().min(1).default('info'),
SWAGGER_ENABLED: booleanFromString.default(false), 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_WINDOW_SECONDS: z.coerce.number().int().min(1).default(60),
RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().min(1).default(300), RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().min(1).default(300),
RATE_LIMIT_SENSITIVE_WINDOW_SECONDS: z.coerce RATE_LIMIT_SENSITIVE_WINDOW_SECONDS: z.coerce
@@ -138,6 +147,14 @@ export function loadConfigFromEnv(env: Record<string, unknown>): AppConfig {
csrfHeaderName: value.CSRF_HEADER_NAME, csrfHeaderName: value.CSRF_HEADER_NAME,
logLevel: value.LOG_LEVEL, logLevel: value.LOG_LEVEL,
swaggerEnabled: value.SWAGGER_ENABLED, 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: { rateLimit: {
global: { global: {
windowSeconds: value.RATE_LIMIT_WINDOW_SECONDS, 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 { OidcLoginStateEntity } from '../auth/entities/oidc-login-state.entity';
import { ItemEntity } from '../items/entities/item.entity'; import { ItemEntity } from '../items/entities/item.entity';
import { NotificationEntity } from '../notifications/entities/notification.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 { RoleEntity } from '../roles/entities/role.entity';
import { PermissionEntity } from '../roles/entities/permission.entity'; import { PermissionEntity } from '../roles/entities/permission.entity';
import { SessionEntity } from '../sessions/entities/session.entity'; import { SessionEntity } from '../sessions/entities/session.entity';
import { UserSettingsEntity } from '../users/entities/user-settings.entity'; import { UserSettingsEntity } from '../users/entities/user-settings.entity';
import { UserEntity } from '../users/entities/user.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 = [ export const entities = [
AuditLogEntity, AuditLogEntity,
OidcLoginStateEntity, OidcLoginStateEntity,
ItemEntity, ItemEntity,
NotificationEntity, NotificationEntity,
ProjectEntity,
ProjectMembershipEntity,
ProjectInvitationEntity,
ProjectActivityEntity,
RoleEntity, RoleEntity,
PermissionEntity, PermissionEntity,
SessionEntity, SessionEntity,
UserSettingsEntity, UserSettingsEntity,
UserEntity, 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'; name = 'AddRoleDescription1720000002000';
async up(queryRunner: QueryRunner): Promise<void> { async up(queryRunner: QueryRunner): Promise<void> {
if (await queryRunner.hasColumn('roles', 'description')) {
return;
}
await queryRunner.query(` await queryRunner.query(`
ALTER TABLE roles ALTER TABLE roles
ADD description varchar(255) NOT NULL DEFAULT '' ADD description varchar(255) NOT NULL DEFAULT ''
`); `);
} }
async down(queryRunner: QueryRunner): Promise<void> { async down(): Promise<void> {
await queryRunner.query('ALTER TABLE roles DROP COLUMN description'); // 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 { InitialSchema1720000000000 } from './migrations/1720000000000-InitialSchema';
import { AddNotifications1720000001000 } from './migrations/1720000001000-AddNotifications'; import { AddNotifications1720000001000 } from './migrations/1720000001000-AddNotifications';
import { AddRoleDescription1720000002000 } from './migrations/1720000002000-AddRoleDescription'; 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(); const config = loadConfigForCli();
@@ -25,5 +30,10 @@ export default new DataSource({
InitialSchema1720000000000, InitialSchema1720000000000,
AddNotifications1720000001000, AddNotifications1720000001000,
AddRoleDescription1720000002000, AddRoleDescription1720000002000,
AddHausPilotProjects1720000003000,
AddRenovationDomain1720000004000,
AddMentionsAndReminders1720000005000,
AddDefaultProjectFloors1720000006000,
AddFurniturePlanning1720000007000,
], ],
}); });

View File

@@ -4,6 +4,11 @@ import { entities } from './entities';
import { InitialSchema1720000000000 } from './migrations/1720000000000-InitialSchema'; import { InitialSchema1720000000000 } from './migrations/1720000000000-InitialSchema';
import { AddNotifications1720000001000 } from './migrations/1720000001000-AddNotifications'; import { AddNotifications1720000001000 } from './migrations/1720000001000-AddNotifications';
import { AddRoleDescription1720000002000 } from './migrations/1720000002000-AddRoleDescription'; 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( export function typeOrmOptionsFactory(
config: AppConfigService, config: AppConfigService,
@@ -25,6 +30,11 @@ export function typeOrmOptionsFactory(
InitialSchema1720000000000, InitialSchema1720000000000,
AddNotifications1720000001000, AddNotifications1720000001000,
AddRoleDescription1720000002000, AddRoleDescription1720000002000,
AddHausPilotProjects1720000003000,
AddRenovationDomain1720000004000,
AddMentionsAndReminders1720000005000,
AddDefaultProjectFloors1720000006000,
AddFurniturePlanning1720000007000,
], ],
}; };
} }

View File

@@ -3,6 +3,20 @@ export const NotificationType = {
ItemCreated: 'item.created', ItemCreated: 'item.created',
ItemUpdated: 'item.updated', ItemUpdated: 'item.updated',
UserRoleChanged: 'user.role-changed', 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; } as const;
export type NotificationType = 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', NotificationsReadOwn = 'notifications.readOwn',
NotificationsUpdateOwn = 'notifications.updateOwn', NotificationsUpdateOwn = 'notifications.updateOwn',
NotificationsManage = 'notifications.manage', NotificationsManage = 'notifications.manage',
ProjectsUse = 'projects.use',
} }
export const allPermissions = Object.values(Permission); export const allPermissions = Object.values(Permission);

View File

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

View File

@@ -34,6 +34,9 @@ export class UserEntity {
@Column({ type: 'varchar', length: 320, nullable: true }) @Column({ type: 'varchar', length: 320, nullable: true })
email!: string | null; email!: string | null;
@Column({ name: 'email_verified', type: 'boolean', nullable: true })
emailVerified!: boolean | null;
@Column({ type: 'boolean', default: true }) @Column({ type: 'boolean', default: true })
active!: boolean; active!: boolean;

View File

@@ -23,6 +23,19 @@ export class UsersRepository {
return this.repo.findOne({ where: { issuer, subject } }); 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( async search(
query: string | undefined, query: string | undefined,
page: number, page: number,

View File

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

View File

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

View File

@@ -18,6 +18,8 @@
"@angular/platform-browser-dynamic": "22.0.6", "@angular/platform-browser-dynamic": "22.0.6",
"@angular/router": "22.0.6", "@angular/router": "22.0.6",
"@boilerplate/api-client": "1.0.0", "@boilerplate/api-client": "1.0.0",
"ag-grid-angular": "^36.0.1",
"ag-grid-community": "^36.0.1",
"rxjs": "7.8.2", "rxjs": "7.8.2",
"tslib": "2.8.1" "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 type { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http'; 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 { provideRouter, withComponentInputBinding } from '@angular/router';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { csrfInterceptor } from './core/csrf.interceptor'; import { csrfInterceptor } from './core/csrf.interceptor';
import { sessionExpiryInterceptor } from './core/session-expiry.interceptor';
import { titleStrategyProvider } from './core/title.strategy'; import { titleStrategyProvider } from './core/title.strategy';
registerLocaleData(localeDe);
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
provideBrowserGlobalErrorListeners(), provideBrowserGlobalErrorListeners(),
provideHttpClient(withInterceptors([csrfInterceptor])), { provide: LOCALE_ID, useValue: 'de-DE' },
provideHttpClient(withInterceptors([csrfInterceptor, sessionExpiryInterceptor])),
provideRouter(routes, withComponentInputBinding()), provideRouter(routes, withComponentInputBinding()),
titleStrategyProvider, titleStrategyProvider,
], ],

View File

@@ -13,6 +13,46 @@ export const routes: Routes = [
loadComponent: () => loadComponent: () =>
import('./features/dashboard/dashboard.page').then((m) => m.DashboardPageComponent), 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', path: 'profil',
title: 'Profil', title: 'Profil',

View File

@@ -7,6 +7,7 @@ const user: UserDto = {
id: 'u1', id: 'u1',
name: 'Ada', name: 'Ada',
email: 'ada@example.test', email: 'ada@example.test',
emailVerified: true,
active: true, active: true,
lastLoginAt: null, lastLoginAt: null,
settings: { tablePageSize: 20, sidebarExpanded: true }, settings: { tablePageSize: 20, sidebarExpanded: true },
@@ -32,4 +33,17 @@ describe('AuthService', () => {
expect(service.has('items.read')).toBe(true); expect(service.has('items.read')).toBe(true);
expect(service.has('users.manage')).toBe(false); 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 { has(permission: Permission): boolean {
return this.permissions().has(permission); 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', id: 'u1',
name: 'Ada Lovelace', name: 'Ada Lovelace',
email: 'ada@example.com', email: 'ada@example.com',
emailVerified: true,
active: true, active: true,
lastLoginAt: '2026-07-16T08:30:00.000Z', lastLoginAt: '2026-07-16T08:30:00.000Z',
settings: { tablePageSize: 20, sidebarExpanded: true }, 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()) { @if (auth.user()) {
<ui-icon-button icon="menu" label="Navigation" (pressed)="toggleDrawer()" /> <ui-icon-button icon="menu" label="Navigation" (pressed)="toggleDrawer()" />
} }
<strong class="brand">Business App</strong> <strong class="brand">HausPilot</strong>
@if (auth.user()) { @if (auth.user()) {
<button <button
class="ui-icon-button notification-button" class="ui-icon-button notification-button"
@@ -47,7 +47,7 @@ interface NavItem {
</button> </button>
<a class="ui-button ui-button--ghost logout" href="/api/auth/logout">Abmelden</a> <a class="ui-button ui-button--ghost logout" href="/api/auth/logout">Abmelden</a>
} @else { } @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()) { @if (auth.user() && notificationPanelOpen()) {
<app-notification-panel <app-notification-panel
@@ -93,7 +93,7 @@ interface NavItem {
<section class="login-panel"> <section class="login-panel">
<h1>Anmelden</h1> <h1>Anmelden</h1>
<p>Bitte melden Sie sich ueber den zentralen Identity Provider an.</p> <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 >Mit OIDC anmelden</a
> >
</section> </section>
@@ -243,6 +243,8 @@ export class AppShellComponent {
); );
readonly nav: NavItem[] = [ readonly nav: NavItem[] = [
{ label: 'Dashboard', path: '/' }, { label: 'Dashboard', path: '/' },
{ label: 'Projekte', path: '/projekte', permission: 'projects.use' },
{ label: 'Einladungen', path: '/einladungen', permission: 'projects.use' },
{ label: 'Profil', path: '/profil' }, { label: 'Profil', path: '/profil' },
{ label: 'Sicherheit', path: '/account/security' }, { label: 'Sicherheit', path: '/account/security' },
{ {
@@ -273,6 +275,11 @@ export class AppShellComponent {
return !item.permission || this.auth.has(item.permission); 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 { toggleNotifications(): void {
this.notificationPanelOpen.update((open) => !open); this.notificationPanelOpen.update((open) => !open);
if (this.notificationPanelOpen()) { if (this.notificationPanelOpen()) {

View File

@@ -0,0 +1,123 @@
# HausPilot: Authentifizierungs- und Benutzerintegration
## Ergebnis der Boilerplate-Analyse
HausPilot verwendet unveraendert die vorhandene Backend-for-Frontend-
Authentifizierung. Das Boilerplate spricht keinen bestimmten Hersteller fest,
sondern einen ueber `OIDC_ISSUER` konfigurierten OpenID-Connect-Provider. Der
Login ist ein OIDC Authorization Code Flow mit PKCE, `state` und `nonce`.
Der Browser startet den Login ueber `GET /api/auth/login`. Der Callback liegt
fest unter `<APP_BASE_URL>/api/auth/callback`. Der Backend-Callback validiert
Issuer, Audience, Signaturalgorithmus, Nonce und Subject, laedt gegebenenfalls
UserInfo und legt den lokalen Benutzer an oder aktualisiert ihn. Die stabile
Identitaet beim Provider ist `(issuer, subject)`; fachliche Beziehungen verwenden
ausschliesslich die interne UUID `users.id`.
OIDC Access-, Refresh- und ID-Tokens werden verschluesselt in der MySQL-Tabelle
`sessions` gespeichert. Der Browser erhaelt nur eine signierte, HttpOnly
Session-ID und ein lesbares CSRF-Cookie. Schreibende Requests senden dieses
Cookie ueber den vorhandenen `csrfInterceptor` als `X-CSRF-Token`. Idle- und
Absolute-Timeout werden beim serverseitigen Session-Resolve geprueft. Es gibt
keinen zweiten Token-Refresh im Browser.
Der globale `PermissionsGuard` loest die Session auf, prueft den globalen
Benutzerstatus und setzt `AuthenticatedRequest.user` mit interner UUID,
Session-ID und effektiven Permissions. Controller verwenden weiterhin
`@RequirePermissions(...)`. Deaktivierte Benutzer werden beim Login und bei
jeder Session-Aufloesung abgewiesen; ihre historischen fachlichen Beziehungen
werden nicht geloescht.
Angular ermittelt den aktuellen Benutzer ueber `AuthService.ensureLoaded()` und
`GET /api/me`. Der Auth-State liegt ausschliesslich in dessen Signals. Der
vorhandene `permissionGuard` steuert nur Navigation und Darstellung; das Backend
bleibt verbindlich. Logout erfolgt ausschliesslich ueber `/api/auth/logout`,
widerruft die lokale Session, loescht Session- und CSRF-Cookie und verwendet
danach den OIDC `end_session_endpoint` beziehungsweise `OIDC_LOGOUT_URL`.
## Registrierung und Benutzerprofil
Das Boilerplate besitzt keinen lokalen Registrierungs- oder Passwortdialog und
keine lokale Passwort-Entity. Konten werden durch den vorhandenen Identity
Provider registriert oder bereitgestellt und beim ersten erfolgreichen OIDC-
Login lokal synchronisiert. HausPilot fuehrt daher keine zweite Registrierung
ein. Nach dem ersten Login werden offene Einladungen nur angezeigt, niemals
automatisch angenommen.
Die lokale `UserEntity` speichert UUID, Issuer, Subject, Anzeigename, E-Mail,
Aktivstatus, globale Rollen und Einstellungen. Name und E-Mail werden bei jedem
Login aus dem OIDC-Profil aktualisiert. Aktive Mitgliedschaften bleiben auch bei
einer E-Mail-Aenderung ueber `users.id` stabil. Offene Einladungen bleiben an die
normalisierte urspruengliche Zieladresse gebunden.
Die Integration uebernimmt ausserdem den standardisierten OIDC-Claim
`email_verified`. Eine Einladung kann nur angenommen werden, wenn der Provider
die aktuelle Zieladresse als verifiziert bestaetigt. Fehlt diese Bestaetigung,
wird die Annahme sicher abgewiesen.
## Globale und projektbezogene Autorisierung
Globale Rollen (`admin`, `user` und verwaltete globale Rollen) bleiben von den
HausPilot-Projektrollen getrennt. Eine globale Basis-Permission erlaubt nur die
Verwendung der Projekt-API. Sie erzeugt weder eine Mitgliedschaft noch einen
Zugriff auf ein privates Projekt. Insbesondere erhaelt ein globaler Administrator
keinen impliziten Projektzugriff.
Projektrollen sind `owner`, `administrator`, `editor` und `reader`. Jede
Projektabfrage prueft serverseitig eine aktive Mitgliedschaft und die fuer die
Aktion erforderliche Rolle. Unterentitaeten werden immer zusammen mit der
Projekt-ID geladen beziehungsweise geprueft. Eigentuermer-, Autor- und
Einladender-IDs sind keine DTO-Felder, sondern stammen aus dem vorhandenen
serverseitigen Current-User-Kontext.
Projektanlage, Eigentuemermitgliedschaft und Erstellungsaktivitaet werden in
einer Datenbanktransaktion gespeichert. Projektrollen entstehen nur durch diese
Projektanlage, explizite Einladungsannahme oder eine berechtigte Aenderung im
Projekt; OIDC-Gruppen und globale Rollen werden nicht abgebildet.
## Einladungen und sicherer Rueckkehrpfad
Einladungen speichern die normalisierte E-Mail-Adresse und optional die bekannte
interne Benutzer-ID. Der zufaellige Einladungstoken wird nur im Link ausgegeben;
in MySQL liegt ausschliesslich sein SHA-256-Hash. Annahme und Ablehnung verlangen
eine gueltige bestehende Session. Das Backend prueft Status, Ablauf,
Widerruf, Zieladresse, `email_verified`, aktiven Benutzer und eine noch nicht
bestehende aktive Mitgliedschaft. Fehlermeldungen geben nur eine maskierte
Adresse aus.
Ist ein Besucher nicht angemeldet, verweist Angular auf den bestehenden
`/api/auth/login`-Flow. Der gewuenschte Rueckweg wird serverseitig im kurzlebigen
OIDC-Login-State gehalten. Erlaubt sind nur interne Pfade; Schemes,
Protokoll-relative URLs und Backslashes werden verworfen. Nach dem Callback baut
das Backend das Ziel relativ zu `FRONTEND_BASE_URL` auf. Tokens oder OIDC-Daten
werden nie in Rueckkehrparametern gespeichert.
## Benachrichtigungen, Mail und Cleanup
Das vorhandene `NotificationsService` erzeugt fuer bereits bekannte aktive
Benutzer eine persoenliche In-App-Benachrichtigung. Normale Endpunkte bleiben auf
den Session-Benutzer beschraenkt. Das Boilerplate hat keinen Mail-Service, keine
Templates, Queue oder Retry-Jobs. HausPilot fuehrt keine parallele
Mail-Infrastruktur ein; der Versandstatus einer Einladung wird daher explizit
als `not_configured` gespeichert. Der Einladungsdatensatz bleibt konsistent und
kann spaeter an eine architektonisch beschlossene Mail-Komponente angebunden
werden.
Projektseiten halten Daten nur komponentenlokal. Bei 401 setzt der vorhandene
globale Auth-State den Benutzer zurueck; dadurch werden geschuetzte Seiten
entfernt und das bestehende Notification-Polling samt personenbezogenem Cache
gestoppt. Ein regulaerer Logout verlaesst die SPA und startet sie ohne alten
In-Memory-State neu. HausPilot fuehrt keine Live-Verbindungen ein.
## Relevante Konfiguration
- OIDC: `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`,
`OIDC_SCOPES`, `OIDC_ALLOWED_ALGORITHMS`, optional `OIDC_LOGOUT_URL`
- Redirects: `APP_BASE_URL`, `FRONTEND_BASE_URL`
- Session: `SESSION_COOKIE_NAME`, Idle-/Absolute-Timeout,
`SESSION_SECRET`, `SESSION_ENCRYPTION_KEY`
- Browser-Schutz: `CORS_ORIGINS`, `CSRF_HEADER_NAME`, Secure/SameSite-Cookies,
Helmet, Rate Limits und Logging-Redaction
Es wurde keine parallele Authentifizierungs-, Benutzer-, Passwort-, Token-,
Registrierungs-, Mail-, Queue- oder Live-Connection-Infrastruktur eingefuehrt.

215
docs/hauspilot-domain.md Normal file
View File

@@ -0,0 +1,215 @@
# HausPilot-Fachmodule
## MVP-Abschluss 2026-07
### Neue Migration und Datenmodelle
`1720000005000-AddMentionsAndReminders.ts` ergänzt `task_comment_mentions`, `reminder_deliveries` und `applied_project_templates`. Eindeutige Constraints deduplizieren Benutzer pro Kommentar, Reminder-Schlüssel sowie Vorlagen je Projekt und Ziel. Die Tabellen referenzieren weiterhin ausschließlich bestehende Benutzer- und Projekt-UUIDs.
### Listen-API
Räume, Aufgaben, Meilensteine, Ausgaben, Dokumente, Kommentare und Aktivitäten liefern serverseitig `{ items, page, pageSize, totalItems, totalPages }`. `pageSize` ist auf 100 begrenzt. Filter enthalten stets `projectId`. Sortierfelder sind DTO-Whitelists und werden nie als frei übergebener SQL-Ausdruck ausgeführt. Aufgaben unterstützen Suchtext, mehrere Statuswerte, Priorität, Raum, Verantwortlichen, Kategorie, Datumsbereiche, überfällig, blockiert, nicht zugewiesen und „nur meine“. Ausgaben und Dokumente besitzen Zuordnungs- und Datumsfilter.
Neue gezielte Leseendpunkte sind `GET /projects/:projectId/tasks/:taskId` und `GET /projects/:projectId/calendar?from=YYYY-MM-DD&to=YYYY-MM-DD`. Die stabile Angular-Route `/projekte/:projectId/aufgaben/:taskId` enthält Stammdaten, Schnellstatus, Checkliste, Vorgänger und Nachfolger, Kommentare, Erwähnungen, Dokumente und den Aktivitätsauszug. Der Zeitplan bietet eine responsive Monats- und Agendaansicht ohne neue UI-Bibliothek und lädt nur den benötigten Zeitraum.
### Vorlagen
Die unveränderlichen Systemvorlagen „Reihenhaus-Renovierung“, „Schlüsselübergabe“, „Renovierung eines Raums“ und „Umzug“ werden transaktional kopiert. Die Reihenhausvorlage enthält fünf Bereiche, 15 Räume, 13 Budgetkategorien, Meilensteine und allgemeine Aufgaben. Die Raumvorlage erzeugt 14 Aufgaben samt Abhängigkeitskette. `applied_project_templates` erkennt Wiederholungen je Projekt und Zielraum; erneute Anwendung verlangt `confirmDuplicate`. Fehler rollen alle Kopien und die Aktivität gemeinsam zurück.
### Strukturierte Erwähnungen
Kommentare übertragen neben sichtbarem `@Anzeigename` eine validierte Liste stabiler Benutzer-UUIDs. Nur aktive Mitglieder desselben Projekts werden akzeptiert. Mehrfachnennungen werden dedupliziert, Selbst-Erwähnungen erzeugen keine Nachricht und beim Bearbeiten werden nur neu hinzugekommene Benutzer benachrichtigt. Die In-App-Nachricht verweist direkt auf Aufgabe und Kommentar.
### Scheduler und Reminder
Der offizielle Nest-Scheduler führt den `ReminderService` standardmäßig alle 15 Minuten aus. Er erfasst bald fällige und überfällige Aufgaben, Meilensteine, überfällige offene Ausgaben sowie bald ablaufende Einladungen mit bekanntem Benutzer. Entfernte oder global deaktivierte Mitglieder erhalten nichts. `reminder_deliveries.dedupe_key` enthält Reminder-Art, Entität, Empfänger und Bezugsdatum; der Unique Constraint schützt auch parallele Läufe. Eine Datumsänderung erzeugt einen neuen fachlich relevanten Schlüssel.
- `REMINDER_INTERVAL_MS`: mindestens 60.000, Standard 900.000
- `REMINDER_DUE_SOON_DAYS`: Standard 3
### Development-Seed
Nach Migrationen und mindestens einem vorhandenen aktiven SSO-Benutzer wird der ausschließlich explizite Seed gestartet:
```text
npm run seed:development
```
Er erzeugt idempotent „Umzug Reihenhaus“ mit fünf Etagen, 15 Räumen, 30 Aufgaben, zehn Abhängigkeiten, 20 Checklistenpunkten, zehn Kommentaren, acht Meilensteinen, 13 Budgetkategorien, 15 Ausgaben, vier Dokumentmetadaten, 20 Aktivitäten, In-App-Benachrichtigungen und sofern vorhanden bis zu vier bestehende Benutzer in unterschiedlichen Rollen. Er legt weder Benutzer noch Passwörter an. In `NODE_ENV=production` verweigert er Ausführung und Reset. Der gezielte Reset entfernt nur das eindeutig markierte Seed-Projekt:
```text
npm run seed:development -- --reset
```
Seed-Dokumente enthalten absichtlich nur Metadaten, keine fingierten Binärdateien.
### Abhängigkeitssicherheit und Grenzen
Die produktiven Advisories wurden ohne Major-Upgrade geschlossen: `@nestjs/swagger` 11.4.6 und TypeORM 0.3.31 beheben die gemeldeten transitiven Risiken in `js-yaml`, `lodash`, `path-to-regexp` und TypeORM. `npm audit --omit=dev` meldet danach keine bekannte Schwachstelle. `@nestjs/schedule` 6.1.3 (MIT, Nest-10/11-kompatibel) ist die einzige neue Laufzeitabhängigkeit.
Der Scheduler läuft pro Backend-Instanz; der Datenbank-Constraint macht die Auslieferung mehrfach laufender Instanzen idempotent, ersetzt bei sehr großen Installationen jedoch kein verteiltes Job-Leasing. Komplexe Gantt-, Offline-, E-Mail- und externe Kalenderfunktionen bleiben außerhalb des MVP.
## Ausgangsarchitektur und Wiederverwendung
Die Umsetzung erweitert die bestehende Angular-/NestJS-Anwendung. Globale Anmeldung, OIDC, serverseitige MySQL-Session, CSRF, `PermissionsGuard`, `ProjectAccessService`, `ProjectMembershipEntity`, `NotificationsService` und `ProjectActivityEntity` bleiben die einzigen Mechanismen für Identität, Projektzugriff, Benachrichtigungen und Aktivitäten. Es gibt keine zweite Benutzer-, Login-, Session- oder Projektverwaltung.
Die fachlichen Controller tragen weiterhin `@RequirePermissions(Permission.ProjectsUse)`. Danach prüft `ProjectAccessService` die aktive Mitgliedschaft und die Projektrolle. Jede Unterentität wird zusätzlich mit der Kombination aus `id` und `projectId` geladen. Dadurch reicht weder eine gültige Anmeldung noch eine fremde Entitäts-ID zum Zugriff.
## Datenmodell und Module
`RenovationModule` bündelt die eng zusammenhängende Renovierungsdomäne und besteht aus Controller, Service, Persistence-Repository, Dokumentablage und DTO-Validierung. Die Migration `1720000004000-AddRenovationDomain.ts` ergänzt:
- Gebäude, Etagen und Räume
- Renovierungsaufgaben, Checklisten, Abhängigkeiten und Kommentare
- Meilensteine
- Budgetkategorien und Ausgaben
- projektbezogene Dokumentmetadaten
- Projektstatus, Währung und Gesamtbudget-Grundlage
Historische Benutzerbeziehungen verwenden die bestehende interne User-UUID. Beim Entfernen eines Mitglieds werden offene Zuweisungen in derselben Transaktion geleert; abgeschlossene und historische Beziehungen bleiben erhalten.
## API
Die API liegt unter `/api/projects/:projectId` und stellt Listen sowie Create-/Patch-/Delete-Endpunkte für `buildings`, `floors`, `rooms`, `tasks`, `milestones`, `budget-categories`, `expenses` und `documents` bereit. Aufgaben besitzen zusätzlich `checklist`, `dependencies` und `comments`. Weitere gezielte Endpunkte sind:
- `GET /projects/:projectId/dashboard`
- `GET /projects/:projectId/activities`
- `GET /templates`
- `POST /projects/:projectId/apply-template/:templateId`
- `GET /projects/:projectId/documents/:id/download`
Request-DTOs enthalten weder Ersteller noch handelnde Benutzer. Diese IDs stammen ausschließlich aus dem bestehenden Sessionkontext.
## Rollen
- Eigentümer und Projektadministratoren verwalten die gesamte Struktur, Budgets und Vorlagen.
- Bearbeiter verwalten Räume, Aufgaben, Checklisten, Kommentare, Ausgaben und Dokumente.
- Leser können ausschließlich lesen.
Budgetkategorien sind bewusst Eigentümern und Projektadministratoren vorbehalten, weil ihre Änderung den finanziellen Projektrahmen verändert. Globale SSO-Rollen erzeugen weiterhin keine Projektmitgliedschaft.
## Fortschritt und Abhängigkeiten
Die einzige Fortschrittsberechnung liegt in `renovation/progress.ts`: Idee 0, geplant 10, beauftragt 25, in Arbeit 50, blockiert 25, Abnahme 90 und erledigt 100 Prozent. Entfallene Aufgaben werden ausgeschlossen. Das optionale positive Gewicht bildet einen gewichteten Mittelwert; ohne fachliches Gewicht wird `1` verwendet.
Die zentrale Zykluserkennung erweitert den gerichteten Graphen probeweise um die neue Kante und führt eine Tiefensuche mit `visiting`-/`visited`-Mengen aus. Selbstbezüge sowie direkte und indirekte Zyklen werden mit HTTP 409 abgewiesen. Eine Aufgabe ist blockiert, sobald ein zwingender Vorgänger nicht erledigt ist; der Start wird dann serverseitig verhindert.
## Dashboard und Budget
Der Dashboard-Endpunkt berechnet aggregiert Gesamtfortschritt, offene, überfällige, blockierte, kritische, eigene und nicht zugewiesene Aufgaben, Raumstatus, nächste/gefährdete Meilensteine, aktive Mitglieder sowie geplante, tatsächliche, offene und bezahlte Kosten. Handlungsorientierte Hinweise werden serverseitig aus denselben Daten erzeugt.
Geldbeträge werden in MySQL als `DECIMAL` gespeichert und erst für Aggregationen explizit in Zahlen überführt. Stornierte Ausgaben fließen nicht in tatsächliche Kosten ein.
## Optimistische Nebenläufigkeit
Gebäude, Etagen, Räume, Aufgaben, Meilensteine, Budgetkategorien, Ausgaben und Dokumentmetadaten besitzen eine Versionsnummer. Updates verwenden atomar `WHERE id = ? AND project_id = ? AND version = ?` und erhöhen die Version in derselben SQL-Anweisung. Ein veralteter Stand überschreibt daher keine neueren Daten und liefert HTTP 409. Angular zeigt dafür einen konkreten Konflikthinweis und führt keine verlustbehaftete automatische Zusammenführung durch.
## Dokumentablage
Dateien werden nicht öffentlich ausgeliefert. Upload und Download laufen nach Projektzugriffsprüfung über das Backend. Erlaubt sind PDF, JPEG, PNG und WebP bis zum konfigurierten Limit. Endung, MIME-Typ und Dateisignatur müssen zusammenpassen; der Speichername ist eine zufällige UUID und Pfadbestandteile aus dem Client werden nie übernommen. Metadaten liegen in MySQL, Inhalte unter `DOCUMENT_STORAGE_PATH`.
Konfiguration:
- `DOCUMENT_STORAGE_PATH` (Standard `storage/documents`)
- `DOCUMENT_MAX_FILE_SIZE_BYTES` (Standard 10 MiB)
Für mehrere Backend-Instanzen muss der Pfad als gemeinsames, geschütztes Volume bereitgestellt werden. Virenscanning und objektbasierter Cloud-Speicher sind sinnvolle Produktionsausbaustufen.
## Frontend
Der responsive Projektarbeitsbereich nutzt ausschließlich bestehende Design-Tokens und UI-Komponenten. Die horizontale, tastaturbedienbare Projektnavigation umfasst Übersicht, Räume, Aufgaben, Zeitplan, Budget, Dokumente, Aktivitäten und die bestehende Mitgliederseite. Listen wechseln auf kleinen Displays in Karten; Hauptaktionen benötigen kein Hover.
## Vorlagen und Erweiterbarkeit
Systemvorlagen werden als unveränderliche Definitionen angeboten. Die Raumrenovierung kopiert Aufgaben und Abhängigkeiten; ein vorhandener Aufgabenbestand verlangt eine explizite Duplikatbestätigung. Die Servicegrenze ist für weitere transaktionale Vorlagen vorbereitet.
Spätere Module für Kartons/QR-Codes, Inventar, Lieferanten/Angebote, Materialmengen, Rechnungserkennung, KI-Planung, E-Mail-Erinnerungen und Kalenderintegration können über `projectId`, bestehende Mitgliedschaften, Dokumentreferenzen, Aktivitäten und Benachrichtigungen angebunden werden, ohne Authentifizierung oder Projektzugriff zu duplizieren.
## Tests
Die Verhaltenstests decken insbesondere die gewichtete Fortschrittsberechnung, ausgeschlossene Aufgaben, direkte/indirekte Abhängigkeitszyklen, kombinierte Aufgabenfilter und den Concurrency-Hinweis ab. Bestehende Tests sichern SSO, Projektzugriff, Einladungen, Sessions und Benachrichtigungen weiterhin ab.
## Frühere MVP-Grenzen
Die zuvor fehlenden Vorlagen, strukturierten Erwähnungen und deduplizierten Reminder wurden mit dem oben dokumentierten MVP-Abschluss umgesetzt. Eine Queue wurde dafür bewusst nicht eingeführt.
# Möbel- und Einrichtungsplanung
## AG-Grid-Arbeitsoberfläche
Die Möbelverwaltung nutzt `ag-grid-angular` und `ag-grid-community` 36.0.1 (MIT, Community
Edition). Registriert wird ausschließlich `AllCommunityModule`. Enterprise-Funktionen wie Row
Grouping und Master/Detail werden weder importiert noch vorausgesetzt. Bedarfe, Alternativen,
Bestellungen und Szenarien sind getrennte Grid-Ansichten; die vorhandenen Formulare bleiben für
die vollständige Detailbearbeitung zuständig.
Listen verwenden die bestehende Page-Antwort (`items`, `page`, `pageSize`, `totalItems`,
`totalPages`). Bedarfe unterstützen zusätzlich `openDecision` und `overBudget`; die projektweite
Alternativenliste unterstützt `roomId`, `requirementId`, `status`, `availability`, `favorite`,
`selected`, `ordered` und `delayed`. Sortierfelder werden im Service gegen Positivlisten geprüft.
Bedarfsantworten enthalten aggregierte Optionen, Preise, Budgetabweichung, Bestellstatus und
Lieferdatum. Die Repository-Abfragen laden Optionen gebündelt für die aktuelle Seite.
Inline-Änderungen senden die vorhandene Versionsnummer. Bei Fehlern wird der alte Zellwert
wiederhergestellt; bei HTTP 409 wird der Serverstand neu geladen und der Konflikt sichtbar gemeldet.
Favorit und Auswahl verwenden die bestehenden Fachendpunkte. Leser erhalten keine Editoren; die
serverseitige Projektberechtigung bleibt verbindlich.
Spaltenzustand wird ohne Fachdaten lokal unter Benutzer-, Projekt- und Ansichtsschlüssel gespeichert.
Die Suche wird um 300 ms entprellt, Row-IDs sind stabile Datenbank-IDs und CSV-Export nutzt die
Community-Funktion. Auf kleinen Displays gelten eine reduzierte Höhe und die vorhandene
Dialogbearbeitung für komplexe Eingaben.
Die Möbelplanung ist Bestandteil des vorhandenen `RenovationModule` und verwendet dessen
`ProjectAccessService`, Dokumentablage, Ausgaben, Aktivitäten und Benachrichtigungen. Ein
`FurnitureRequirement` beschreibt den Bedarf eines Raums; konkrete Produkte werden als
`FurnitureOption` gespeichert. `FurnitureScenario` und `FurnitureScenarioSelection`
kombinieren höchstens eine Alternative je Bedarf zu vergleichbaren Einrichtungsvarianten.
## Preis- und Kostenregeln
Der geplante Gesamtpreis lautet `Einzelpreis × Menge + Versand + Zusatzkosten Rabatt`.
Bei vorhandenem Mobiliar entfallen Anschaffungskosten; Umzug und Aufbereitung werden dennoch
berücksichtigt. Alle persistierten Geldwerte sind `DECIMAL(13,2)` und werden im Backend als
Strings verarbeitet; die zentrale Berechnung in `furniture-pricing.ts` rechnet in Cent.
Planwerte stammen aus der ausgewählten Alternative bzw. einem Szenario. Ist-Kosten stammen
ausschließlich aus nicht stornierten, über `furniture_option_id` verknüpften Ausgaben. Damit
wird eine Bestellung nicht zugleich als Produktpreis und Ausgabe doppelt gezählt.
## API und Listenmodell
Die Endpunkte liegen unter `/projects/:projectId/furniture-requirements`,
`/furniture-options`, `/furniture-scenarios`, `/furniture-summary` sowie
`/rooms/:roomId/furniture-summary`. Bedarfslisten unterstützen `page`, `pageSize` (maximal
100), `search`, Raum, Kategorie, Status, Priorität, Verantwortlichen, Favorit, Auswahl,
Bestell-/Lieferstatus und Lieferverzug. Erlaubte Sortierungen sind Name, Raum, Kategorie,
Preis, Priorität, Status, Lieferdatum, Änderungsdatum und Sortierreihenfolge.
## Auswahl, Szenarien und Nebenläufigkeit
Das Auswählen eines Produkts sperrt die betroffene Alternative und deren Bedarf
transaktional, prüft die Version, entfernt die bisherige Auswahl und erzeugt eine Aktivität.
Damit bleibt auch bei parallelen Anforderungen höchstens eine aktive Auswahl bestehen. Bedarfe,
Alternativen und Szenarien besitzen Versionsnummern; veraltete Änderungen liefern HTTP 409.
Automatische Szenarien wählen günstigste, bevorzugte (Auswahl, Favorit, günstigste), teuerste
oder vorhandene Alternativen und können danach manuell verändert werden.
## Dokumente, Ausgaben, Status und Sicherheit
`furniture_option_documents` referenziert ausschließlich geschützte Projektdokumente; es
existiert keine zweite Dateiablage. Ausgaben können Bedarf und Alternative referenzieren.
Sämtliche Unterentitäten werden zusätzlich zur authentifizierten Projektmitgliedschaft gegen
die Pfad-`projectId` geprüft. Leser dürfen lesen und vergleichen; Eigentümer,
Administratoren und Bearbeiter verwenden die bestehende Projektaktion `edit`.
Bestellungen speichern Besteller, Nummer und Liefertermin. Teil- und Komplettlieferung sowie
Verspätung werden ausgewertet. Liefererinnerungen laufen über den vorhandenen Scheduler und
die bestehende `ReminderDelivery`-Deduplizierung; eine Terminänderung erzeugt durch das Datum
im Schlüssel eine neue, wiederholte Jobläufe dagegen keine weitere Benachrichtigung.
## Migration und Development-Seed
Migration `1720000007000-AddFurniturePlanning` erstellt die Möbel- und Szenariotabellen und
ergänzt optionale Expense-Referenzen. Der Development-Seed bleibt produktionsgesperrt und
legt 16 Bedarfe, 48 Alternativen, drei Szenarien, Favoriten, Auswahlen, Bestellungen, eine
Lieferung, einen Lieferverzug, sichere Dokumentmetadaten und verknüpfte Ausgaben an. Die
Etagen des Seeds sind Keller, Erdgeschoss, 1. Stock und Dachboden.

210
package-lock.json generated
View File

@@ -51,7 +51,8 @@
"@nestjs/config": "4.0.4", "@nestjs/config": "4.0.4",
"@nestjs/core": "11.1.28", "@nestjs/core": "11.1.28",
"@nestjs/platform-express": "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/throttler": "6.4.0",
"@nestjs/typeorm": "11.0.0", "@nestjs/typeorm": "11.0.0",
"class-transformer": "0.5.1", "class-transformer": "0.5.1",
@@ -67,10 +68,11 @@
"pino-pretty": "13.1.2", "pino-pretty": "13.1.2",
"reflect-metadata": "0.2.2", "reflect-metadata": "0.2.2",
"rxjs": "7.8.2", "rxjs": "7.8.2",
"typeorm": "0.3.27", "typeorm": "^0.3.31",
"zod": "4.1.13" "zod": "4.1.13"
}, },
"devDependencies": { "devDependencies": {
"@types/multer": "^2.0.0",
"typeorm-ts-node-commonjs": "0.3.20" "typeorm-ts-node-commonjs": "0.3.20"
} }
}, },
@@ -107,6 +109,8 @@
"@angular/platform-browser-dynamic": "22.0.6", "@angular/platform-browser-dynamic": "22.0.6",
"@angular/router": "22.0.6", "@angular/router": "22.0.6",
"@boilerplate/api-client": "1.0.0", "@boilerplate/api-client": "1.0.0",
"ag-grid-angular": "^36.0.1",
"ag-grid-community": "^36.0.1",
"rxjs": "7.8.2", "rxjs": "7.8.2",
"tslib": "2.8.1" "tslib": "2.8.1"
} }
@@ -4540,12 +4544,6 @@
"url": "https://dotenvx.com" "url": "https://dotenvx.com"
} }
}, },
"node_modules/@nestjs/config/node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/@nestjs/core": { "node_modules/@nestjs/core": {
"version": "11.1.28", "version": "11.1.28",
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.28.tgz", "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.28.tgz",
@@ -4586,14 +4584,14 @@
} }
}, },
"node_modules/@nestjs/mapped-types": { "node_modules/@nestjs/mapped-types": {
"version": "2.1.0", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.1.0.tgz", "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.1.1.tgz",
"integrity": "sha512-W+n+rM69XsFdwORF11UqJahn4J3xi4g/ZEOlJNL6KoW5ygWSmBB2p0S2BZ4FQeS/NDH72e6xIcu35SfJnE8bXw==", "integrity": "sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==",
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0", "@nestjs/common": "^10.0.0 || ^11.0.0",
"class-transformer": "^0.4.0 || ^0.5.0", "class-transformer": "^0.4.0 || ^0.5.0",
"class-validator": "^0.13.0 || ^0.14.0", "class-validator": "^0.13.0 || ^0.14.0 || ^0.15.0",
"reflect-metadata": "^0.1.12 || ^0.2.0" "reflect-metadata": "^0.1.12 || ^0.2.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
@@ -4626,6 +4624,19 @@
"@nestjs/core": "^11.0.0" "@nestjs/core": "^11.0.0"
} }
}, },
"node_modules/@nestjs/schedule": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.1.3.tgz",
"integrity": "sha512-RflMFOpR16Dwd1jAUbeB4mfGTCh65fvEdL4mSjQPJChpkRGRjIXjb+6YQcK2faQrVT60c9DmLmoVR7/ONCtuYQ==",
"license": "MIT",
"dependencies": {
"cron": "4.4.0"
},
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"@nestjs/core": "^10.0.0 || ^11.0.0"
}
},
"node_modules/@nestjs/schematics": { "node_modules/@nestjs/schematics": {
"version": "11.0.9", "version": "11.0.9",
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.9.tgz", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.9.tgz",
@@ -4891,20 +4902,20 @@
} }
}, },
"node_modules/@nestjs/swagger": { "node_modules/@nestjs/swagger": {
"version": "11.2.3", "version": "11.4.6",
"resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.2.3.tgz", "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.4.6.tgz",
"integrity": "sha512-a0xFfjeqk69uHIUpP8u0ryn4cKuHdra2Ug96L858i0N200Hxho+n3j+TlQXyOF4EstLSGjTfxI1Xb2E1lUxeNg==", "integrity": "sha512-Le136h2WC7HGsd70+WyK1qrm+Zq7kFxBLkYC1JgAVqNRCt8kNh7bMF7Qkn65D5j2t/aks0+VbWmUVlYIwPrs3A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@microsoft/tsdoc": "0.16.0", "@microsoft/tsdoc": "0.16.0",
"@nestjs/mapped-types": "2.1.0", "@nestjs/mapped-types": "2.1.1",
"js-yaml": "4.1.1", "js-yaml": "5.2.1",
"lodash": "4.17.21", "lodash": "4.18.1",
"path-to-regexp": "8.3.0", "path-to-regexp": "8.4.2",
"swagger-ui-dist": "5.30.2" "swagger-ui-dist": "5.32.8"
}, },
"peerDependencies": { "peerDependencies": {
"@fastify/static": "^8.0.0", "@fastify/static": "^8.0.0 || ^9.0.0 || ^10.0.0",
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
"class-transformer": "*", "class-transformer": "*",
@@ -4923,14 +4934,26 @@
} }
} }
}, },
"node_modules/@nestjs/swagger/node_modules/path-to-regexp": { "node_modules/@nestjs/swagger/node_modules/js-yaml": {
"version": "8.3.0", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.1.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", "integrity": "sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT", "license": "MIT",
"funding": { "dependencies": {
"type": "opencollective", "argparse": "^2.0.1"
"url": "https://opencollective.com/express" },
"bin": {
"js-yaml": "bin/js-yaml.mjs"
} }
}, },
"node_modules/@nestjs/testing": { "node_modules/@nestjs/testing": {
@@ -6382,6 +6405,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.2.tgz",
"integrity": "sha512-gW+Oib+vUtGJBtNC8V9Reww0oIpusw+4m81uncg9REGZAJfqOQHfo/nkabnc7w0QReXyPqjrbWMJk6NuAkiX3Q==",
"license": "MIT"
},
"node_modules/@types/methods": { "node_modules/@types/methods": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
@@ -6396,6 +6425,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/multer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz",
"integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.10.1", "version": "24.10.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
@@ -7143,6 +7182,42 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/ag-charts-types": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-14.0.1.tgz",
"integrity": "sha512-fGNDptlNzaQDqDoZxAYeLtBrV8sIXE1iRXX9s5W+/drobP3Ig2fhdAOlKp3IIrHlbiKZUiuhraqT0D4IYVyM4w==",
"license": "MIT"
},
"node_modules/ag-grid-angular": {
"version": "36.0.1",
"resolved": "https://registry.npmjs.org/ag-grid-angular/-/ag-grid-angular-36.0.1.tgz",
"integrity": "sha512-SRfyvCtv8T6LNnXRu9kjg/u6a+bFdXF41UhHtJH/X0HDa/BJV/S2hIvZdcSvTHD4ss77tz1wwdg5DXRTmj6a9g==",
"license": "MIT",
"dependencies": {
"ag-grid-community": "36.0.1",
"tslib": "^2.8.1"
},
"peerDependencies": {
"@angular/common": ">= 20.0.0",
"@angular/core": ">= 20.0.0"
}
},
"node_modules/ag-grid-community": {
"version": "36.0.1",
"resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-36.0.1.tgz",
"integrity": "sha512-wQetUqoY6SBCtKzDjsYkImiGpM4+hU7S5/NvxcCAO6liL6mSRXlEvCHdQZUfj6amuhsdjVlGK1C8e+B54EyDTQ==",
"license": "MIT",
"dependencies": {
"ag-charts-types": "14.0.1",
"ag-stack": "36.0.1"
}
},
"node_modules/ag-stack": {
"version": "36.0.1",
"resolved": "https://registry.npmjs.org/ag-stack/-/ag-stack-36.0.1.tgz",
"integrity": "sha512-FsrO55fl7EDToQVgRDaTF1+QKWpfJK1nxTxTavkkfHeq6Kei4skAeP0MsjXm7zECjfyHczlwmWvp+KN7KHnlYQ==",
"license": "MIT"
},
"node_modules/agent-base": { "node_modules/agent-base": {
"version": "9.0.0", "version": "9.0.0",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz",
@@ -8322,6 +8397,23 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cron": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/cron/-/cron-4.4.0.tgz",
"integrity": "sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==",
"license": "MIT",
"dependencies": {
"@types/luxon": "~3.7.0",
"luxon": "~3.7.0"
},
"engines": {
"node": ">=18.x"
},
"funding": {
"type": "ko-fi",
"url": "https://ko-fi.com/intcreator"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -10471,6 +10563,7 @@
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"argparse": "^2.0.1" "argparse": "^2.0.1"
@@ -10770,9 +10863,9 @@
} }
}, },
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.17.21", "version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.merge": { "node_modules/lodash.merge": {
@@ -10923,6 +11016,15 @@
"url": "https://github.com/sponsors/wellwelwel" "url": "https://github.com/sponsors/wellwelwel"
} }
}, },
"node_modules/luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/lz-string": { "node_modules/lz-string": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
@@ -13556,9 +13658,9 @@
} }
}, },
"node_modules/swagger-ui-dist": { "node_modules/swagger-ui-dist": {
"version": "5.30.2", "version": "5.32.8",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.30.2.tgz", "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.8.tgz",
"integrity": "sha512-HWCg1DTNE/Nmapt+0m2EPXFwNKNeKK4PwMjkwveN/zn1cV2Kxi9SURd+m0SpdcSgWEK/O64sf8bzXdtUhigtHA==", "integrity": "sha512-dgMdWXIgnI4zX4OPhKEdWnlDODbgm8W3AX0Ivn/BBqcUh6xZsBxhZMnvk6DJyRz1BTrj8dPxtarmEGgkz30oyA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@scarf/scarf": "=1.4.0" "@scarf/scarf": "=1.4.0"
@@ -14068,25 +14170,26 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/typeorm": { "node_modules/typeorm": {
"version": "0.3.27", "version": "0.3.31",
"resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.27.tgz", "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.31.tgz",
"integrity": "sha512-pNV1bn+1n8qEe8tUNsNdD8ejuPcMAg47u2lUGnbsajiNUr3p2Js1XLKQjBMH0yMRMDfdX8T+fIRejFmIwy9x4A==", "integrity": "sha512-6u9EFtdLBgHjnPm78NStVeM+I/1MolTzKykDDcydzKUkh6E++YS6XViU/fePJbvDvEGU4Xq34KOM/CLeer9I2A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sqltools/formatter": "^1.2.5", "@sqltools/formatter": "^1.2.5",
"ansis": "^3.17.0", "ansis": "^4.3.1",
"app-root-path": "^3.1.0", "app-root-path": "^3.1.0",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"dayjs": "^1.11.13", "dayjs": "^1.11.21",
"debug": "^4.4.0", "debug": "^4.4.3",
"dedent": "^1.6.0", "dedent": "^1.7.2",
"dotenv": "^16.4.7", "dotenv": "^16.6.1",
"glob": "^10.4.5", "glob": "^10.5.0",
"reflect-metadata": "^0.2.2",
"sha.js": "^2.4.12", "sha.js": "^2.4.12",
"sql-highlight": "^6.0.0", "sql-highlight": "^6.1.0",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"uuid": "^11.1.0", "uuid": "^11.1.1",
"yargs": "^17.7.2" "yargs": "^17.7.3"
}, },
"bin": { "bin": {
"typeorm": "cli.js", "typeorm": "cli.js",
@@ -14100,21 +14203,20 @@
"url": "https://opencollective.com/typeorm" "url": "https://opencollective.com/typeorm"
}, },
"peerDependencies": { "peerDependencies": {
"@google-cloud/spanner": "^5.18.0 || ^6.0.0 || ^7.0.0", "@google-cloud/spanner": "^5.18.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
"@sap/hana-client": "^2.14.22", "@sap/hana-client": "^2.14.22",
"better-sqlite3": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0", "better-sqlite3": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0",
"ioredis": "^5.0.4", "ioredis": "^5.0.4",
"mongodb": "^5.8.0 || ^6.0.0", "mongodb": "^5.8.0 || ^6.0.0",
"mssql": "^9.1.1 || ^10.0.1 || ^11.0.1", "mssql": "^9.1.1 || ^10.0.0 || ^11.0.0 || ^12.0.0",
"mysql2": "^2.2.5 || ^3.0.1", "mysql2": "^2.2.5 || ^3.0.1",
"oracledb": "^6.3.0", "oracledb": "^6.3.0 || ^7.0.0",
"pg": "^8.5.1", "pg": "^8.5.1",
"pg-native": "^3.0.0", "pg-native": "^3.0.0",
"pg-query-stream": "^4.0.0", "pg-query-stream": "^4.0.0",
"redis": "^3.1.1 || ^4.0.0 || ^5.0.14", "redis": "^3.1.1 || ^4.0.0 || ^5.0.14",
"reflect-metadata": "^0.1.14 || ^0.2.0",
"sql.js": "^1.4.0", "sql.js": "^1.4.0",
"sqlite3": "^5.0.3", "sqlite3": "^5.0.3 || ^6.0.0",
"ts-node": "^10.7.0", "ts-node": "^10.7.0",
"typeorm-aurora-data-api-driver": "^2.0.0 || ^3.0.0" "typeorm-aurora-data-api-driver": "^2.0.0 || ^3.0.0"
}, },
@@ -14180,9 +14282,9 @@
} }
}, },
"node_modules/typeorm/node_modules/ansis": { "node_modules/typeorm/node_modules/ansis": {
"version": "3.17.0", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/ansis/-/ansis-3.17.0.tgz", "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.3.1.tgz",
"integrity": "sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==", "integrity": "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==",
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": ">=14" "node": ">=14"

View File

@@ -33,6 +33,7 @@
"migration:status": "npm --workspace apps/backend run migration:status", "migration:status": "npm --workspace apps/backend run migration:status",
"migration:run": "npm --workspace apps/backend run migration:run", "migration:run": "npm --workspace apps/backend run migration:run",
"migration:generate": "npm --workspace apps/backend run migration:generate", "migration:generate": "npm --workspace apps/backend run migration:generate",
"seed:development": "npm --workspace apps/backend run seed:development --",
"docker:build": "docker build ." "docker:build": "docker build ."
}, },
"devDependencies": { "devDependencies": {

View File

@@ -14,6 +14,10 @@ import type {
NotificationStatusFilter, NotificationStatusFilter,
PageDto, PageDto,
Permission, Permission,
ProjectDto,
ProjectInvitationDto,
ProjectMemberDto,
ProjectRole,
RoleDto, RoleDto,
SaveItemDto, SaveItemDto,
SessionDto, SessionDto,
@@ -49,6 +53,104 @@ export class ApiClientService {
}>(`${this.base}/dashboard`, { withCredentials: true }); }>(`${this.base}/dashboard`, { withCredentials: true });
} }
projects(): Observable<ProjectDto[]> {
return this.http.get<ProjectDto[]>(`${this.base}/projects`, {
withCredentials: true,
});
}
project(id: string): Observable<ProjectDto> {
return this.http.get<ProjectDto>(`${this.base}/projects/${id}`, {
withCredentials: true,
});
}
createProject(body: { name: string; description?: string }): Observable<ProjectDto> {
return this.http.post<ProjectDto>(`${this.base}/projects`, body, {
withCredentials: true,
});
}
projectMembers(projectId: string): Observable<ProjectMemberDto[]> {
return this.http.get<ProjectMemberDto[]>(`${this.base}/projects/${projectId}/members`, {
withCredentials: true,
});
}
updateProjectMember(
projectId: string,
userId: string,
role: ProjectRole,
): Observable<ProjectMemberDto> {
return this.http.patch<ProjectMemberDto>(
`${this.base}/projects/${projectId}/members/${userId}`,
{ role },
{ withCredentials: true },
);
}
removeProjectMember(projectId: string, userId: string): Observable<void> {
return this.http.delete<void>(`${this.base}/projects/${projectId}/members/${userId}`, {
withCredentials: true,
});
}
createProjectInvitation(
projectId: string,
body: { email: string; role: ProjectRole },
): Observable<ProjectInvitationDto> {
return this.http.post<ProjectInvitationDto>(
`${this.base}/projects/${projectId}/invitations`,
body,
{ withCredentials: true },
);
}
pendingProjectInvitations(): Observable<ProjectInvitationDto[]> {
return this.http.get<ProjectInvitationDto[]>(`${this.base}/invitations`, {
withCredentials: true,
});
}
acceptPendingProjectInvitation(invitationId: string): Observable<ProjectDto> {
return this.http.post<ProjectDto>(
`${this.base}/invitations/by-id/${invitationId}/accept`,
{},
{ withCredentials: true },
);
}
declinePendingProjectInvitation(invitationId: string): Observable<void> {
return this.http.post<void>(
`${this.base}/invitations/by-id/${invitationId}/decline`,
{},
{ withCredentials: true },
);
}
projectInvitation(token: string): Observable<ProjectInvitationDto> {
return this.http.get<ProjectInvitationDto>(
`${this.base}/invitations/${encodeURIComponent(token)}`,
{ withCredentials: true },
);
}
acceptProjectInvitation(token: string): Observable<ProjectDto> {
return this.http.post<ProjectDto>(
`${this.base}/invitations/${encodeURIComponent(token)}/accept`,
{},
{ withCredentials: true },
);
}
declineProjectInvitation(token: string): Observable<void> {
return this.http.post<void>(
`${this.base}/invitations/${encodeURIComponent(token)}/decline`,
{},
{ withCredentials: true },
);
}
items( items(
query: { query: {
search?: string; search?: string;

View File

@@ -14,7 +14,8 @@ export type Permission =
| 'sessions.manage' | 'sessions.manage'
| 'notifications.readOwn' | 'notifications.readOwn'
| 'notifications.updateOwn' | 'notifications.updateOwn'
| 'notifications.manage'; | 'notifications.manage'
| 'projects.use';
export interface ApiErrorBody { export interface ApiErrorBody {
status: number; status: number;
@@ -35,6 +36,7 @@ export interface UserDto {
id: string; id: string;
name: string; name: string;
email: string | null; email: string | null;
emailVerified: boolean | null;
active: boolean; active: boolean;
lastLoginAt: string | null; lastLoginAt: string | null;
roles: RoleDto[]; roles: RoleDto[];
@@ -85,6 +87,40 @@ export interface RoleDto {
users?: UserDto[]; users?: UserDto[];
} }
export type ProjectRole = 'owner' | 'administrator' | 'editor' | 'reader';
export interface ProjectDto {
id: string;
name: string;
description: string | null;
role: ProjectRole;
createdAt: string;
updatedAt: string;
}
export interface ProjectMemberDto {
userId: string;
name: string;
email: string | null;
role: ProjectRole;
active: boolean;
joinedAt: string;
}
export interface ProjectInvitationDto {
id: string;
projectId: string | null;
projectName: string | null;
role: ProjectRole;
status: 'pending' | 'accepted' | 'declined' | 'revoked' | 'expired';
invitedEmailMasked: string;
expiresAt: string;
mailStatus: 'not_configured';
canRespond: boolean;
reason: 'email_mismatch' | 'email_unverified' | null;
invitationPath?: string;
}
export interface ItemDto { export interface ItemDto {
id: string; id: string;
name: string; name: string;
@@ -124,7 +160,12 @@ export interface AuditLogDto {
requestId: string; requestId: string;
} }
export type NotificationType = 'system' | 'item.created' | 'item.updated' | 'user.role-changed'; export type NotificationType =
| 'system'
| 'item.created'
| 'item.updated'
| 'user.role-changed'
| 'project.invitation';
export type NotificationStatusFilter = 'all' | 'read' | 'unread'; export type NotificationStatusFilter = 'all' | 'read' | 'unread';

View File

@@ -26,7 +26,8 @@ export type Permission =
| 'sessions.manage' | 'sessions.manage'
| 'notifications.readOwn' | 'notifications.readOwn'
| 'notifications.updateOwn' | 'notifications.updateOwn'
| 'notifications.manage'; | 'notifications.manage'
| 'projects.use';
export interface ApiErrorBody { export interface ApiErrorBody {
status: number; status: number;
@@ -47,6 +48,7 @@ export interface UserDto {
id: string; id: string;
name: string; name: string;
email: string | null; email: string | null;
emailVerified: boolean | null;
active: boolean; active: boolean;
lastLoginAt: string | null; lastLoginAt: string | null;
roles: RoleDto[]; roles: RoleDto[];
@@ -97,6 +99,40 @@ export interface RoleDto {
users?: UserDto[]; users?: UserDto[];
} }
export type ProjectRole = 'owner' | 'administrator' | 'editor' | 'reader';
export interface ProjectDto {
id: string;
name: string;
description: string | null;
role: ProjectRole;
createdAt: string;
updatedAt: string;
}
export interface ProjectMemberDto {
userId: string;
name: string;
email: string | null;
role: ProjectRole;
active: boolean;
joinedAt: string;
}
export interface ProjectInvitationDto {
id: string;
projectId: string | null;
projectName: string | null;
role: ProjectRole;
status: 'pending' | 'accepted' | 'declined' | 'revoked' | 'expired';
invitedEmailMasked: string;
expiresAt: string;
mailStatus: 'not_configured';
canRespond: boolean;
reason: 'email_mismatch' | 'email_unverified' | null;
invitationPath?: string;
}
export interface ItemDto { export interface ItemDto {
id: string; id: string;
name: string; name: string;
@@ -140,7 +176,8 @@ export type NotificationType =
| 'system' | 'system'
| 'item.created' | 'item.created'
| 'item.updated' | 'item.updated'
| 'user.role-changed'; | 'user.role-changed'
| 'project.invitation';
export type NotificationStatusFilter = 'all' | 'read' | 'unread'; export type NotificationStatusFilter = 'all' | 'read' | 'unread';
@@ -188,6 +225,10 @@ import type {
NotificationStatusFilter, NotificationStatusFilter,
PageDto, PageDto,
Permission, Permission,
ProjectDto,
ProjectInvitationDto,
ProjectMemberDto,
ProjectRole,
RoleDto, RoleDto,
SaveItemDto, SaveItemDto,
SessionDto, SessionDto,
@@ -226,6 +267,106 @@ export class ApiClientService {
}>(\`\${this.base}/dashboard\`, { withCredentials: true }); }>(\`\${this.base}/dashboard\`, { withCredentials: true });
} }
projects(): Observable<ProjectDto[]> {
return this.http.get<ProjectDto[]>(\`\${this.base}/projects\`, {
withCredentials: true,
});
}
project(id: string): Observable<ProjectDto> {
return this.http.get<ProjectDto>(\`\${this.base}/projects/\${id}\`, {
withCredentials: true,
});
}
createProject(body: { name: string; description?: string }): Observable<ProjectDto> {
return this.http.post<ProjectDto>(\`\${this.base}/projects\`, body, {
withCredentials: true,
});
}
projectMembers(projectId: string): Observable<ProjectMemberDto[]> {
return this.http.get<ProjectMemberDto[]>(
\`\${this.base}/projects/\${projectId}/members\`,
{ withCredentials: true },
);
}
updateProjectMember(
projectId: string,
userId: string,
role: ProjectRole,
): Observable<ProjectMemberDto> {
return this.http.patch<ProjectMemberDto>(
\`\${this.base}/projects/\${projectId}/members/\${userId}\`,
{ role },
{ withCredentials: true },
);
}
removeProjectMember(projectId: string, userId: string): Observable<void> {
return this.http.delete<void>(
\`\${this.base}/projects/\${projectId}/members/\${userId}\`,
{ withCredentials: true },
);
}
createProjectInvitation(
projectId: string,
body: { email: string; role: ProjectRole },
): Observable<ProjectInvitationDto> {
return this.http.post<ProjectInvitationDto>(
\`\${this.base}/projects/\${projectId}/invitations\`,
body,
{ withCredentials: true },
);
}
pendingProjectInvitations(): Observable<ProjectInvitationDto[]> {
return this.http.get<ProjectInvitationDto[]>(\`\${this.base}/invitations\`, {
withCredentials: true,
});
}
acceptPendingProjectInvitation(invitationId: string): Observable<ProjectDto> {
return this.http.post<ProjectDto>(
\`\${this.base}/invitations/by-id/\${invitationId}/accept\`,
{},
{ withCredentials: true },
);
}
declinePendingProjectInvitation(invitationId: string): Observable<void> {
return this.http.post<void>(
\`\${this.base}/invitations/by-id/\${invitationId}/decline\`,
{},
{ withCredentials: true },
);
}
projectInvitation(token: string): Observable<ProjectInvitationDto> {
return this.http.get<ProjectInvitationDto>(
\`\${this.base}/invitations/\${encodeURIComponent(token)}\`,
{ withCredentials: true },
);
}
acceptProjectInvitation(token: string): Observable<ProjectDto> {
return this.http.post<ProjectDto>(
\`\${this.base}/invitations/\${encodeURIComponent(token)}/accept\`,
{},
{ withCredentials: true },
);
}
declineProjectInvitation(token: string): Observable<void> {
return this.http.post<void>(
\`\${this.base}/invitations/\${encodeURIComponent(token)}/decline\`,
{},
{ withCredentials: true },
);
}
items( items(
query: { query: {
search?: string; search?: string;