generated from bastian/boilerplate
mvp
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user