generated from bastian/boilerplate
mvp
This commit is contained in:
@@ -31,6 +31,11 @@ CSRF_HEADER_NAME=X-CSRF-Token
|
||||
LOG_LEVEL=info
|
||||
SWAGGER_ENABLED=true
|
||||
|
||||
DOCUMENT_STORAGE_PATH=storage/documents
|
||||
DOCUMENT_MAX_FILE_SIZE_BYTES=10485760
|
||||
REMINDER_INTERVAL_MS=900000
|
||||
REMINDER_DUE_SOON_DAYS=3
|
||||
|
||||
RATE_LIMIT_WINDOW_SECONDS=60
|
||||
RATE_LIMIT_MAX_REQUESTS=300
|
||||
RATE_LIMIT_SENSITIVE_WINDOW_SECONDS=60
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
"singleQuote": true,
|
||||
"semi": true,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "all"
|
||||
"trailingComma": "all",
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
"trailingComma": "all",
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
|
||||
@@ -11,14 +11,16 @@
|
||||
"test": "vitest run --config vitest.config.ts",
|
||||
"migration:status": "typeorm-ts-node-commonjs -d src/database/typeorm-cli.datasource.ts migration:show",
|
||||
"migration:run": "typeorm-ts-node-commonjs -d src/database/typeorm-cli.datasource.ts migration:run",
|
||||
"migration:generate": "typeorm-ts-node-commonjs -d src/database/typeorm-cli.datasource.ts migration:generate src/database/migrations/GeneratedMigration"
|
||||
"migration:generate": "typeorm-ts-node-commonjs -d src/database/typeorm-cli.datasource.ts migration:generate src/database/migrations/GeneratedMigration",
|
||||
"seed:development": "ts-node src/database/run-development-seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "11.1.28",
|
||||
"@nestjs/config": "4.0.4",
|
||||
"@nestjs/core": "11.1.28",
|
||||
"@nestjs/platform-express": "11.1.28",
|
||||
"@nestjs/swagger": "11.2.3",
|
||||
"@nestjs/schedule": "^6.1.3",
|
||||
"@nestjs/swagger": "^11.4.6",
|
||||
"@nestjs/throttler": "6.4.0",
|
||||
"@nestjs/typeorm": "11.0.0",
|
||||
"class-transformer": "0.5.1",
|
||||
@@ -34,10 +36,11 @@
|
||||
"pino-pretty": "13.1.2",
|
||||
"reflect-metadata": "0.2.2",
|
||||
"rxjs": "7.8.2",
|
||||
"typeorm": "0.3.27",
|
||||
"typeorm": "^0.3.31",
|
||||
"zod": "4.1.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/multer": "^2.0.0",
|
||||
"typeorm-ts-node-commonjs": "0.3.20"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,9 @@ import { DashboardModule } from './dashboard/dashboard.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { ItemsModule } from './items/items.module';
|
||||
import { NotificationsModule } from './notifications/notifications.module';
|
||||
import { ProjectsModule } from './projects/projects.module';
|
||||
import { RolesModule } from './roles/roles.module';
|
||||
import { RenovationModule } from './renovation/renovation.module';
|
||||
import { SessionsModule } from './sessions/sessions.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
|
||||
@@ -83,6 +85,8 @@ const generateGlobalKey: ThrottlerGenerateKeyFunction = (
|
||||
RolesModule,
|
||||
SessionsModule,
|
||||
NotificationsModule,
|
||||
ProjectsModule,
|
||||
RenovationModule,
|
||||
AuditModule,
|
||||
ItemsModule,
|
||||
HealthModule,
|
||||
|
||||
@@ -17,8 +17,8 @@ export class AuthController {
|
||||
@Public()
|
||||
@SensitiveRateLimit()
|
||||
@Redirect()
|
||||
async login() {
|
||||
return { url: await this.auth.createLoginUrl() };
|
||||
async login(@Query('returnTo') returnTo?: string) {
|
||||
return { url: await this.auth.createLoginUrl(returnTo) };
|
||||
}
|
||||
|
||||
@Get('callback')
|
||||
@@ -30,7 +30,7 @@ export class AuthController {
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { session, csrfToken } = await this.auth.completeLogin(
|
||||
const { session, csrfToken, returnPath } = await this.auth.completeLogin(
|
||||
code,
|
||||
state,
|
||||
req.get('user-agent'),
|
||||
@@ -51,7 +51,9 @@ export class AuthController {
|
||||
path: '/',
|
||||
expires: session.absoluteExpiresAt,
|
||||
});
|
||||
res.redirect(this.config.frontendBaseUrl);
|
||||
res.redirect(
|
||||
new URL(returnPath ?? '/', this.config.frontendBaseUrl).toString(),
|
||||
);
|
||||
}
|
||||
|
||||
@Get('logout')
|
||||
|
||||
@@ -9,6 +9,50 @@ import type { OidcLoginStateEntity } from './entities/oidc-login-state.entity';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
describe('AuthService', () => {
|
||||
it('stores only an internal return path in the short-lived OIDC login state', async () => {
|
||||
const saved: OidcLoginStateEntity[] = [];
|
||||
const service = new AuthService(
|
||||
{
|
||||
frontendBaseUrl: 'https://app.example.test',
|
||||
appBaseUrl: 'https://app.example.test',
|
||||
oidc: {
|
||||
issuer: 'https://idp.example.test',
|
||||
clientId: 'business-app',
|
||||
clientSecret: 'secret',
|
||||
scopes: 'openid profile email',
|
||||
allowedAlgorithms: ['RS256'],
|
||||
httpTimeoutMs: 5000,
|
||||
},
|
||||
} as AppConfigService,
|
||||
{
|
||||
requestJson: () =>
|
||||
Promise.resolve({
|
||||
issuer: 'https://idp.example.test',
|
||||
authorization_endpoint: 'https://idp.example.test/authorize',
|
||||
token_endpoint: 'https://idp.example.test/token',
|
||||
jwks_uri: 'https://idp.example.test/jwks',
|
||||
}),
|
||||
} as unknown as ExternalHttpClient,
|
||||
{} as RolesService,
|
||||
{} as UsersRepository,
|
||||
{} as SessionsService,
|
||||
{} as DataSource,
|
||||
{
|
||||
delete: () => Promise.resolve({}),
|
||||
save: (state: OidcLoginStateEntity) => {
|
||||
saved.push(state);
|
||||
return Promise.resolve(state);
|
||||
},
|
||||
} as unknown as Repository<OidcLoginStateEntity>,
|
||||
);
|
||||
|
||||
await service.createLoginUrl('/einladungen/sicher');
|
||||
await service.createLoginUrl('//evil.example/path');
|
||||
|
||||
expect(saved[0]?.returnPath).toBe('/einladungen/sicher');
|
||||
expect(saved[1]?.returnPath).toBeNull();
|
||||
});
|
||||
|
||||
it('revokes the local session and redirects to the OIDC logout endpoint', async () => {
|
||||
const revoke = vi.fn<() => Promise<number>>(() => Promise.resolve(1));
|
||||
const getIdTokenForLogout = vi.fn<() => Promise<string | undefined>>(() =>
|
||||
|
||||
@@ -31,7 +31,7 @@ export class AuthService {
|
||||
private readonly loginStates: Repository<OidcLoginStateEntity>,
|
||||
) {}
|
||||
|
||||
async createLoginUrl(): Promise<string> {
|
||||
async createLoginUrl(returnTo?: string): Promise<string> {
|
||||
const discovery = await this.discovery();
|
||||
const state = randomBytes(32).toString('hex');
|
||||
const nonce = randomBytes(32).toString('base64url');
|
||||
@@ -45,6 +45,7 @@ export class AuthService {
|
||||
loginState.state = state;
|
||||
loginState.codeVerifier = codeVerifier;
|
||||
loginState.nonce = nonce;
|
||||
loginState.returnPath = this.safeReturnPath(returnTo);
|
||||
loginState.expiresAt = new Date(Date.now() + 10 * 60 * 1000);
|
||||
await this.loginStates.save(loginState);
|
||||
|
||||
@@ -96,7 +97,7 @@ export class AuthService {
|
||||
);
|
||||
}
|
||||
|
||||
return this.sessions.createSession(
|
||||
const result = await this.sessions.createSession(
|
||||
user,
|
||||
{
|
||||
accessToken: tokens.access_token,
|
||||
@@ -109,6 +110,7 @@ export class AuthService {
|
||||
userAgent,
|
||||
ip,
|
||||
);
|
||||
return { ...result, returnPath: loginState.returnPath };
|
||||
}
|
||||
|
||||
async logout(sessionId: string | undefined): Promise<string> {
|
||||
@@ -146,6 +148,10 @@ export class AuthService {
|
||||
}
|
||||
user.name = profile.name ?? profile.email ?? profile.sub;
|
||||
user.email = profile.email ?? null;
|
||||
user.emailVerified =
|
||||
typeof profile.email_verified === 'boolean'
|
||||
? profile.email_verified
|
||||
: null;
|
||||
user.lastLoginAt = new Date();
|
||||
const savedUser = await manager.getRepository(UserEntity).save(user);
|
||||
savedUser.settings = await this.ensureUserSettings(manager, savedUser);
|
||||
@@ -296,6 +302,9 @@ export class AuthService {
|
||||
if (typeof payload['email'] === 'string') {
|
||||
fallback.email = payload['email'];
|
||||
}
|
||||
if (typeof payload['email_verified'] === 'boolean') {
|
||||
fallback.email_verified = payload['email_verified'];
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
const userInfo = await this.http.requestJson<OidcUserInfo>(
|
||||
@@ -311,6 +320,21 @@ export class AuthService {
|
||||
401,
|
||||
);
|
||||
}
|
||||
if (!userInfo.name && typeof payload['name'] === 'string') {
|
||||
userInfo.name = payload['name'];
|
||||
}
|
||||
if (!userInfo.email && typeof payload['email'] === 'string') {
|
||||
userInfo.email = payload['email'];
|
||||
}
|
||||
if (
|
||||
typeof userInfo.email_verified !== 'boolean' &&
|
||||
typeof payload['email_verified'] === 'boolean' &&
|
||||
typeof payload['email'] === 'string' &&
|
||||
payload['email'].trim().toLowerCase() ===
|
||||
userInfo.email?.trim().toLowerCase()
|
||||
) {
|
||||
userInfo.email_verified = payload['email_verified'];
|
||||
}
|
||||
return userInfo;
|
||||
}
|
||||
|
||||
@@ -325,4 +349,18 @@ export class AuthService {
|
||||
private get callbackUrl(): string {
|
||||
return new URL('/api/auth/callback', this.config.appBaseUrl).toString();
|
||||
}
|
||||
|
||||
private safeReturnPath(value: string | undefined): string | null {
|
||||
if (
|
||||
!value ||
|
||||
value.length > 500 ||
|
||||
!value.startsWith('/') ||
|
||||
value.startsWith('//') ||
|
||||
value.includes('\\') ||
|
||||
/^[a-z][a-z0-9+.-]*:/i.test(value)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ export class OidcLoginStateEntity {
|
||||
@Column({ type: 'varchar', length: 160 })
|
||||
nonce!: string;
|
||||
|
||||
@Column({ name: 'return_path', type: 'varchar', length: 500, nullable: true })
|
||||
returnPath!: string | null;
|
||||
|
||||
@Column({ name: 'expires_at', type: 'datetime', precision: 3 })
|
||||
expiresAt!: Date;
|
||||
|
||||
|
||||
@@ -20,4 +20,5 @@ export interface OidcUserInfo {
|
||||
sub: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
email_verified?: boolean;
|
||||
}
|
||||
|
||||
@@ -46,9 +46,18 @@ export class ApiExceptionFilter implements ExceptionFilter {
|
||||
? message
|
||||
: 'Die Anfrage konnte nicht verarbeitet werden.';
|
||||
} else if (exception instanceof QueryFailedError) {
|
||||
status = HttpStatus.CONFLICT;
|
||||
code = ErrorCode.Conflict;
|
||||
message = 'Die Aenderung steht im Konflikt mit bestehenden Daten.';
|
||||
const driverCode = this.driverCode(exception.driverError as unknown);
|
||||
if (
|
||||
[
|
||||
'ER_DUP_ENTRY',
|
||||
'ER_ROW_IS_REFERENCED_2',
|
||||
'ER_NO_REFERENCED_ROW_2',
|
||||
].includes(driverCode ?? '')
|
||||
) {
|
||||
status = HttpStatus.CONFLICT;
|
||||
code = ErrorCode.Conflict;
|
||||
message = 'Die Aenderung steht im Konflikt mit bestehenden Daten.';
|
||||
}
|
||||
}
|
||||
|
||||
if (status >= 500) {
|
||||
@@ -63,4 +72,13 @@ export class ApiExceptionFilter implements ExceptionFilter {
|
||||
...(validation ? { validation } : {}),
|
||||
} satisfies ApiErrorBody);
|
||||
}
|
||||
|
||||
private driverCode(driverError: unknown): string | undefined {
|
||||
return typeof driverError === 'object' &&
|
||||
driverError !== null &&
|
||||
'code' in driverError &&
|
||||
typeof driverError.code === 'string'
|
||||
? driverError.code
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,5 +25,15 @@ export enum ErrorCode {
|
||||
SystemRoleProtected = 'SYSTEM_ROLE_PROTECTED',
|
||||
UnknownPermission = 'UNKNOWN_PERMISSION',
|
||||
SessionNotFound = 'SESSION_NOT_FOUND',
|
||||
ProjectNotFound = 'PROJECT_NOT_FOUND',
|
||||
ProjectAccessDenied = 'PROJECT_ACCESS_DENIED',
|
||||
ProjectMemberNotFound = 'PROJECT_MEMBER_NOT_FOUND',
|
||||
ProjectMemberAlreadyExists = 'PROJECT_MEMBER_ALREADY_EXISTS',
|
||||
ProjectOwnerProtected = 'PROJECT_OWNER_PROTECTED',
|
||||
ProjectInvitationNotFound = 'PROJECT_INVITATION_NOT_FOUND',
|
||||
ProjectInvitationAlreadyExists = 'PROJECT_INVITATION_ALREADY_EXISTS',
|
||||
ProjectInvitationExpired = 'PROJECT_INVITATION_EXPIRED',
|
||||
ProjectInvitationEmailMismatch = 'PROJECT_INVITATION_EMAIL_MISMATCH',
|
||||
ProjectInvitationEmailUnverified = 'PROJECT_INVITATION_EMAIL_UNVERIFIED',
|
||||
InternalError = 'INTERNAL_ERROR',
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ export class AppConfigService {
|
||||
swaggerEnabled: this.nestConfig.getOrThrow('swaggerEnabled', {
|
||||
infer: true,
|
||||
}),
|
||||
documents: this.nestConfig.getOrThrow('documents', { infer: true }),
|
||||
reminders: this.nestConfig.getOrThrow('reminders', { infer: true }),
|
||||
rateLimit: this.nestConfig.getOrThrow('rateLimit', { infer: true }),
|
||||
};
|
||||
}
|
||||
@@ -82,6 +84,14 @@ export class AppConfigService {
|
||||
return this.config.swaggerEnabled;
|
||||
}
|
||||
|
||||
get documents(): AppConfig['documents'] {
|
||||
return this.config.documents;
|
||||
}
|
||||
|
||||
get reminders(): AppConfig['reminders'] {
|
||||
return this.config.reminders;
|
||||
}
|
||||
|
||||
get rateLimit(): AppConfig['rateLimit'] {
|
||||
return this.config.rateLimit;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,14 @@ export interface AppConfig {
|
||||
csrfHeaderName: string;
|
||||
logLevel: string;
|
||||
swaggerEnabled: boolean;
|
||||
documents: {
|
||||
storagePath: string;
|
||||
maxFileSizeBytes: number;
|
||||
};
|
||||
reminders: {
|
||||
intervalMs: number;
|
||||
dueSoonDays: number;
|
||||
};
|
||||
rateLimit: {
|
||||
global: RateLimitRuleConfig;
|
||||
sensitive: RateLimitRuleConfig;
|
||||
|
||||
@@ -71,6 +71,15 @@ const envSchema = z.object({
|
||||
CSRF_HEADER_NAME: z.string().min(1).default('X-CSRF-Token'),
|
||||
LOG_LEVEL: z.string().min(1).default('info'),
|
||||
SWAGGER_ENABLED: booleanFromString.default(false),
|
||||
DOCUMENT_STORAGE_PATH: z.string().min(1).default('storage/documents'),
|
||||
DOCUMENT_MAX_FILE_SIZE_BYTES: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1024)
|
||||
.max(50 * 1024 * 1024)
|
||||
.default(10 * 1024 * 1024),
|
||||
REMINDER_INTERVAL_MS: z.coerce.number().int().min(60000).default(900000),
|
||||
REMINDER_DUE_SOON_DAYS: z.coerce.number().int().min(1).max(30).default(3),
|
||||
RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().min(1).default(60),
|
||||
RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().min(1).default(300),
|
||||
RATE_LIMIT_SENSITIVE_WINDOW_SECONDS: z.coerce
|
||||
@@ -138,6 +147,14 @@ export function loadConfigFromEnv(env: Record<string, unknown>): AppConfig {
|
||||
csrfHeaderName: value.CSRF_HEADER_NAME,
|
||||
logLevel: value.LOG_LEVEL,
|
||||
swaggerEnabled: value.SWAGGER_ENABLED,
|
||||
documents: {
|
||||
storagePath: value.DOCUMENT_STORAGE_PATH,
|
||||
maxFileSizeBytes: value.DOCUMENT_MAX_FILE_SIZE_BYTES,
|
||||
},
|
||||
reminders: {
|
||||
intervalMs: value.REMINDER_INTERVAL_MS,
|
||||
dueSoonDays: value.REMINDER_DUE_SOON_DAYS,
|
||||
},
|
||||
rateLimit: {
|
||||
global: {
|
||||
windowSeconds: value.RATE_LIMIT_WINDOW_SECONDS,
|
||||
|
||||
@@ -2,20 +2,70 @@ import { AuditLogEntity } from '../audit/entities/audit-log.entity';
|
||||
import { OidcLoginStateEntity } from '../auth/entities/oidc-login-state.entity';
|
||||
import { ItemEntity } from '../items/entities/item.entity';
|
||||
import { NotificationEntity } from '../notifications/entities/notification.entity';
|
||||
import { ProjectActivityEntity } from '../projects/entities/project-activity.entity';
|
||||
import { ProjectInvitationEntity } from '../projects/entities/project-invitation.entity';
|
||||
import { ProjectMembershipEntity } from '../projects/entities/project-membership.entity';
|
||||
import { ProjectEntity } from '../projects/entities/project.entity';
|
||||
import { RoleEntity } from '../roles/entities/role.entity';
|
||||
import { PermissionEntity } from '../roles/entities/permission.entity';
|
||||
import { SessionEntity } from '../sessions/entities/session.entity';
|
||||
import { UserSettingsEntity } from '../users/entities/user-settings.entity';
|
||||
import { UserEntity } from '../users/entities/user.entity';
|
||||
import {
|
||||
BudgetCategoryEntity,
|
||||
BuildingEntity,
|
||||
ChecklistItemEntity,
|
||||
ExpenseEntity,
|
||||
FloorEntity,
|
||||
MilestoneEntity,
|
||||
ProjectDocumentEntity,
|
||||
RenovationTaskEntity,
|
||||
RoomEntity,
|
||||
TaskCommentEntity,
|
||||
TaskDependencyEntity,
|
||||
TaskCommentMentionEntity,
|
||||
ReminderDeliveryEntity,
|
||||
AppliedProjectTemplateEntity,
|
||||
} from '../renovation/entities/renovation.entities';
|
||||
import {
|
||||
FurnitureOptionDocumentEntity,
|
||||
FurnitureOptionEntity,
|
||||
FurnitureRequirementEntity,
|
||||
FurnitureScenarioEntity,
|
||||
FurnitureScenarioSelectionEntity,
|
||||
} from '../renovation/entities/furniture.entities';
|
||||
|
||||
export const entities = [
|
||||
AuditLogEntity,
|
||||
OidcLoginStateEntity,
|
||||
ItemEntity,
|
||||
NotificationEntity,
|
||||
ProjectEntity,
|
||||
ProjectMembershipEntity,
|
||||
ProjectInvitationEntity,
|
||||
ProjectActivityEntity,
|
||||
RoleEntity,
|
||||
PermissionEntity,
|
||||
SessionEntity,
|
||||
UserSettingsEntity,
|
||||
UserEntity,
|
||||
BuildingEntity,
|
||||
FloorEntity,
|
||||
RoomEntity,
|
||||
RenovationTaskEntity,
|
||||
ChecklistItemEntity,
|
||||
TaskDependencyEntity,
|
||||
TaskCommentEntity,
|
||||
MilestoneEntity,
|
||||
BudgetCategoryEntity,
|
||||
ExpenseEntity,
|
||||
ProjectDocumentEntity,
|
||||
TaskCommentMentionEntity,
|
||||
ReminderDeliveryEntity,
|
||||
AppliedProjectTemplateEntity,
|
||||
FurnitureRequirementEntity,
|
||||
FurnitureOptionEntity,
|
||||
FurnitureScenarioEntity,
|
||||
FurnitureScenarioSelectionEntity,
|
||||
FurnitureOptionDocumentEntity,
|
||||
];
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -4,13 +4,17 @@ export class AddRoleDescription1720000002000 implements MigrationInterface {
|
||||
name = 'AddRoleDescription1720000002000';
|
||||
|
||||
async up(queryRunner: QueryRunner): Promise<void> {
|
||||
if (await queryRunner.hasColumn('roles', 'description')) {
|
||||
return;
|
||||
}
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE roles
|
||||
ADD description varchar(255) NOT NULL DEFAULT ''
|
||||
`);
|
||||
}
|
||||
|
||||
async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query('ALTER TABLE roles DROP COLUMN description');
|
||||
async down(): Promise<void> {
|
||||
// The current baseline schema already owns this column. Removing it here
|
||||
// would corrupt databases created by InitialSchema1720000000000.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
21
apps/backend/src/database/run-development-seed.ts
Normal file
21
apps/backend/src/database/run-development-seed.ts
Normal 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();
|
||||
@@ -5,6 +5,11 @@ import { entities } from './entities';
|
||||
import { InitialSchema1720000000000 } from './migrations/1720000000000-InitialSchema';
|
||||
import { AddNotifications1720000001000 } from './migrations/1720000001000-AddNotifications';
|
||||
import { AddRoleDescription1720000002000 } from './migrations/1720000002000-AddRoleDescription';
|
||||
import { AddHausPilotProjects1720000003000 } from './migrations/1720000003000-AddHausPilotProjects';
|
||||
import { AddRenovationDomain1720000004000 } from './migrations/1720000004000-AddRenovationDomain';
|
||||
import { AddMentionsAndReminders1720000005000 } from './migrations/1720000005000-AddMentionsAndReminders';
|
||||
import { AddDefaultProjectFloors1720000006000 } from './migrations/1720000006000-AddDefaultProjectFloors';
|
||||
import { AddFurniturePlanning1720000007000 } from './migrations/1720000007000-AddFurniturePlanning';
|
||||
|
||||
const config = loadConfigForCli();
|
||||
|
||||
@@ -25,5 +30,10 @@ export default new DataSource({
|
||||
InitialSchema1720000000000,
|
||||
AddNotifications1720000001000,
|
||||
AddRoleDescription1720000002000,
|
||||
AddHausPilotProjects1720000003000,
|
||||
AddRenovationDomain1720000004000,
|
||||
AddMentionsAndReminders1720000005000,
|
||||
AddDefaultProjectFloors1720000006000,
|
||||
AddFurniturePlanning1720000007000,
|
||||
],
|
||||
});
|
||||
|
||||
@@ -4,6 +4,11 @@ import { entities } from './entities';
|
||||
import { InitialSchema1720000000000 } from './migrations/1720000000000-InitialSchema';
|
||||
import { AddNotifications1720000001000 } from './migrations/1720000001000-AddNotifications';
|
||||
import { AddRoleDescription1720000002000 } from './migrations/1720000002000-AddRoleDescription';
|
||||
import { AddHausPilotProjects1720000003000 } from './migrations/1720000003000-AddHausPilotProjects';
|
||||
import { AddRenovationDomain1720000004000 } from './migrations/1720000004000-AddRenovationDomain';
|
||||
import { AddMentionsAndReminders1720000005000 } from './migrations/1720000005000-AddMentionsAndReminders';
|
||||
import { AddDefaultProjectFloors1720000006000 } from './migrations/1720000006000-AddDefaultProjectFloors';
|
||||
import { AddFurniturePlanning1720000007000 } from './migrations/1720000007000-AddFurniturePlanning';
|
||||
|
||||
export function typeOrmOptionsFactory(
|
||||
config: AppConfigService,
|
||||
@@ -25,6 +30,11 @@ export function typeOrmOptionsFactory(
|
||||
InitialSchema1720000000000,
|
||||
AddNotifications1720000001000,
|
||||
AddRoleDescription1720000002000,
|
||||
AddHausPilotProjects1720000003000,
|
||||
AddRenovationDomain1720000004000,
|
||||
AddMentionsAndReminders1720000005000,
|
||||
AddDefaultProjectFloors1720000006000,
|
||||
AddFurniturePlanning1720000007000,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,20 @@ export const NotificationType = {
|
||||
ItemCreated: 'item.created',
|
||||
ItemUpdated: 'item.updated',
|
||||
UserRoleChanged: 'user.role-changed',
|
||||
ProjectInvitation: 'project.invitation',
|
||||
TaskAssigned: 'task.assigned',
|
||||
TaskDueSoon: 'task.due-soon',
|
||||
TaskOverdue: 'task.overdue',
|
||||
TaskMention: 'task.mention',
|
||||
MilestoneAtRisk: 'milestone.at-risk',
|
||||
BudgetExceeded: 'budget.exceeded',
|
||||
ExpenseOverdue: 'expense.overdue',
|
||||
InvitationExpiring: 'project.invitation-expiring',
|
||||
FurnitureRequirementAssigned: 'furniture.requirement-assigned',
|
||||
FurnitureStatusChanged: 'furniture.status-changed',
|
||||
FurnitureDeliveryDue: 'furniture.delivery-due',
|
||||
FurnitureDeliveryDelayed: 'furniture.delivery-delayed',
|
||||
FurnitureBudgetExceeded: 'furniture.budget-exceeded',
|
||||
} as const;
|
||||
|
||||
export type NotificationType =
|
||||
|
||||
53
apps/backend/src/projects/dto/project.dto.ts
Normal file
53
apps/backend/src/projects/dto/project.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
101
apps/backend/src/projects/entities/project-invitation.entity.ts
Normal file
101
apps/backend/src/projects/entities/project-invitation.entity.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
53
apps/backend/src/projects/entities/project.entity.ts
Normal file
53
apps/backend/src/projects/entities/project.entity.ts
Normal 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;
|
||||
}
|
||||
34
apps/backend/src/projects/project-access.service.ts
Normal file
34
apps/backend/src/projects/project-access.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
178
apps/backend/src/projects/projects.controller.ts
Normal file
178
apps/backend/src/projects/projects.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
34
apps/backend/src/projects/projects.module.ts
Normal file
34
apps/backend/src/projects/projects.module.ts
Normal 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 {}
|
||||
732
apps/backend/src/projects/projects.service.ts
Normal file
732
apps/backend/src/projects/projects.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
201
apps/backend/src/projects/repositories/projects.repository.ts
Normal file
201
apps/backend/src/projects/repositories/projects.repository.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
236
apps/backend/src/projects/tests/projects.service.spec.ts
Normal file
236
apps/backend/src/projects/tests/projects.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
1044
apps/backend/src/renovation/development-seed.service.ts
Normal file
1044
apps/backend/src/renovation/development-seed.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
101
apps/backend/src/renovation/document-storage.service.ts
Normal file
101
apps/backend/src/renovation/document-storage.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
231
apps/backend/src/renovation/dto/furniture.dto.ts
Normal file
231
apps/backend/src/renovation/dto/furniture.dto.ts
Normal 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';
|
||||
}
|
||||
296
apps/backend/src/renovation/dto/renovation.dto.ts
Normal file
296
apps/backend/src/renovation/dto/renovation.dto.ts
Normal 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[];
|
||||
}
|
||||
426
apps/backend/src/renovation/entities/furniture.entities.ts
Normal file
426
apps/backend/src/renovation/entities/furniture.entities.ts
Normal 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;
|
||||
}
|
||||
445
apps/backend/src/renovation/entities/renovation.entities.ts
Normal file
445
apps/backend/src/renovation/entities/renovation.entities.ts
Normal 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;
|
||||
}
|
||||
38
apps/backend/src/renovation/furniture-pricing.ts
Normal file
38
apps/backend/src/renovation/furniture-pricing.ts
Normal 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')}`;
|
||||
}
|
||||
299
apps/backend/src/renovation/furniture.controller.ts
Normal file
299
apps/backend/src/renovation/furniture.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
265
apps/backend/src/renovation/furniture.repository.ts
Normal file
265
apps/backend/src/renovation/furniture.repository.ts
Normal 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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
1332
apps/backend/src/renovation/furniture.service.ts
Normal file
1332
apps/backend/src/renovation/furniture.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
15
apps/backend/src/renovation/mentions.ts
Normal file
15
apps/backend/src/renovation/mentions.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
65
apps/backend/src/renovation/progress.ts
Normal file
65
apps/backend/src/renovation/progress.ts
Normal 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);
|
||||
}
|
||||
298
apps/backend/src/renovation/reminder.service.ts
Normal file
298
apps/backend/src/renovation/reminder.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
415
apps/backend/src/renovation/renovation.controller.ts
Normal file
415
apps/backend/src/renovation/renovation.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
98
apps/backend/src/renovation/renovation.module.ts
Normal file
98
apps/backend/src/renovation/renovation.module.ts
Normal 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 {}
|
||||
289
apps/backend/src/renovation/renovation.repository.ts
Normal file
289
apps/backend/src/renovation/renovation.repository.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
1702
apps/backend/src/renovation/renovation.service.ts
Normal file
1702
apps/backend/src/renovation/renovation.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
128
apps/backend/src/renovation/templates.ts
Normal file
128
apps/backend/src/renovation/templates.ts
Normal 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 } : {}),
|
||||
}));
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
43
apps/backend/src/renovation/tests/furniture-pricing.spec.ts
Normal file
43
apps/backend/src/renovation/tests/furniture-pricing.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
24
apps/backend/src/renovation/tests/mentions.spec.ts
Normal file
24
apps/backend/src/renovation/tests/mentions.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
40
apps/backend/src/renovation/tests/progress.spec.ts
Normal file
40
apps/backend/src/renovation/tests/progress.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
143
apps/backend/src/renovation/tests/reminder.service.spec.ts
Normal file
143
apps/backend/src/renovation/tests/reminder.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
39
apps/backend/src/renovation/tests/templates.spec.ts
Normal file
39
apps/backend/src/renovation/tests/templates.spec.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ export enum Permission {
|
||||
NotificationsReadOwn = 'notifications.readOwn',
|
||||
NotificationsUpdateOwn = 'notifications.updateOwn',
|
||||
NotificationsManage = 'notifications.manage',
|
||||
ProjectsUse = 'projects.use',
|
||||
}
|
||||
|
||||
export const allPermissions = Object.values(Permission);
|
||||
|
||||
@@ -189,6 +189,7 @@ export class RolesService {
|
||||
Permission.SessionsReadOwn,
|
||||
Permission.NotificationsReadOwn,
|
||||
Permission.NotificationsUpdateOwn,
|
||||
Permission.ProjectsUse,
|
||||
],
|
||||
true,
|
||||
manager,
|
||||
|
||||
@@ -34,6 +34,9 @@ export class UserEntity {
|
||||
@Column({ type: 'varchar', length: 320, nullable: true })
|
||||
email!: string | null;
|
||||
|
||||
@Column({ name: 'email_verified', type: 'boolean', nullable: true })
|
||||
emailVerified!: boolean | null;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
active!: boolean;
|
||||
|
||||
|
||||
@@ -23,6 +23,19 @@ export class UsersRepository {
|
||||
return this.repo.findOne({ where: { issuer, subject } });
|
||||
}
|
||||
|
||||
async findActiveByNormalizedEmail(
|
||||
normalizedEmail: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<UserEntity | null> {
|
||||
const users = await (manager?.getRepository(UserEntity) ?? this.repo)
|
||||
.createQueryBuilder('user')
|
||||
.where('user.active = :active', { active: true })
|
||||
.andWhere('LOWER(TRIM(user.email)) = :email', { email: normalizedEmail })
|
||||
.take(2)
|
||||
.getMany();
|
||||
return users.length === 1 ? (users[0] ?? null) : null;
|
||||
}
|
||||
|
||||
async search(
|
||||
query: string | undefined,
|
||||
page: number,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node", "vitest"],
|
||||
"types": ["node", "vitest", "multer"],
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"isolatedModules": false,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"endOfLine": "auto",
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.html",
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
"@angular/platform-browser-dynamic": "22.0.6",
|
||||
"@angular/router": "22.0.6",
|
||||
"@boilerplate/api-client": "1.0.0",
|
||||
"ag-grid-angular": "^36.0.1",
|
||||
"ag-grid-community": "^36.0.1",
|
||||
"rxjs": "7.8.2",
|
||||
"tslib": "2.8.1"
|
||||
}
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { LOCALE_ID, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import type { ApplicationConfig } from '@angular/core';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { registerLocaleData } from '@angular/common';
|
||||
import localeDe from '@angular/common/locales/de';
|
||||
import { provideRouter, withComponentInputBinding } from '@angular/router';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import { csrfInterceptor } from './core/csrf.interceptor';
|
||||
import { sessionExpiryInterceptor } from './core/session-expiry.interceptor';
|
||||
import { titleStrategyProvider } from './core/title.strategy';
|
||||
|
||||
registerLocaleData(localeDe);
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideHttpClient(withInterceptors([csrfInterceptor])),
|
||||
{ provide: LOCALE_ID, useValue: 'de-DE' },
|
||||
provideHttpClient(withInterceptors([csrfInterceptor, sessionExpiryInterceptor])),
|
||||
provideRouter(routes, withComponentInputBinding()),
|
||||
titleStrategyProvider,
|
||||
],
|
||||
|
||||
@@ -13,6 +13,46 @@ export const routes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./features/dashboard/dashboard.page').then((m) => m.DashboardPageComponent),
|
||||
},
|
||||
{
|
||||
path: 'projekte',
|
||||
title: 'Projekte',
|
||||
canActivate: [permissionGuard],
|
||||
data: { permissions: ['projects.use'] },
|
||||
loadComponent: () =>
|
||||
import('./features/projects/projects.page').then((m) => m.ProjectsPageComponent),
|
||||
},
|
||||
{
|
||||
path: 'projekte/:id/:section',
|
||||
title: 'HausPilot-Projekt',
|
||||
canActivate: [permissionGuard],
|
||||
data: { permissions: ['projects.use'] },
|
||||
loadComponent: () =>
|
||||
import('./features/projects/project-workspace.page').then(
|
||||
(m) => m.ProjectWorkspacePageComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'projekte/:id',
|
||||
title: 'Projekt',
|
||||
canActivate: [permissionGuard],
|
||||
data: { permissions: ['projects.use'] },
|
||||
loadComponent: () =>
|
||||
import('./features/projects/project-detail.page').then(
|
||||
(m) => m.ProjectDetailPageComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'einladungen',
|
||||
title: 'Einladungen',
|
||||
loadComponent: () =>
|
||||
import('./features/projects/invitations.page').then((m) => m.InvitationsPageComponent),
|
||||
},
|
||||
{
|
||||
path: 'einladungen/:token',
|
||||
title: 'Projekteinladung',
|
||||
loadComponent: () =>
|
||||
import('./features/projects/invitations.page').then((m) => m.InvitationsPageComponent),
|
||||
},
|
||||
{
|
||||
path: 'profil',
|
||||
title: 'Profil',
|
||||
|
||||
@@ -7,6 +7,7 @@ const user: UserDto = {
|
||||
id: 'u1',
|
||||
name: 'Ada',
|
||||
email: 'ada@example.test',
|
||||
emailVerified: true,
|
||||
active: true,
|
||||
lastLoginAt: null,
|
||||
settings: { tablePageSize: 20, sidebarExpanded: true },
|
||||
@@ -32,4 +33,17 @@ describe('AuthService', () => {
|
||||
expect(service.has('items.read')).toBe(true);
|
||||
expect(service.has('users.manage')).toBe(false);
|
||||
});
|
||||
|
||||
it('clears the current user when the server session expires', () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: ApiClientService, useValue: { me: () => of(user) } }],
|
||||
});
|
||||
const service = TestBed.inject(AuthService);
|
||||
service.user.set(user);
|
||||
|
||||
service.clearSessionState();
|
||||
|
||||
expect(service.user()).toBeNull();
|
||||
expect(service.loaded()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,4 +40,9 @@ export class AuthService {
|
||||
has(permission: Permission): boolean {
|
||||
return this.permissions().has(permission);
|
||||
}
|
||||
|
||||
clearSessionState(): void {
|
||||
this.user.set(null);
|
||||
this.loaded.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
21
apps/frontend/src/app/core/session-expiry.interceptor.ts
Normal file
21
apps/frontend/src/app/core/session-expiry.interceptor.ts
Normal 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);
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -10,6 +10,7 @@ const user: UserDto = {
|
||||
id: 'u1',
|
||||
name: 'Ada Lovelace',
|
||||
email: 'ada@example.com',
|
||||
emailVerified: true,
|
||||
active: true,
|
||||
lastLoginAt: '2026-07-16T08:30:00.000Z',
|
||||
settings: { tablePageSize: 20, sidebarExpanded: true },
|
||||
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
568
apps/frontend/src/app/features/projects/hauspilot-api.service.ts
Normal file
568
apps/frontend/src/app/features/projects/hauspilot-api.service.ts
Normal 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 }) });
|
||||
}
|
||||
}
|
||||
225
apps/frontend/src/app/features/projects/invitations.page.ts
Normal file
225
apps/frontend/src/app/features/projects/invitations.page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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')}`;
|
||||
}
|
||||
}
|
||||
262
apps/frontend/src/app/features/projects/project-detail.page.ts
Normal file
262
apps/frontend/src/app/features/projects/project-detail.page.ts
Normal 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.'),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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]]);
|
||||
});
|
||||
});
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
1283
apps/frontend/src/app/features/projects/project-workspace.page.ts
Normal file
1283
apps/frontend/src/app/features/projects/project-workspace.page.ts
Normal file
File diff suppressed because it is too large
Load Diff
150
apps/frontend/src/app/features/projects/projects.page.ts
Normal file
150
apps/frontend/src/app/features/projects/projects.page.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
797
apps/frontend/src/app/features/projects/task-detail.page.ts
Normal file
797
apps/frontend/src/app/features/projects/task-detail.page.ts
Normal 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)"
|
||||
>
|
||||
@{{ 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);
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ interface NavItem {
|
||||
@if (auth.user()) {
|
||||
<ui-icon-button icon="menu" label="Navigation" (pressed)="toggleDrawer()" />
|
||||
}
|
||||
<strong class="brand">Business App</strong>
|
||||
<strong class="brand">HausPilot</strong>
|
||||
@if (auth.user()) {
|
||||
<button
|
||||
class="ui-icon-button notification-button"
|
||||
@@ -47,7 +47,7 @@ interface NavItem {
|
||||
</button>
|
||||
<a class="ui-button ui-button--ghost logout" href="/api/auth/logout">Abmelden</a>
|
||||
} @else {
|
||||
<a class="ui-button ui-button--primary logout" href="/api/auth/login">Anmelden</a>
|
||||
<a class="ui-button ui-button--primary logout" [href]="loginHref()">Anmelden</a>
|
||||
}
|
||||
@if (auth.user() && notificationPanelOpen()) {
|
||||
<app-notification-panel
|
||||
@@ -93,7 +93,7 @@ interface NavItem {
|
||||
<section class="login-panel">
|
||||
<h1>Anmelden</h1>
|
||||
<p>Bitte melden Sie sich ueber den zentralen Identity Provider an.</p>
|
||||
<a class="ui-button ui-button--primary ui-button--mobile-full" href="/api/auth/login"
|
||||
<a class="ui-button ui-button--primary ui-button--mobile-full" [href]="loginHref()"
|
||||
>Mit OIDC anmelden</a
|
||||
>
|
||||
</section>
|
||||
@@ -243,6 +243,8 @@ export class AppShellComponent {
|
||||
);
|
||||
readonly nav: NavItem[] = [
|
||||
{ label: 'Dashboard', path: '/' },
|
||||
{ label: 'Projekte', path: '/projekte', permission: 'projects.use' },
|
||||
{ label: 'Einladungen', path: '/einladungen', permission: 'projects.use' },
|
||||
{ label: 'Profil', path: '/profil' },
|
||||
{ label: 'Sicherheit', path: '/account/security' },
|
||||
{
|
||||
@@ -273,6 +275,11 @@ export class AppShellComponent {
|
||||
return !item.permission || this.auth.has(item.permission);
|
||||
}
|
||||
|
||||
loginHref(): string {
|
||||
const returnTo = this.router.url.startsWith('/einladungen') ? this.router.url : '/';
|
||||
return `/api/auth/login?returnTo=${encodeURIComponent(returnTo)}`;
|
||||
}
|
||||
|
||||
toggleNotifications(): void {
|
||||
this.notificationPanelOpen.update((open) => !open);
|
||||
if (this.notificationPanelOpen()) {
|
||||
|
||||
123
docs/hauspilot-auth-integration.md
Normal file
123
docs/hauspilot-auth-integration.md
Normal 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
215
docs/hauspilot-domain.md
Normal 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
210
package-lock.json
generated
@@ -51,7 +51,8 @@
|
||||
"@nestjs/config": "4.0.4",
|
||||
"@nestjs/core": "11.1.28",
|
||||
"@nestjs/platform-express": "11.1.28",
|
||||
"@nestjs/swagger": "11.2.3",
|
||||
"@nestjs/schedule": "^6.1.3",
|
||||
"@nestjs/swagger": "^11.4.6",
|
||||
"@nestjs/throttler": "6.4.0",
|
||||
"@nestjs/typeorm": "11.0.0",
|
||||
"class-transformer": "0.5.1",
|
||||
@@ -67,10 +68,11 @@
|
||||
"pino-pretty": "13.1.2",
|
||||
"reflect-metadata": "0.2.2",
|
||||
"rxjs": "7.8.2",
|
||||
"typeorm": "0.3.27",
|
||||
"typeorm": "^0.3.31",
|
||||
"zod": "4.1.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/multer": "^2.0.0",
|
||||
"typeorm-ts-node-commonjs": "0.3.20"
|
||||
}
|
||||
},
|
||||
@@ -107,6 +109,8 @@
|
||||
"@angular/platform-browser-dynamic": "22.0.6",
|
||||
"@angular/router": "22.0.6",
|
||||
"@boilerplate/api-client": "1.0.0",
|
||||
"ag-grid-angular": "^36.0.1",
|
||||
"ag-grid-community": "^36.0.1",
|
||||
"rxjs": "7.8.2",
|
||||
"tslib": "2.8.1"
|
||||
}
|
||||
@@ -4540,12 +4544,6 @@
|
||||
"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": {
|
||||
"version": "11.1.28",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.28.tgz",
|
||||
@@ -4586,14 +4584,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/mapped-types": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.1.0.tgz",
|
||||
"integrity": "sha512-W+n+rM69XsFdwORF11UqJahn4J3xi4g/ZEOlJNL6KoW5ygWSmBB2p0S2BZ4FQeS/NDH72e6xIcu35SfJnE8bXw==",
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.1.1.tgz",
|
||||
"integrity": "sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^10.0.0 || ^11.0.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"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -4626,6 +4624,19 @@
|
||||
"@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": {
|
||||
"version": "11.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.9.tgz",
|
||||
@@ -4891,20 +4902,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/swagger": {
|
||||
"version": "11.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.2.3.tgz",
|
||||
"integrity": "sha512-a0xFfjeqk69uHIUpP8u0ryn4cKuHdra2Ug96L858i0N200Hxho+n3j+TlQXyOF4EstLSGjTfxI1Xb2E1lUxeNg==",
|
||||
"version": "11.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.4.6.tgz",
|
||||
"integrity": "sha512-Le136h2WC7HGsd70+WyK1qrm+Zq7kFxBLkYC1JgAVqNRCt8kNh7bMF7Qkn65D5j2t/aks0+VbWmUVlYIwPrs3A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@microsoft/tsdoc": "0.16.0",
|
||||
"@nestjs/mapped-types": "2.1.0",
|
||||
"js-yaml": "4.1.1",
|
||||
"lodash": "4.17.21",
|
||||
"path-to-regexp": "8.3.0",
|
||||
"swagger-ui-dist": "5.30.2"
|
||||
"@nestjs/mapped-types": "2.1.1",
|
||||
"js-yaml": "5.2.1",
|
||||
"lodash": "4.18.1",
|
||||
"path-to-regexp": "8.4.2",
|
||||
"swagger-ui-dist": "5.32.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@fastify/static": "^8.0.0",
|
||||
"@fastify/static": "^8.0.0 || ^9.0.0 || ^10.0.0",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"class-transformer": "*",
|
||||
@@ -4923,14 +4934,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/swagger/node_modules/path-to-regexp": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
|
||||
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
|
||||
"node_modules/@nestjs/swagger/node_modules/js-yaml": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.1.tgz",
|
||||
"integrity": "sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/testing": {
|
||||
@@ -6382,6 +6405,12 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
||||
@@ -6396,6 +6425,16 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "24.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
|
||||
@@ -7143,6 +7182,42 @@
|
||||
"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": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz",
|
||||
@@ -8322,6 +8397,23 @@
|
||||
"devOptional": true,
|
||||
"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": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -10471,6 +10563,7 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
@@ -10770,9 +10863,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"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/lodash.merge": {
|
||||
@@ -10923,6 +11016,15 @@
|
||||
"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": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
@@ -13556,9 +13658,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/swagger-ui-dist": {
|
||||
"version": "5.30.2",
|
||||
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.30.2.tgz",
|
||||
"integrity": "sha512-HWCg1DTNE/Nmapt+0m2EPXFwNKNeKK4PwMjkwveN/zn1cV2Kxi9SURd+m0SpdcSgWEK/O64sf8bzXdtUhigtHA==",
|
||||
"version": "5.32.8",
|
||||
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.8.tgz",
|
||||
"integrity": "sha512-dgMdWXIgnI4zX4OPhKEdWnlDODbgm8W3AX0Ivn/BBqcUh6xZsBxhZMnvk6DJyRz1BTrj8dPxtarmEGgkz30oyA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@scarf/scarf": "=1.4.0"
|
||||
@@ -14068,25 +14170,26 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typeorm": {
|
||||
"version": "0.3.27",
|
||||
"resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.27.tgz",
|
||||
"integrity": "sha512-pNV1bn+1n8qEe8tUNsNdD8ejuPcMAg47u2lUGnbsajiNUr3p2Js1XLKQjBMH0yMRMDfdX8T+fIRejFmIwy9x4A==",
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.31.tgz",
|
||||
"integrity": "sha512-6u9EFtdLBgHjnPm78NStVeM+I/1MolTzKykDDcydzKUkh6E++YS6XViU/fePJbvDvEGU4Xq34KOM/CLeer9I2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sqltools/formatter": "^1.2.5",
|
||||
"ansis": "^3.17.0",
|
||||
"ansis": "^4.3.1",
|
||||
"app-root-path": "^3.1.0",
|
||||
"buffer": "^6.0.3",
|
||||
"dayjs": "^1.11.13",
|
||||
"debug": "^4.4.0",
|
||||
"dedent": "^1.6.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"glob": "^10.4.5",
|
||||
"dayjs": "^1.11.21",
|
||||
"debug": "^4.4.3",
|
||||
"dedent": "^1.7.2",
|
||||
"dotenv": "^16.6.1",
|
||||
"glob": "^10.5.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"sha.js": "^2.4.12",
|
||||
"sql-highlight": "^6.0.0",
|
||||
"sql-highlight": "^6.1.0",
|
||||
"tslib": "^2.8.1",
|
||||
"uuid": "^11.1.0",
|
||||
"yargs": "^17.7.2"
|
||||
"uuid": "^11.1.1",
|
||||
"yargs": "^17.7.3"
|
||||
},
|
||||
"bin": {
|
||||
"typeorm": "cli.js",
|
||||
@@ -14100,21 +14203,20 @@
|
||||
"url": "https://opencollective.com/typeorm"
|
||||
},
|
||||
"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",
|
||||
"better-sqlite3": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0",
|
||||
"ioredis": "^5.0.4",
|
||||
"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",
|
||||
"oracledb": "^6.3.0",
|
||||
"oracledb": "^6.3.0 || ^7.0.0",
|
||||
"pg": "^8.5.1",
|
||||
"pg-native": "^3.0.0",
|
||||
"pg-query-stream": "^4.0.0",
|
||||
"redis": "^3.1.1 || ^4.0.0 || ^5.0.14",
|
||||
"reflect-metadata": "^0.1.14 || ^0.2.0",
|
||||
"sql.js": "^1.4.0",
|
||||
"sqlite3": "^5.0.3",
|
||||
"sqlite3": "^5.0.3 || ^6.0.0",
|
||||
"ts-node": "^10.7.0",
|
||||
"typeorm-aurora-data-api-driver": "^2.0.0 || ^3.0.0"
|
||||
},
|
||||
@@ -14180,9 +14282,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typeorm/node_modules/ansis": {
|
||||
"version": "3.17.0",
|
||||
"resolved": "https://registry.npmjs.org/ansis/-/ansis-3.17.0.tgz",
|
||||
"integrity": "sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==",
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/ansis/-/ansis-4.3.1.tgz",
|
||||
"integrity": "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"migration:status": "npm --workspace apps/backend run migration:status",
|
||||
"migration:run": "npm --workspace apps/backend run migration:run",
|
||||
"migration:generate": "npm --workspace apps/backend run migration:generate",
|
||||
"seed:development": "npm --workspace apps/backend run seed:development --",
|
||||
"docker:build": "docker build ."
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -14,6 +14,10 @@ import type {
|
||||
NotificationStatusFilter,
|
||||
PageDto,
|
||||
Permission,
|
||||
ProjectDto,
|
||||
ProjectInvitationDto,
|
||||
ProjectMemberDto,
|
||||
ProjectRole,
|
||||
RoleDto,
|
||||
SaveItemDto,
|
||||
SessionDto,
|
||||
@@ -49,6 +53,104 @@ export class ApiClientService {
|
||||
}>(`${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(
|
||||
query: {
|
||||
search?: string;
|
||||
|
||||
@@ -14,7 +14,8 @@ export type Permission =
|
||||
| 'sessions.manage'
|
||||
| 'notifications.readOwn'
|
||||
| 'notifications.updateOwn'
|
||||
| 'notifications.manage';
|
||||
| 'notifications.manage'
|
||||
| 'projects.use';
|
||||
|
||||
export interface ApiErrorBody {
|
||||
status: number;
|
||||
@@ -35,6 +36,7 @@ export interface UserDto {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
emailVerified: boolean | null;
|
||||
active: boolean;
|
||||
lastLoginAt: string | null;
|
||||
roles: RoleDto[];
|
||||
@@ -85,6 +87,40 @@ export interface RoleDto {
|
||||
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 {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -124,7 +160,12 @@ export interface AuditLogDto {
|
||||
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';
|
||||
|
||||
|
||||
@@ -26,7 +26,8 @@ export type Permission =
|
||||
| 'sessions.manage'
|
||||
| 'notifications.readOwn'
|
||||
| 'notifications.updateOwn'
|
||||
| 'notifications.manage';
|
||||
| 'notifications.manage'
|
||||
| 'projects.use';
|
||||
|
||||
export interface ApiErrorBody {
|
||||
status: number;
|
||||
@@ -47,6 +48,7 @@ export interface UserDto {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
emailVerified: boolean | null;
|
||||
active: boolean;
|
||||
lastLoginAt: string | null;
|
||||
roles: RoleDto[];
|
||||
@@ -97,6 +99,40 @@ export interface RoleDto {
|
||||
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 {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -140,7 +176,8 @@ export type NotificationType =
|
||||
| 'system'
|
||||
| 'item.created'
|
||||
| 'item.updated'
|
||||
| 'user.role-changed';
|
||||
| 'user.role-changed'
|
||||
| 'project.invitation';
|
||||
|
||||
export type NotificationStatusFilter = 'all' | 'read' | 'unread';
|
||||
|
||||
@@ -188,6 +225,10 @@ import type {
|
||||
NotificationStatusFilter,
|
||||
PageDto,
|
||||
Permission,
|
||||
ProjectDto,
|
||||
ProjectInvitationDto,
|
||||
ProjectMemberDto,
|
||||
ProjectRole,
|
||||
RoleDto,
|
||||
SaveItemDto,
|
||||
SessionDto,
|
||||
@@ -226,6 +267,106 @@ export class ApiClientService {
|
||||
}>(\`\${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(
|
||||
query: {
|
||||
search?: string;
|
||||
|
||||
Reference in New Issue
Block a user