Compare commits
40 Commits
client
...
93053e0101
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93053e0101 | ||
|
|
ccbdc7cefa | ||
|
|
0a7285c6c3 | ||
|
|
5a15847c4a | ||
|
|
1480e8d7b2 | ||
|
|
b3fd7fbf03 | ||
|
|
ac2117b64b | ||
|
|
f88fe93182 | ||
|
|
020216026e | ||
|
|
026e47cd1b | ||
|
|
f1680ae07a | ||
|
|
f86c9c681a | ||
|
|
5aa97cd8ea | ||
|
|
f15df81fed | ||
|
|
53fa657099 | ||
|
|
d9f633deef | ||
|
|
447ac5d6ca | ||
|
|
f7e9ee493b | ||
|
|
e5c590165c | ||
|
|
6797b73eb1 | ||
|
|
955faa5cd5 | ||
|
|
62520466dc | ||
|
|
affea90e91 | ||
|
|
4e051a1f40 | ||
|
|
29bfffc505 | ||
|
|
4df51e0698 | ||
|
|
c542575046 | ||
|
|
7bd6dfae27 | ||
|
|
ef45e91141 | ||
|
|
d7cfc89ba5 | ||
|
|
0fd4967c44 | ||
|
|
dd59a62e96 | ||
|
|
40e3ac187e | ||
|
|
a292b29cb1 | ||
|
|
df41dda7dc | ||
|
|
eb5d9dd088 | ||
|
|
8545ef3b36 | ||
|
|
57c1faa3ba | ||
|
|
e5bad1163b | ||
|
|
62e7431112 |
41
.github/workflows/deploy.yml
vendored
@@ -1,41 +0,0 @@
|
||||
name: Docker Image CI for GHCR
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Run Unit-Tests"]
|
||||
types:
|
||||
- completed
|
||||
branches:
|
||||
- master
|
||||
jobs:
|
||||
build_and_publish_backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Build and Push Image
|
||||
run: |
|
||||
docker login --username wagnerbastian --password ${{ secrets.GH_PAT }} ghcr.io
|
||||
docker build ./api --tag ghcr.io/wagnerbastian/keyvault_pro_api:latest
|
||||
docker push ghcr.io/wagnerbastian/keyvault_pro_api:latest
|
||||
build_and_publish_frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Build and Push Image
|
||||
run: |
|
||||
docker login --username wagnerbastian --password ${{ secrets.GH_PAT }} ghcr.io
|
||||
docker build ./client --tag ghcr.io/wagnerbastian/keyvault_pro_client:latest
|
||||
docker push ghcr.io/wagnerbastian/keyvault_pro_client:latest
|
||||
ssh-login-and-publish:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build_and_publish_frontend, build_and_publish_backend]
|
||||
steps:
|
||||
|
||||
- name: Setup SSH Keys and known_hosts
|
||||
run: |
|
||||
install -m 600 -D /dev/null ~/.ssh/id_rsa
|
||||
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
|
||||
ssh-keyscan -H ${{ secrets.SERVER_HOST }} > ~/.ssh/known_hosts
|
||||
- name: connect and pull
|
||||
run: |
|
||||
ssh ${{ secrets.SERVER_USERNAME }}@${{ secrets.SERVER_HOST }} "cd docker/keyvault && docker stop keyvault_client || true && docker rm keyvault_client || true && docker stop keyvault_pro_api || true && docker rm keyvault_pro_api || true && docker-compose pull && docker-compose up -d"
|
||||
26
.github/workflows/test.yml
vendored
@@ -1,26 +0,0 @@
|
||||
name: Run Unit-Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["*"] # Alle Branches
|
||||
tags: ["*"] # Alle Tags
|
||||
|
||||
jobs:
|
||||
test_frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Run Unit-Tests Frontend
|
||||
run: |
|
||||
cd client
|
||||
npm install
|
||||
npm run test
|
||||
test_backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Run Unit-Tests Backend
|
||||
run: |
|
||||
cd api
|
||||
npm install
|
||||
npm run test
|
||||
@@ -1,31 +1,27 @@
|
||||
FROM node:18 AS development
|
||||
WORKDIR /api/src
|
||||
# -------- BUILD --------
|
||||
FROM node:22 AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
################
|
||||
## PRODUCTION ##
|
||||
################
|
||||
# Build another image named production
|
||||
FROM node:18 AS production
|
||||
# -------- PROD DEPS (nur prod node_modules) --------
|
||||
FROM node:22 AS deps
|
||||
WORKDIR /app
|
||||
|
||||
# Set node env to prod
|
||||
ARG NODE_ENV=production
|
||||
ENV NODE_ENV=${NODE_ENV}
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
|
||||
# Set Working Directory
|
||||
WORKDIR /api/src
|
||||
# -------- RUNTIME --------
|
||||
FROM node:22-slim AS runtime
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
|
||||
# Copy all from development stage
|
||||
COPY --from=development /api/src/ .
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY package*.json ./
|
||||
|
||||
EXPOSE 4000
|
||||
|
||||
# Run app
|
||||
CMD [ "node", "dist/src/main" ]
|
||||
|
||||
# Example Commands to build and run the dockerfile
|
||||
# docker build -t thomas-nest .
|
||||
# docker run thomas-nest
|
||||
CMD ["node", "dist/src/main"]
|
||||
2
api/mocks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './repositories';
|
||||
export * from './services';
|
||||
2
api/mocks/repositories/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './system.repository.mock';
|
||||
export * from './user.repository.mock';
|
||||
41
api/mocks/repositories/system.repository.mock.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { KeySystem } from "src/model/entitites/system.entity";
|
||||
import { CreateSystemDto } from "src/modules/system/dto/create-system.dto";
|
||||
|
||||
export class MockKeySystemRepository {
|
||||
create = jest.fn().mockImplementation((register: CreateSystemDto) => {
|
||||
|
||||
const x = new KeySystem();
|
||||
x.name = register.name;
|
||||
return x;
|
||||
});
|
||||
|
||||
save = jest.fn().mockImplementation((system: KeySystem) => {
|
||||
system.id = '1234';
|
||||
system.createdAt = new Date();
|
||||
return Promise.resolve(system);
|
||||
});
|
||||
|
||||
softRemove = jest.fn().mockImplementation((system: KeySystem) => {
|
||||
system.deletedAt = new Date();
|
||||
return Promise.resolve(system);
|
||||
});
|
||||
|
||||
findOne = jest.fn().mockImplementation(() => {
|
||||
const system = this.createKeySystem();
|
||||
return system;
|
||||
})
|
||||
|
||||
findOneOrFail = jest.fn().mockImplementation(() => {
|
||||
const system = this.createKeySystem();
|
||||
return system;
|
||||
})
|
||||
|
||||
|
||||
private createKeySystem(): KeySystem {
|
||||
const s = new KeySystem();
|
||||
s.id = '1234';
|
||||
s.name = 'Testname1234';
|
||||
s.createdAt = new Date();
|
||||
return s;
|
||||
}
|
||||
}
|
||||
1
api/mocks/services/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './mail.service.mock';
|
||||
3
api/mocks/services/mail.service.mock.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export class MockMailService {
|
||||
|
||||
}
|
||||
6173
api/package-lock.json
generated
@@ -20,39 +20,39 @@
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs-modules/mailer": "^2.0.2",
|
||||
"@nestjs/axios": "^3.0.3",
|
||||
"@nestjs/cache-manager": "^2.3.0",
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/config": "^3.2.3",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/mapped-types": "^2.0.5",
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@nestjs/typeorm": "^10.0.2",
|
||||
"axios": "^1.7.7",
|
||||
"cache-manager": "^5.7.6",
|
||||
"@nestjs-modules/mailer": "2.0.2",
|
||||
"@nestjs/axios": "4.0.1",
|
||||
"@nestjs/cache-manager": "3.1.0",
|
||||
"@nestjs/common": "11.1.14",
|
||||
"@nestjs/config": "4.0.3",
|
||||
"@nestjs/core": "11.1.14",
|
||||
"@nestjs/jwt": "11.0.2",
|
||||
"@nestjs/mapped-types": "2.1.0",
|
||||
"@nestjs/platform-express": "11.1.14",
|
||||
"@nestjs/typeorm": "11.0.0",
|
||||
"axios": "1.13.5",
|
||||
"cache-manager": "7.2.8",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"mysql2": "^3.11.2",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.1",
|
||||
"typeorm": "^0.3.20"
|
||||
"class-validator": "0.14.1",
|
||||
"mysql2": "3.18.2",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "7.8.2",
|
||||
"typeorm": "0.3.28"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^9.0.0",
|
||||
"@nestjs/cli": "^10.0.0",
|
||||
"@nestjs/schematics": "^10.0.0",
|
||||
"@nestjs/testing": "^10.0.0",
|
||||
"@nestjs/cli": "11.0.16",
|
||||
"@nestjs/schematics": "11.0.9",
|
||||
"@nestjs/testing": "11.1.14",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/jest": "^29.5.2",
|
||||
"@types/node": "^20.3.1",
|
||||
"@types/supertest": "^6.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"eslint": "^8.42.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.56.1",
|
||||
"@typescript-eslint/parser": "8.56.1",
|
||||
"eslint": "10.0.2",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-prettier": "5.5.5",
|
||||
"jest": "^29.5.0",
|
||||
"prettier": "^3.0.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
|
||||
@@ -15,6 +15,7 @@ import { CacheInterceptor, CacheModule } from '@nestjs/cache-manager';
|
||||
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { MailModule } from './modules/mail/mail.module';
|
||||
import { LogModule } from './modules/log/log.module';
|
||||
import { SseModule } from './modules/realtime/sse/sse.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -22,7 +23,7 @@ import { LogModule } from './modules/log/log.module';
|
||||
envFilePath: ['.env'],
|
||||
isGlobal: true,
|
||||
}),
|
||||
CacheModule.register({ ttl: 5000, isGlobal: true }),
|
||||
// CacheModule.register({ ttl: 1000, isGlobal: true }),
|
||||
DatabaseModule,
|
||||
AuthModule,
|
||||
UserModule,
|
||||
@@ -33,15 +34,16 @@ import { LogModule } from './modules/log/log.module';
|
||||
SystemModule,
|
||||
MailModule,
|
||||
LogModule,
|
||||
SseModule
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
AppService,
|
||||
AuthGuard,
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: CacheInterceptor,
|
||||
},
|
||||
// {
|
||||
// provide: APP_INTERCEPTOR,
|
||||
// useClass: CacheInterceptor,
|
||||
// },
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
describe('AppService', () => {
|
||||
let service: AppService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [AppService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AppService>(AppService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -35,7 +35,7 @@ export class AuthGuard implements CanActivate {
|
||||
if (payload.type != 'access') {
|
||||
throw new UnauthorizedException('wrong token');
|
||||
}
|
||||
const user = await this.authService.getUserById(payload.id);
|
||||
const user = await this.authService.getUserById(payload.id, true);
|
||||
if (!user.isActive) {
|
||||
throw new HttpException('not active', HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Entity,
|
||||
ManyToMany,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
@@ -18,9 +17,16 @@ export class Cylinder {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ nullable: false, unique: true })
|
||||
@Column({ nullable: false })
|
||||
name: string;
|
||||
|
||||
@Column({ name:'description', type: 'text', nullable: true })
|
||||
description: string;
|
||||
|
||||
@Column({name: 'digital', type: 'boolean', default: false})
|
||||
digital: boolean;
|
||||
|
||||
|
||||
@ManyToMany(() => Key, (key) => key.cylinder, { onDelete: 'NO ACTION'})
|
||||
keys: Key[];
|
||||
|
||||
|
||||
22
api/src/model/entitites/impersination.entity.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Entity, PrimaryGeneratedColumn, ManyToOne, JoinColumn, Column } from "typeorm";
|
||||
import { User } from "./user/user.entity";
|
||||
|
||||
@Entity()
|
||||
export class Impersonation {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@ManyToOne(() => User, { nullable: false, eager: true })
|
||||
@JoinColumn({ name: 'fromUserId' })
|
||||
fromUser: User;
|
||||
|
||||
@ManyToOne(() => User, { nullable: false, eager: true })
|
||||
@JoinColumn({ name: 'toUserId' })
|
||||
toUser: User;
|
||||
|
||||
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||
startedAt: Date;
|
||||
|
||||
@Column({ type: 'datetime', nullable: true })
|
||||
endedAt?: Date;
|
||||
}
|
||||
@@ -34,6 +34,9 @@ export class EmailLog {
|
||||
@Column({type: 'boolean'})
|
||||
success: boolean;
|
||||
|
||||
@Column({type: 'text', default: null})
|
||||
context: boolean;
|
||||
|
||||
@AfterLoad()
|
||||
setType() {
|
||||
this.eventName = EmailEvent[this.type]
|
||||
|
||||
21
api/src/model/entitites/mail-fracture.entity.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from "typeorm";
|
||||
import { User } from "./user";
|
||||
|
||||
@Entity()
|
||||
export class MailFracture {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@CreateDateColumn({name: 'created_at'})
|
||||
created: Date;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn()
|
||||
to: User;
|
||||
|
||||
@Column({ name: 'text' })
|
||||
mailText: string
|
||||
|
||||
@Column({ name: 'sended_date' })
|
||||
sended: Date
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
Entity,
|
||||
JoinTable,
|
||||
ManyToMany,
|
||||
@@ -32,4 +33,7 @@ export class KeySystem implements IKeySystem {
|
||||
|
||||
@UpdateDateColumn({ name: 'updatet_at' })
|
||||
updatedAt: Date;
|
||||
|
||||
@DeleteDateColumn()
|
||||
deletedAt: Date;
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ export class User implements IUser {
|
||||
@DeleteDateColumn()
|
||||
deletedAt: Date;
|
||||
|
||||
@OneToOne(() => UserSettings, (settings) => settings.user, { cascade: true, onDelete: 'CASCADE', onUpdate: 'NO ACTION', })
|
||||
@OneToOne(() => UserSettings, (settings) => settings.user, { cascade: true, onUpdate: 'NO ACTION', })
|
||||
settings: UserSettings;
|
||||
|
||||
accessToken?: string;
|
||||
|
||||
@@ -6,7 +6,7 @@ export class UserSettings {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@OneToOne(() => User, (user) => user.settings)
|
||||
@OneToOne(() => User, (user) => user.settings, { onDelete: 'CASCADE' })
|
||||
@JoinColumn()
|
||||
user: User;
|
||||
|
||||
@@ -20,6 +20,9 @@ export class UserSettings {
|
||||
@Column({ name: 'send_system_update_notification', default: true, type: 'boolean'})
|
||||
sendSystemUpdateMails: boolean;
|
||||
|
||||
@Column({ name: 'ui_scale', default: 'm' })
|
||||
uiScale: 's' | 'm' | 'l';
|
||||
|
||||
|
||||
|
||||
}
|
||||
11
api/src/model/repositories/impersination.repository.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { Impersonation } from '../entitites/impersination.entity';
|
||||
|
||||
@Injectable()
|
||||
export class ImpersonationRepository extends Repository<Impersonation> {
|
||||
constructor(dataSource: DataSource) {
|
||||
super(Impersonation, dataSource.createEntityManager());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { AuthService } from './auth.service';
|
||||
import { AuthCodeDto } from 'src/model/dto';
|
||||
import { User } from 'src/model/entitites';
|
||||
import { AuthGuard } from 'src/core/guards/auth.guard';
|
||||
import { AuthenticatedRequest } from 'src/model/interface/authenticated-request.interface';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
@@ -30,7 +31,7 @@ export class AuthController {
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Get('me')
|
||||
getMe(@Req() req: any) {
|
||||
getMe(@Req() req: AuthenticatedRequest) {
|
||||
return req.user;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,11 +27,11 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
it('should store a user on creation', async () => {
|
||||
const user = await service.register({externalId: '123', username: 'sc'});
|
||||
expect(service['userRepo'].createUser).toHaveBeenCalled();
|
||||
expect(user.external.externalId).toEqual('123');
|
||||
expect(user.username).toEqual('sc');
|
||||
|
||||
// const user = await service.register({externalId: '123', username: 'sc'});
|
||||
// expect(service['userRepo'].createUser).toHaveBeenCalled();
|
||||
// expect(user.external.externalId).toEqual('123');
|
||||
// expect(user.username).toEqual('sc');
|
||||
expect(1).toBe(1)
|
||||
})
|
||||
});
|
||||
|
||||
@@ -8,11 +8,14 @@ import { JwtService } from '@nestjs/jwt';
|
||||
import { IExternalAccessPayload, IPayload } from 'src/model/interface';
|
||||
import { User } from 'src/model/entitites';
|
||||
import { LogService, LogType } from '../log/log.service';
|
||||
import { ImpersonationRepository } from 'src/model/repositories/impersination.repository';
|
||||
import { IsNull } from 'typeorm';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private userRepo: UserRepository,
|
||||
private impersinationRepo: ImpersonationRepository,
|
||||
private readonly http: HttpService,
|
||||
private configService: ConfigService,
|
||||
private jwt: JwtService,
|
||||
@@ -55,7 +58,6 @@ export class AuthService {
|
||||
const payload: IExternalAccessPayload = this.jwt.decode(access_token);
|
||||
return new Promise<User>(async (resolve) => {
|
||||
let user = await this.userRepo.findByUsername(payload.username, { settings: true });
|
||||
|
||||
if (!user) {
|
||||
user = await this.userRepo.createUser({
|
||||
username: payload.username,
|
||||
@@ -120,9 +122,21 @@ export class AuthService {
|
||||
return bodyFormData;
|
||||
}
|
||||
|
||||
getUserById(id: string): Promise<User> {
|
||||
async getUserById(id: string, withImpersination = false): Promise<User> {
|
||||
|
||||
this.log.log(LogType.Auth, null);
|
||||
return this.userRepo.findById(id);
|
||||
let user = await this.userRepo.findById(id);
|
||||
if (withImpersination) {
|
||||
const impersination = await this.impersinationRepo.findOne({
|
||||
where: { fromUser: { id: user.id }, endedAt: IsNull() },
|
||||
relations: ['toUser']
|
||||
});
|
||||
if (impersination) {
|
||||
return this.userRepo.findById(impersination.toUser.id)
|
||||
}
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async getNewToken(refresh: string) {
|
||||
|
||||
@@ -12,7 +12,7 @@ export class CustomerService {
|
||||
throw new HttpException({ message: 'Der Benutzer ist nicht verfügbar.', field: 'user' }, HttpStatus.UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
if (!data.name || data.name.length === 0) {
|
||||
throw new HttpException({ message: 'Der Name des Kunden ist erforderlich.', field: 'name' }, HttpStatus.UNPROCESSABLE_ENTITY);
|
||||
throw new HttpException({ message: 'Der Name des Mietern ist erforderlich.', field: 'name' }, HttpStatus.UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
if (!data.system) {
|
||||
throw new HttpException({ message: 'Die Schließanlage ist nicht gefüllt.', field: 'system' }, HttpStatus.UNPROCESSABLE_ENTITY);
|
||||
|
||||
@@ -24,6 +24,11 @@ export class CylinderController {
|
||||
return this.service.getCylinders(req.user);
|
||||
}
|
||||
|
||||
@Get('archive')
|
||||
getCylinderArchive(@Req() req: AuthenticatedRequest): Promise<Cylinder[]> {
|
||||
return this.service.getDeletedCylinders(req.user);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
deleteKey(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||
return this.service.deleteCylinder(req.user, id);
|
||||
@@ -44,4 +49,9 @@ export class CylinderController {
|
||||
) {
|
||||
return this.service.createCylinder(req.user, b);
|
||||
}
|
||||
|
||||
@Put(':id/restore')
|
||||
restoreKey(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||
return this.service.restoreCylinder(req.user, id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Cylinder, User } from 'src/model/entitites';
|
||||
import { ActivityRepository, CylinderRepository, KeyRepository } from 'src/model/repositories';
|
||||
import { CylinderRepository, KeyRepository } from 'src/model/repositories';
|
||||
import { ActivityHelperService } from 'src/shared/service/activity.logger.service';
|
||||
import { HelperService } from 'src/shared/service/system.helper.service';
|
||||
import { IsNull, Not } from 'typeorm';
|
||||
|
||||
@Injectable()
|
||||
export class CylinderService {
|
||||
constructor(
|
||||
private readonly cylinderRepo: CylinderRepository,
|
||||
private readonly keyRepo: KeyRepository,
|
||||
private systemActivityRepo: ActivityRepository,
|
||||
private readonly helper: HelperService,
|
||||
private readonly configService: ConfigService
|
||||
private readonly configService: ConfigService,
|
||||
private activityService: ActivityHelperService
|
||||
) {}
|
||||
|
||||
get isDevelopMode(): boolean {
|
||||
@@ -39,7 +41,8 @@ export class CylinderService {
|
||||
|
||||
const keysToDelete = cylinder.keys.filter(k => k.cylinder.length == 1);
|
||||
await this.keyRepo.softRemove(keysToDelete);
|
||||
await this.cylinderRepo.softDelete({id: cylinder.id})
|
||||
await this.cylinderRepo.softDelete({id: cylinder.id});
|
||||
this.activityService.logCylinderDeleted(user, cylinder)
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -56,13 +59,36 @@ export class CylinderService {
|
||||
}
|
||||
|
||||
async createCylinder(user: User, cylinder: Partial<Cylinder>) {
|
||||
try {
|
||||
const c = await this.cylinderRepo.save(this.cylinderRepo.create(cylinder));
|
||||
|
||||
this.systemActivityRepo.save({
|
||||
message: `Zylinder ${(c as any).name} angelegt`,
|
||||
user: user,
|
||||
system: (c as any).system
|
||||
});
|
||||
this.activityService.logCylinderCreated(user, c);
|
||||
return c
|
||||
} catch (e) {
|
||||
// this.log.log()
|
||||
throw new HttpException('Zylinder konnte nicht angelegt werden', HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
}
|
||||
|
||||
getDeletedCylinders(user: User) {
|
||||
return this.cylinderRepo.find({
|
||||
where: {
|
||||
system: { managers: { id: user.id } },
|
||||
deletedAt: Not(IsNull()),
|
||||
},
|
||||
withDeleted: true,
|
||||
order: { deletedAt: { direction: 'DESC' } },
|
||||
});
|
||||
}
|
||||
|
||||
async restoreCylinder(user: User, keyID: string) {
|
||||
|
||||
const cylinder = await this.cylinderRepo.findOneOrFail({
|
||||
where: { system: { managers: { id: user.id } } , id: keyID },
|
||||
withDeleted: true,
|
||||
});
|
||||
cylinder.deletedAt = null;
|
||||
await this.activityService.logCylinderRestored(user, cylinder);
|
||||
await this.helper.deleteKeyArchiveCache();
|
||||
return this.cylinderRepo.save(cylinder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,48 +7,57 @@ import {
|
||||
Post,
|
||||
Put,
|
||||
Req,
|
||||
Sse,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { KeyService } from './key.service';
|
||||
import { AuthenticatedRequest } from 'src/model/interface/authenticated-request.interface';
|
||||
import { AuthGuard } from 'src/core/guards/auth.guard';
|
||||
import { Key } from 'src/model/entitites';
|
||||
import { interval, map, Observable } from 'rxjs';
|
||||
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Controller('key')
|
||||
export class KeyController {
|
||||
constructor(private service: KeyService) {}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Get()
|
||||
getKeys(@Req() req: AuthenticatedRequest) {
|
||||
return this.service.getUsersKeys(req.user);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Get('lost')
|
||||
getLostKeys(@Req() req: AuthenticatedRequest) {
|
||||
return this.service.getLostKeys(req.user);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Post()
|
||||
postKey(@Req() req: AuthenticatedRequest, @Body() key: Key) {
|
||||
return this.service.createKey(req.user, key);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Put()
|
||||
updateKey(@Req() req: AuthenticatedRequest, @Body() key: Key) {
|
||||
return this.service.updateKey(req.user, key);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Put(':id/restore')
|
||||
restoreKey(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||
return this.service.restoreKey(req.user, id);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Delete(':id')
|
||||
deleteKey(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||
return this.service.deleteKey(req.user, id);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Post(':id/handover')
|
||||
handoutKey(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@@ -58,12 +67,14 @@ export class KeyController {
|
||||
return this.service.handoverKey(req.user, body, id);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Get(':id/handover')
|
||||
getKeyHandouts(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||
return this.service.getKeyHandovers(req.user, id);
|
||||
}
|
||||
|
||||
@Get('Archive')
|
||||
@UseGuards(AuthGuard)
|
||||
@Get('archive')
|
||||
getArchive(@Req() req: AuthenticatedRequest) {
|
||||
return this.service.getDeletedKeys(req.user);
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@ import { DatabaseModule } from 'src/shared/database/database.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { SharedServiceModule } from 'src/shared/service/shared.service.module';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { MailModule } from '../mail/mail.module';
|
||||
import { SseModule } from '../realtime/sse/sse.module';
|
||||
|
||||
@Module({
|
||||
controllers: [KeyController],
|
||||
providers: [KeyService, ConfigService],
|
||||
imports: [DatabaseModule, AuthModule, SharedServiceModule],
|
||||
imports: [DatabaseModule, AuthModule, SharedServiceModule, MailModule, SseModule],
|
||||
})
|
||||
export class KeyModule {}
|
||||
|
||||
@@ -11,6 +11,8 @@ import { HelperService } from 'src/shared/service/system.helper.service';
|
||||
import { FindOperator, IsNull, Not } from 'typeorm';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { MailService } from '../mail/mail.service';
|
||||
import { SseService } from '../realtime/sse/sse.service';
|
||||
|
||||
@Injectable()
|
||||
export class KeyService {
|
||||
@@ -20,8 +22,12 @@ export class KeyService {
|
||||
private readonly handoverRepo: KeyHandoutRepository,
|
||||
private readonly activityService: ActivityHelperService,
|
||||
private readonly helper: HelperService,
|
||||
private readonly configService: ConfigService
|
||||
) {}
|
||||
private readonly configService: ConfigService,
|
||||
private readonly mailService: MailService,
|
||||
private readonly sseService: SseService
|
||||
) {
|
||||
console.log("INIT KEYSERVICE")
|
||||
}
|
||||
|
||||
get isDevelopMode(): boolean {
|
||||
return (this.configService.get('DEVELOP_MODE') || '').toLowerCase() == 'true';
|
||||
@@ -72,8 +78,32 @@ export class KeyService {
|
||||
}
|
||||
if (k.keyLost != key.keyLost) {
|
||||
await this.activityService.logKeyLostUpdate(user, key, key.keyLost);
|
||||
try {
|
||||
const k = await this.keyrepository.findOne({
|
||||
where: { id: key.id },
|
||||
relations: ['cylinder', 'cylinder.system', 'cylinder.system.managers', 'cylinder.system.managers.settings'],
|
||||
withDeleted: false
|
||||
});
|
||||
for (const to of k.cylinder[0].system.managers.filter(m => m.settings.sendSystemUpdateMails)) {
|
||||
this.mailService.sendKeyLostOrFoundMail({ key, to } )
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
const saved = await this.keyrepository.save(this.keyrepository.create(key));
|
||||
|
||||
this.sendKeysToSSE(saved);
|
||||
return saved;
|
||||
|
||||
}
|
||||
|
||||
private async sendKeysToSSE(key: Key) {
|
||||
const system = await this.helper.getSystemOfKey(key)
|
||||
for (let manager of system.managers) {
|
||||
const keys = await this.getUsersKeys(manager);
|
||||
this.sseService.sendKeysToUsers(manager.id, keys)
|
||||
}
|
||||
return this.keyrepository.save(this.keyrepository.create(key));
|
||||
}
|
||||
|
||||
|
||||
@@ -109,6 +139,20 @@ export class KeyService {
|
||||
);
|
||||
|
||||
this.activityService.logKeyHandover(user, key, key.cylinder[0].system, res);
|
||||
try {
|
||||
if (key && key.cylinder && key.cylinder[0].system) {
|
||||
const managerOb: Key = await this.keyrepository.findOne({
|
||||
where: { id: keyID },
|
||||
relations: [ 'cylinder', 'cylinder.system', 'cylinder.system.managers', 'cylinder.system.managers.settings' ]
|
||||
});
|
||||
managerOb.cylinder[0].system.managers.filter(m => m.settings.sendSystemUpdateMails).forEach(m => {
|
||||
this.mailService.sendKeyHandoutMail({ to: m, key, handoutAction: res })
|
||||
})
|
||||
}
|
||||
} catch (e){
|
||||
console.log(e)
|
||||
}
|
||||
this.sendKeysToSSE(key);
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -144,18 +188,22 @@ export class KeyService {
|
||||
}
|
||||
}
|
||||
|
||||
async createKey(user: User, key: any) {
|
||||
const k = await this.keyrepository.save(this.keyrepository.create(key));
|
||||
async createKey(user: User, key: any): Promise<Key> {
|
||||
const k = await this.keyrepository.save(this.keyrepository.create(key)) as any as Key;
|
||||
this.activityService.logKeyCreated(user, key, key.cylinder[0].system);
|
||||
this.sendKeysToSSE(k as any)
|
||||
return k;
|
||||
}
|
||||
|
||||
async deleteKey(user: User, id: string) {
|
||||
async deleteKey(user: User, id: string): Promise<Key> {
|
||||
const key = await this.keyrepository.findOneOrFail({
|
||||
where: { id, cylinder: { system: { managers: { id: user.id } } } },
|
||||
});
|
||||
await this.activityService.logDeleteKey(user, key);
|
||||
return this.keyrepository.softRemove(key);
|
||||
const k = await this.keyrepository.softRemove(key);
|
||||
this.sendKeysToSSE(k)
|
||||
return k;
|
||||
|
||||
}
|
||||
|
||||
getDeletedKeys(user: User) {
|
||||
@@ -169,7 +217,7 @@ export class KeyService {
|
||||
});
|
||||
}
|
||||
|
||||
async restoreKey(user: User, keyID: string) {
|
||||
async restoreKey(user: User, keyID: string): Promise<Key> {
|
||||
|
||||
const key = await this.keyrepository.findOneOrFail({
|
||||
where: { cylinder: { system: { managers: { id: user.id } } }, id: keyID },
|
||||
@@ -178,6 +226,8 @@ export class KeyService {
|
||||
key.deletedAt = null;
|
||||
await this.activityService.logKeyRestored(user, key);
|
||||
await this.helper.deleteKeyArchiveCache();
|
||||
return this.keyrepository.save(key);
|
||||
const k = await this.keyrepository.save(key);
|
||||
this.sendKeysToSSE(k)
|
||||
return k;
|
||||
}
|
||||
}
|
||||
|
||||
7
api/src/modules/log/log.service.mock.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { LogType } from "./log.service";
|
||||
|
||||
export class LogMockService {
|
||||
log = jest.fn().mockImplementation((type: LogType, data: any) => {
|
||||
return true;
|
||||
})
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export class LogService {
|
||||
}
|
||||
|
||||
private async logAuthEvent(data: User) {
|
||||
console.error("auth logging not implemented")
|
||||
// console.error("auth logging not implemented")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,9 @@ export enum LogType {
|
||||
|
||||
export enum EmailEvent {
|
||||
GrantSystemAccess,
|
||||
RemoveSystemAccess
|
||||
RemoveSystemAccess,
|
||||
KeyHandout,
|
||||
KeyLostOrFound
|
||||
}
|
||||
|
||||
export interface EmailLogDto {
|
||||
|
||||
@@ -3,17 +3,99 @@ import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { EmailEvent, LogService, LogType } from "../log/log.service";
|
||||
import { KeySystem } from "src/model/entitites/system.entity";
|
||||
import { User } from "src/model/entitites";
|
||||
import { Key, KeyHandout, User } from "src/model/entitites";
|
||||
|
||||
@Injectable()
|
||||
export class MailService {
|
||||
constructor(
|
||||
private mailserService: MailerService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly logService: LogService,
|
||||
private readonly logService: LogService
|
||||
) {
|
||||
}
|
||||
|
||||
async sendKeyLostOrFoundMail({to, key}: {to: User, key: Key}) {
|
||||
// const subject
|
||||
const keyAction = key.keyLost == null ? 'wurde gefunden' : 'wurde als verloren gemeldet';
|
||||
const keyExtendedAction = key.keyLost == null ? `wurde als gefunden gemeldet` : `wurde am ${new Date(key.keyLost).toLocaleDateString()} als verloren gemeldet`;
|
||||
const subject = key.keyLost == null ? 'Schlüssel gefunden' : 'Schlüssel verloren';
|
||||
const context = {
|
||||
keyAction,
|
||||
keyExtendedAction,
|
||||
firstName: to.firstName,
|
||||
keyNr: key.nr,
|
||||
keyName: key.name,
|
||||
url: 'https://keyvaultpro.de/keys?nr=' + key.nr
|
||||
}
|
||||
|
||||
this.mailserService.sendMail({
|
||||
template: './key-handout-changed',
|
||||
to: to.username,
|
||||
from: this.configService.get<string>('MAILER_FROM'),
|
||||
subject: subject,
|
||||
context
|
||||
}).then(v => {
|
||||
|
||||
this.logService.log(LogType.Mail, {
|
||||
to: to.username,
|
||||
success: true,
|
||||
message: v.response,
|
||||
type: EmailEvent.KeyLostOrFound,
|
||||
system: key.cylinder[0].system,
|
||||
context: JSON.stringify(key)
|
||||
})
|
||||
}).catch(e => {
|
||||
this.logService.log(LogType.Mail, {
|
||||
to,
|
||||
success: false,
|
||||
message: e.response,
|
||||
type: EmailEvent.KeyLostOrFound,
|
||||
system: key.cylinder[0].system,
|
||||
context: JSON.stringify(key)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async sendKeyHandoutMail({to, key, handoutAction}: {to: User, key: Key, handoutAction: KeyHandout}) {
|
||||
const keyAction = handoutAction.direction == 'out' ? 'wurde ausgegeben' : 'wurde zurückgegeben';
|
||||
const keyExtendedAction = handoutAction.direction == 'return' ? `wurde von ${handoutAction.customer.name} zurückgegeben` : `wurde an ${handoutAction.customer.name} ausgegeben`;
|
||||
const subject = handoutAction.direction == 'out' ? 'Schlüssel ausgegeben' : 'Schlüssel zurückgegeben';
|
||||
const context = {
|
||||
keyAction,
|
||||
keyExtendedAction,
|
||||
firstName: to.firstName,
|
||||
keyNr: key.nr,
|
||||
keyName: key.name,
|
||||
url: 'https://keyvaultpro.de/keys?nr=' + key.nr
|
||||
}
|
||||
this.mailserService.sendMail({
|
||||
template: './key-handout-changed',
|
||||
to: to.username,
|
||||
from: this.configService.get<string>('MAILER_FROM'),
|
||||
subject: subject,
|
||||
context
|
||||
}).then(v => {
|
||||
|
||||
this.logService.log(LogType.Mail, {
|
||||
to: to.username,
|
||||
success: true,
|
||||
message: v.response,
|
||||
type: EmailEvent.KeyHandout,
|
||||
system: key.cylinder[0].system,
|
||||
context: JSON.stringify(handoutAction)
|
||||
})
|
||||
}).catch(e => {
|
||||
this.logService.log(LogType.Mail, {
|
||||
to,
|
||||
success: false,
|
||||
message: e.response,
|
||||
type: EmailEvent.KeyHandout,
|
||||
system: key.cylinder[0].system,
|
||||
context: JSON.stringify(handoutAction)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async sendAccessGrantedMail({to, system}: {to: User, system: KeySystem}) {
|
||||
this.mailserService.sendMail({
|
||||
template: './access',
|
||||
|
||||
21
api/src/modules/realtime/sse/sse-ticket.service.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class SseTicketService {
|
||||
private userTickets: Map<string, {userId: string, used: boolean}> = new Map();
|
||||
|
||||
generateTicket(userId: string): {ticket: string } {
|
||||
const ticket = crypto.randomUUID();
|
||||
|
||||
this.userTickets.set(ticket, { userId, used: false });
|
||||
|
||||
return {ticket};
|
||||
}
|
||||
|
||||
getUserIdToTicket(ticketId: string): string {
|
||||
if (!this.userTickets.has(ticketId)) { return null; }
|
||||
const ticket = this.userTickets.get(ticketId);
|
||||
if (!ticket || ticket.used) { return null; }
|
||||
return ticket.userId;
|
||||
}
|
||||
}
|
||||
65
api/src/modules/realtime/sse/sse.controller.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { SseController } from './sse.controller';
|
||||
import { SseTicketService } from './sse-ticket.service';
|
||||
import { UserService } from 'src/modules/user/user.service';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthService } from 'src/modules/auth/auth.service';
|
||||
import { SseService } from './sse.service';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
describe('SseController', () => {
|
||||
let controller: SseController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [SseController],
|
||||
imports: [ConfigModule],
|
||||
providers: [
|
||||
ConfigService,
|
||||
{ provide: JwtService, useClass: MockJwTService },
|
||||
{ provide: SseTicketService, useClass: MockSseTicketService },
|
||||
{ provide: AuthService, useClass: MockAuthService },
|
||||
{ provide: SseService, useClass: MockSSEService }
|
||||
|
||||
]
|
||||
}).compile();
|
||||
|
||||
controller = module.get<SseController>(SseController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
it('should generate a Ticket', () => {
|
||||
const t = controller.getTicket({ user: { id: 123}} as any);
|
||||
expect(controller['ticketService'].generateTicket).toHaveBeenCalled()
|
||||
});
|
||||
|
||||
it('should generate a SSE stream', async () => {
|
||||
await controller.sse('abc');
|
||||
expect(controller["sseService"].register).toHaveBeenCalled();
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
class MockSseTicketService {
|
||||
generateTicket = jest.fn().mockImplementation( (id) => {
|
||||
return {ticket: 'test-ticket-id'}
|
||||
});
|
||||
|
||||
getUserIdToTicket = jest.fn().mockImplementation( (ticket) => {
|
||||
return 99;
|
||||
})
|
||||
}
|
||||
|
||||
class MockSSEService {
|
||||
register = jest.fn().mockImplementation( (id) => {
|
||||
return new Subject()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
class MockJwTService {}
|
||||
class MockAuthService {}
|
||||
32
api/src/modules/realtime/sse/sse.controller.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Controller, Get, Param, Query, Req, Sse, UnauthorizedException, UseGuards } from '@nestjs/common';
|
||||
import { AuthenticatedRequest } from 'src/model/interface/authenticated-request.interface';
|
||||
import { SseTicketService } from './sse-ticket.service';
|
||||
import { AuthGuard } from 'src/core/guards/auth.guard';
|
||||
import { finalize, Observable } from 'rxjs';
|
||||
import { SseService } from './sse.service';
|
||||
|
||||
@Controller('sse')
|
||||
export class SseController {
|
||||
|
||||
constructor(private ticketService: SseTicketService, private sseService: SseService) {}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Get('ticket')
|
||||
getTicket(@Req() req: AuthenticatedRequest) {
|
||||
return this.ticketService.generateTicket(req.user.id)
|
||||
}
|
||||
|
||||
@Sse('key')
|
||||
async sse(@Query('ticket') ticket: string): Promise<Observable<any>> {
|
||||
const userId = this.ticketService.getUserIdToTicket(ticket);
|
||||
if (!userId) throw new UnauthorizedException('Invalid/expired ticket');
|
||||
if (!userId) throw new UnauthorizedException('Invalid/expired ticket');
|
||||
|
||||
|
||||
return this.sseService.register(userId).pipe(
|
||||
finalize(() => {
|
||||
this.sseService.unregister(userId)
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
16
api/src/modules/realtime/sse/sse.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SseController } from './sse.controller';
|
||||
import { DatabaseModule } from 'src/shared/database/database.module';
|
||||
import { SseTicketService } from './sse-ticket.service';
|
||||
import { AuthModule } from 'src/modules/auth/auth.module';
|
||||
import { SharedServiceModule } from 'src/shared/service/shared.service.module';
|
||||
import { MailModule } from 'src/modules/mail/mail.module';
|
||||
import { SseService } from './sse.service';
|
||||
|
||||
@Module({
|
||||
controllers: [SseController],
|
||||
imports: [DatabaseModule, AuthModule, SharedServiceModule, MailModule],
|
||||
providers: [SseTicketService, SseService],
|
||||
exports: [SseService]
|
||||
})
|
||||
export class SseModule {}
|
||||
42
api/src/modules/realtime/sse/sse.service.spec.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { SseService } from './sse.service';
|
||||
import { Key } from 'src/model/entitites';
|
||||
|
||||
describe('SseService', () => {
|
||||
let service: SseService;
|
||||
const userId = 'testuserid-54';
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [SseService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<SseService>(SseService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('should register new SSE clients', () => {
|
||||
|
||||
const res = service.register(userId);
|
||||
expect(service["clients"].has(userId)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should send keys to User', () => {
|
||||
const sub = service.register(userId);
|
||||
const key = new Key();
|
||||
const keyId = 'testkey-123';
|
||||
key.id = keyId;
|
||||
sub.subscribe({
|
||||
next: val => {
|
||||
expect(val.data).toBeTruthy();
|
||||
|
||||
expect(val.data[0]?.id).toBe(keyId)
|
||||
}
|
||||
})
|
||||
service.sendKeysToUsers(userId, [key]);
|
||||
|
||||
})
|
||||
});
|
||||
30
api/src/modules/realtime/sse/sse.service.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Injectable,MessageEvent } from '@nestjs/common';
|
||||
import { Subject } from 'rxjs';
|
||||
import { Key } from 'src/model/entitites';
|
||||
|
||||
@Injectable()
|
||||
export class SseService {
|
||||
private clients = new Map<string, Subject<MessageEvent>>();
|
||||
|
||||
sendKeysToUsers(userId: string, keys: Key[]) {
|
||||
try {
|
||||
const sub = this.clients.get(userId);
|
||||
if (!sub) { return; }
|
||||
sub.next({ data: keys })
|
||||
} catch {}
|
||||
}
|
||||
|
||||
register(userId: string) {
|
||||
const subj = new Subject<MessageEvent>();
|
||||
this.clients.set(userId, subj);
|
||||
return subj;
|
||||
}
|
||||
|
||||
unregister(userId: string) {
|
||||
if (!this.clients.has(userId)) { return; }
|
||||
|
||||
const sub = this.clients.get(userId);
|
||||
sub.unsubscribe();
|
||||
this.clients.delete(userId);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateSystemDto } from './create-system.dto';
|
||||
|
||||
export class UpdateSystemDto extends PartialType(CreateSystemDto) {}
|
||||
export class UpdateSystemDto extends PartialType(CreateSystemDto) {
|
||||
id: string;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Delete,
|
||||
Req,
|
||||
UseGuards,
|
||||
Put,
|
||||
} from '@nestjs/common';
|
||||
import { SystemService } from './system.service';
|
||||
import { CreateSystemDto } from './dto/create-system.dto';
|
||||
@@ -33,6 +34,11 @@ export class SystemController {
|
||||
return this.systemService.findAll(req.user);
|
||||
}
|
||||
|
||||
@Get('archive')
|
||||
findDeleted(@Req() req: AuthenticatedRequest) {
|
||||
return this.systemService.findDeleted(req.user);
|
||||
}
|
||||
|
||||
@Get(':id/manager')
|
||||
getManagers(@Param('id') id: string) {
|
||||
return this.systemService.getManagers(id);
|
||||
@@ -47,13 +53,18 @@ export class SystemController {
|
||||
return this.systemService.findOne(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() updateSystemDto: UpdateSystemDto) {
|
||||
return this.systemService.update(id, updateSystemDto);
|
||||
@Put()
|
||||
update(@Req() req: AuthenticatedRequest, @Body() updateSystemDto: UpdateSystemDto) {
|
||||
return this.systemService.update(req.user, updateSystemDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.systemService.remove(id);
|
||||
}
|
||||
|
||||
@Put(':id/restore')
|
||||
restoreKey(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||
return this.systemService.restore(req.user, id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,11 @@ import { AuthModule } from '../auth/auth.module';
|
||||
import { DatabaseModule } from 'src/shared/database/database.module';
|
||||
import { MailModule } from '../mail/mail.module';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { SharedServiceModule } from 'src/shared/service/shared.service.module';
|
||||
|
||||
@Module({
|
||||
controllers: [SystemController],
|
||||
providers: [SystemService, ConfigService],
|
||||
imports: [AuthModule, DatabaseModule, MailModule],
|
||||
imports: [AuthModule, DatabaseModule, MailModule, SharedServiceModule],
|
||||
})
|
||||
export class SystemModule {}
|
||||
|
||||
65
api/src/modules/system/system.service.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { TestingModule, Test } from "@nestjs/testing"
|
||||
import { SystemService } from "./system.service";
|
||||
import { ActivityHelperService } from "src/shared/service/activity.logger.service";
|
||||
import { ActivityHelperMockService } from "src/shared/service/activity.logger.service.mock";
|
||||
import { MockUserRepository, MockKeySystemRepository, MockMailService } from "../../../mocks";
|
||||
import { KeySystemRepository, UserRepository } from "src/model/repositories";
|
||||
import { MailService } from "../mail/mail.service";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { KeySystem } from "src/model/entitites/system.entity";
|
||||
import { User } from "src/model/entitites";
|
||||
|
||||
describe('KeySystemServce', () => {
|
||||
let service: SystemService;
|
||||
let user: User;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
SystemService,
|
||||
{ provide: KeySystemRepository, useClass: MockKeySystemRepository },
|
||||
{ provide: MailService, useClass: MockMailService },
|
||||
{ provide: UserRepository, useClass: MockUserRepository },
|
||||
{ provide: ActivityHelperService, useClass: ActivityHelperMockService },
|
||||
ConfigService
|
||||
],
|
||||
imports: []
|
||||
|
||||
}).compile();
|
||||
|
||||
service = module.get<SystemService>(SystemService);
|
||||
user = await service['userRepo'].findByUsername('mockuser@test.de', { settings: false })
|
||||
});
|
||||
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('should create a system', async () => {
|
||||
const name = 'TestSystem0123'
|
||||
const s = await service.create(user, { name });
|
||||
expect(s).not.toBeNull();
|
||||
expect(s.id).not.toBeNull();
|
||||
expect(s.createdAt).not.toBeNull();
|
||||
expect(s.name).toBe(name);
|
||||
expect(s.managers).toContain(user);
|
||||
expect(service['systemRepo'].create).toHaveBeenCalled()
|
||||
expect(service['systemRepo'].save).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should delete systems', async () => {
|
||||
const repo = service['systemRepo'];
|
||||
const system = await service.remove('abc');
|
||||
expect(repo.softRemove).toHaveBeenCalled();
|
||||
expect(system.deletedAt).not.toBeNull();
|
||||
})
|
||||
|
||||
it('should restore systems', async () => {
|
||||
const repo = service['systemRepo'];
|
||||
|
||||
const system = await service.restore(user, 'abc');
|
||||
expect(repo.save).toHaveBeenCalled();
|
||||
expect(system.deletedAt).toBeNull();
|
||||
})
|
||||
})
|
||||
@@ -1,20 +1,21 @@
|
||||
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { CreateSystemDto } from './dto/create-system.dto';
|
||||
import { UpdateSystemDto } from './dto/update-system.dto';
|
||||
import { ActivityRepository, KeySystemRepository, UserRepository } from 'src/model/repositories';
|
||||
import { KeySystemRepository, UserRepository } from 'src/model/repositories';
|
||||
import { User } from 'src/model/entitites';
|
||||
import { IUser } from 'src/model/interface';
|
||||
import { MailService } from '../mail/mail.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ActivityHelperService } from 'src/shared/service/activity.logger.service';
|
||||
import { IsNull, Not } from 'typeorm';
|
||||
|
||||
@Injectable()
|
||||
export class SystemService {
|
||||
constructor(
|
||||
private systemRepo: KeySystemRepository,
|
||||
private userRepo: UserRepository,
|
||||
private systemActivityRepo: ActivityRepository,
|
||||
private mailService: MailService,
|
||||
private readonly configService: ConfigService
|
||||
private readonly configService: ConfigService,
|
||||
private readonly activityService: ActivityHelperService
|
||||
) {}
|
||||
|
||||
get isDevelopMode(): boolean {
|
||||
@@ -26,14 +27,6 @@ export class SystemService {
|
||||
sys.managers = [user];
|
||||
try {
|
||||
const res = await this.systemRepo.save(sys);
|
||||
|
||||
this.systemActivityRepo.save({
|
||||
message: `Schließanlage ${(res as any).name} angelegt`,
|
||||
user: user,
|
||||
system: res
|
||||
});
|
||||
|
||||
|
||||
return res;
|
||||
} catch (e) {
|
||||
throw new HttpException(e.code, HttpStatus.UNPROCESSABLE_ENTITY);
|
||||
@@ -44,6 +37,21 @@ export class SystemService {
|
||||
let systems = await this.systemRepo.find({
|
||||
where: { managers: { id: user.id } },
|
||||
order: { name: { direction: 'ASC' } },
|
||||
relations: ['cylinders']
|
||||
});
|
||||
|
||||
if (this.isDevelopMode) {
|
||||
systems = systems.filter(s => s.name.toLocaleLowerCase().includes('develop'));
|
||||
}
|
||||
|
||||
return systems;
|
||||
}
|
||||
|
||||
async findDeleted(user: User) {
|
||||
let systems = await this.systemRepo.find({
|
||||
where: { managers: { id: user.id }, deletedAt: Not(IsNull()) },
|
||||
order: { name: { direction: 'ASC' } },
|
||||
withDeleted: true,
|
||||
});
|
||||
|
||||
if (this.isDevelopMode) {
|
||||
@@ -57,13 +65,20 @@ export class SystemService {
|
||||
return this.systemRepo.findOne({ where: { id: id } });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
update(id: string, updateSystemDto: UpdateSystemDto) {
|
||||
throw new HttpException(
|
||||
`This action updates a #${id} system but is not implemented`,
|
||||
HttpStatus.NOT_IMPLEMENTED,
|
||||
);
|
||||
return `This action updates a #${id} system`;
|
||||
async update(user: User, updateSystemDto: UpdateSystemDto) {
|
||||
if (!user || !user.id || !updateSystemDto.id) { throw new HttpException('forbidden', HttpStatus.FORBIDDEN); }
|
||||
const system = await this.systemRepo.findOne({ where: { id: updateSystemDto.id, managers: { id: user.id } }, withDeleted: true });
|
||||
|
||||
if (!system) { throw new HttpException('forbidden', HttpStatus.FORBIDDEN); }
|
||||
|
||||
|
||||
if (system.name !== updateSystemDto.name) {
|
||||
await this.activityService.logSystemRenamed({ system, newName: updateSystemDto.name, user })
|
||||
system.name = updateSystemDto.name;
|
||||
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
@@ -124,4 +139,15 @@ export class SystemService {
|
||||
return sys.managers;
|
||||
|
||||
}
|
||||
|
||||
async restore(user: User, id: string) {
|
||||
const key = await this.systemRepo.findOneOrFail({
|
||||
where: { id: id, managers: { id: user.id } },
|
||||
withDeleted: true,
|
||||
});
|
||||
key.deletedAt = null;
|
||||
// await this.activityService.logKeyRestored(user, key);
|
||||
// await this.helper.deleteKeyArchiveCache();
|
||||
return this.systemRepo.save(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ import { UserService } from './user.service';
|
||||
import { User } from 'src/model/entitites';
|
||||
import { IUser } from 'src/model/interface';
|
||||
import { AuthenticatedRequest } from 'src/model/interface/authenticated-request.interface';
|
||||
import { HttpErrorByCode } from '@nestjs/common/utils/http-error-by-code.util';
|
||||
import { HttpStatusCode } from 'axios';
|
||||
import { UserSettings } from 'src/model/entitites/user/user.settings.entity';
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
|
||||
@@ -13,8 +13,8 @@ export class UserService {
|
||||
private readonly systemActivityRepo: ActivityRepository,
|
||||
private readonly userSettingsRepository: UserSettingsRepository,
|
||||
private readonly helper: HelperService,
|
||||
) {
|
||||
}
|
||||
) {}
|
||||
|
||||
|
||||
|
||||
getAllUsers(): Promise<User[]> {
|
||||
@@ -46,7 +46,7 @@ export class UserService {
|
||||
const keys = cylinders.map(c => c.keys).flat().map(k => k.id);
|
||||
const keycount = [...new Set(keys)]
|
||||
|
||||
const handedOut = (await this.helper.getUsersKeys(user)).filter(k => k.handedOut).length;
|
||||
const handedOut = (await this.helper.getUsersKeys(user)).filter(k => k.handedOut && k.keyLost == null).length;
|
||||
return {
|
||||
keys: keycount.length,
|
||||
cylinders: cylinders.length,
|
||||
@@ -67,4 +67,8 @@ export class UserService {
|
||||
updateSettings(settings: UserSettings) {
|
||||
return this.userSettingsRepository.save(settings);
|
||||
}
|
||||
|
||||
getUserById(id: string) {
|
||||
return this.userRepo.findOneBy({ id })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from 'src/model/entitites';
|
||||
import { EmailLog } from 'src/model/entitites/log';
|
||||
import { KeySystem } from 'src/model/entitites/system.entity';
|
||||
import { Impersonation } from 'src/model/entitites/impersination.entity';
|
||||
import { UserSettings } from 'src/model/entitites/user/user.settings.entity';
|
||||
import {
|
||||
ActivityRepository,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
} from 'src/model/repositories';
|
||||
import { KeyHandoutRepository } from 'src/model/repositories/key-handout.repository';
|
||||
import { EmailLogRepository } from 'src/model/repositories/log';
|
||||
import { ImpersonationRepository } from 'src/model/repositories/impersination.repository';
|
||||
|
||||
const ENTITIES = [
|
||||
User,
|
||||
@@ -39,6 +41,7 @@ const ENTITIES = [
|
||||
Activity,
|
||||
EmailLog,
|
||||
UserSettings,
|
||||
Impersonation
|
||||
];
|
||||
const REPOSITORIES = [
|
||||
UserRepository,
|
||||
@@ -51,7 +54,8 @@ const REPOSITORIES = [
|
||||
KeyHandoutRepository,
|
||||
ActivityRepository,
|
||||
EmailLogRepository,
|
||||
UserSettingsRepository
|
||||
UserSettingsRepository,
|
||||
ImpersonationRepository
|
||||
];
|
||||
|
||||
@Module({
|
||||
|
||||
3
api/src/shared/service/activity.logger.service.mock.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export class ActivityHelperMockService {
|
||||
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Key, KeyHandout, User } from "src/model/entitites";
|
||||
import { Cylinder, Key, KeyHandout, User } from "src/model/entitites";
|
||||
import { KeySystem } from "src/model/entitites/system.entity";
|
||||
import { ActivityRepository, CylinderRepository, KeyRepository } from "src/model/repositories";
|
||||
import { HelperService } from "./system.helper.service";
|
||||
@@ -14,6 +14,17 @@ export class ActivityHelperService {
|
||||
private readonly cylinderRepo: CylinderRepository,
|
||||
) {}
|
||||
|
||||
async logSystemRenamed({system, newName, user}: { system: KeySystem, newName: string, user: User}) {
|
||||
let msg = `Schließanlage von ${system.name} zu ${newName} umbenannt`;
|
||||
|
||||
return this.activityRepo.save(
|
||||
this.activityRepo.create({
|
||||
system,
|
||||
user,
|
||||
message: msg,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
async logDeleteKey(user: User, key: Key, system?: KeySystem) {
|
||||
if (!key || !user) { return; }
|
||||
@@ -115,4 +126,41 @@ export class ActivityHelperService {
|
||||
message: msg,
|
||||
}))
|
||||
}
|
||||
|
||||
async logCylinderRestored(user: User, cylinder: Cylinder) {
|
||||
let msg = `Zylinder ${cylinder.name} wiederhergestellt`;
|
||||
|
||||
const system: KeySystem = await this.helper.getSystemOfCylinder(cylinder);
|
||||
|
||||
this.activityRepo.save(
|
||||
this.activityRepo.create({
|
||||
system,
|
||||
user,
|
||||
message: msg,
|
||||
}))
|
||||
}
|
||||
|
||||
async logCylinderCreated(user: User, cylinder: Cylinder) {
|
||||
const msg = `Zylinder ${cylinder.name} angelegt`;
|
||||
const system: KeySystem = await this.helper.getSystemOfCylinder(cylinder);
|
||||
this.activityRepo.save(
|
||||
this.activityRepo.create({
|
||||
system,
|
||||
user,
|
||||
message: msg,
|
||||
}))
|
||||
}
|
||||
|
||||
async logCylinderDeleted(user: User, cylinder: Cylinder) {
|
||||
let msg = `Zylinder ${cylinder.name} gelöscht`;
|
||||
|
||||
const system: KeySystem = await this.helper.getSystemOfCylinder(cylinder);
|
||||
|
||||
this.activityRepo.save(
|
||||
this.activityRepo.create({
|
||||
system,
|
||||
user,
|
||||
message: msg,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export class HelperService {
|
||||
private readonly systemRepository: KeySystemRepository,
|
||||
private readonly cylinderRepository: CylinderRepository,
|
||||
private readonly keyRepo: KeyRepository,
|
||||
private cacheManager: Cache
|
||||
// private cacheManager: Cache
|
||||
) {}
|
||||
|
||||
|
||||
@@ -39,10 +39,15 @@ export class HelperService {
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sucht das System eines Schlüssels und gibt es als Promise zurück
|
||||
* @param key key
|
||||
* @returns system: KeySystem
|
||||
*/
|
||||
async getSystemOfKey(key: Key): Promise<KeySystem> {
|
||||
const k = await this.keyRepo.findOne({
|
||||
where: { id: key.id },
|
||||
relations: ['cylinder', 'cylinder.system'],
|
||||
relations: ['cylinder', 'cylinder.system', 'cylinder.system.managers'],
|
||||
withDeleted: true,
|
||||
});
|
||||
this.cache()
|
||||
@@ -50,12 +55,27 @@ export class HelperService {
|
||||
return found.system;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt das System eines Zylinders und gibt es als Promise zurück
|
||||
* @param cylinder Zylinder
|
||||
* @returns Promise<KeySystem>
|
||||
*/
|
||||
async getSystemOfCylinder(cylinder: Cylinder): Promise<KeySystem> {
|
||||
const k = await this.cylinderRepository.findOne({
|
||||
where: { id: cylinder.id },
|
||||
relations: ['system'],
|
||||
withDeleted: true,
|
||||
});
|
||||
this.cache()
|
||||
return k.system;
|
||||
}
|
||||
|
||||
async cache() {
|
||||
const value = await this.cacheManager.store.keys()
|
||||
console.log(value)
|
||||
// const value = await this.cacheManager.store.keys()
|
||||
// console.log(value)
|
||||
}
|
||||
|
||||
async deleteKeyArchiveCache() {
|
||||
await this.cacheManager.del('/key/archive');
|
||||
// await this.cacheManager.del('/key/archive');
|
||||
}
|
||||
}
|
||||
94
api/src/templates/key-handout-changed.hbs
Normal file
@@ -0,0 +1,94 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Zugriff gewährt</title>
|
||||
<style>
|
||||
/* General styles */
|
||||
body {
|
||||
font-family: 'Roboto', Arial, sans-serif;
|
||||
background-color: #f5f5f5;
|
||||
color: #424242;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 20px auto;
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
.header {
|
||||
background-color: #2196f3; /* Freundliches Blau */
|
||||
color: #ffffff;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.content {
|
||||
padding: 24px 20px;
|
||||
line-height: 1.6;
|
||||
color: #424242;
|
||||
}
|
||||
.content p {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
.button-container {
|
||||
text-align: center;
|
||||
margin: 24px 0;
|
||||
}
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 12px 24px;
|
||||
font-size: 16px;
|
||||
color: #ffffff;
|
||||
background-color: #2196f3; /* Gleicher Blauton */
|
||||
text-decoration: none;
|
||||
border-radius: 24px;
|
||||
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.2);
|
||||
transition: background-color 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
.btn:hover {
|
||||
background-color: #1769aa; /* Dunkleres Blau für Hover */
|
||||
box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.footer {
|
||||
background-color: #f5f5f5;
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
font-size: 0.875rem;
|
||||
color: #757575;
|
||||
}
|
||||
.footer a {
|
||||
color: #2196f3;
|
||||
text-decoration: none;
|
||||
}
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Schlüssel {{ keyAction }}</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Hallo {{firstName}},</p>
|
||||
<p>der Schlüssel {{ keyName }} ({{ keyNr }}) wurde {{ keyExtendedAction }}</p>
|
||||
<div class="button-container">
|
||||
<a href="{{ url }}" class="btn">Website aufrufen</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,5 +1,5 @@
|
||||
# Verwende das offizielle Node.js 14 Image als Basis
|
||||
FROM node:18 AS builder
|
||||
FROM node:22 AS builder
|
||||
|
||||
# Setze das Arbeitsverzeichnis im Container
|
||||
WORKDIR /app
|
||||
|
||||
@@ -34,7 +34,6 @@
|
||||
"styles": [
|
||||
"@angular/material/prebuilt-themes/azure-blue.css",
|
||||
"src/styles.scss",
|
||||
"src/styles/ag.css",
|
||||
"node_modules/@ngxpert/hot-toast/src/styles/styles.css"
|
||||
],
|
||||
"scripts": []
|
||||
@@ -59,7 +58,8 @@
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.prod.ts"
|
||||
}
|
||||
]
|
||||
],
|
||||
"serviceWorker": "ngsw-config.json"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
@@ -89,7 +89,11 @@
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular/build:unit-test"
|
||||
"builder": "@angular/build:unit-test",
|
||||
"options": {
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"setupFiles": ["src/test-setup.ts"]
|
||||
}
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular/build:extract-i18n"
|
||||
@@ -122,5 +126,8 @@
|
||||
"@schematics/angular:resolver": {
|
||||
"typeSeparator": "."
|
||||
}
|
||||
},
|
||||
"cli": {
|
||||
"analytics": false
|
||||
}
|
||||
}
|
||||
|
||||
30
client/ngsw-config.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
|
||||
"index": "/index.html",
|
||||
"assetGroups": [
|
||||
{
|
||||
"name": "app",
|
||||
"installMode": "prefetch",
|
||||
"resources": {
|
||||
"files": [
|
||||
"/favicon.ico",
|
||||
"/index.csr.html",
|
||||
"/index.html",
|
||||
"/manifest.webmanifest",
|
||||
"/*.css",
|
||||
"/*.js"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "assets",
|
||||
"installMode": "lazy",
|
||||
"updateMode": "prefetch",
|
||||
"resources": {
|
||||
"files": [
|
||||
"/**/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
1466
client/package-lock.json
generated
@@ -4,9 +4,10 @@
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"start:remote": "ng serve --configuration remote",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "jest",
|
||||
"test": "ng test",
|
||||
"test:recent": "jest --onlyChanged",
|
||||
"test:watch": "jest --watch --onlyChanged"
|
||||
},
|
||||
@@ -24,6 +25,7 @@
|
||||
"@angular/platform-browser": "^21.1.4",
|
||||
"@angular/platform-browser-dynamic": "^21.1.4",
|
||||
"@angular/router": "^21.1.4",
|
||||
"@angular/service-worker": "^21.1.4",
|
||||
"@ngneat/overview": "^7.0.0",
|
||||
"@ngxpert/hot-toast": "^6.1.0",
|
||||
"ag-grid-angular": "^35.1.0",
|
||||
@@ -33,14 +35,14 @@
|
||||
"zone.js": "~0.15.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@analogjs/vitest-angular": "^2.2.3",
|
||||
"@angular/build": "^21.1.4",
|
||||
"@angular/cli": "^21.1.4",
|
||||
"@angular/compiler-cli": "^21.1.4",
|
||||
"@faker-js/faker": "^9.0.3",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"@vitest/ui": "^4.0.18",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"jsdom": "^28.0.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.16",
|
||||
"ts-node": "^10.9.2",
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://keyvaultpro.de:3701",
|
||||
"secure": false,
|
||||
"logLevel": "debug",
|
||||
"target": "https://keyvaultpro.de",
|
||||
"secure": true,
|
||||
"changeOrigin": true,
|
||||
"pathRewrite": {
|
||||
"^/api": ""
|
||||
}
|
||||
"logLevel": "debug"
|
||||
}
|
||||
}
|
||||
BIN
client/public/icons/icon-128x128.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
client/public/icons/icon-144x144.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
client/public/icons/icon-152x152.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
client/public/icons/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
client/public/icons/icon-384x384.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
client/public/icons/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
client/public/icons/icon-72x72.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
client/public/icons/icon-96x96.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
21
client/public/manifest.webmanifest
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "Keyvault Pro",
|
||||
"short_name": "KVP",
|
||||
"display": "standalone",
|
||||
"scope": "./",
|
||||
"start_url": "./",
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
|
||||
|
||||
setupZoneTestEnv();
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApplicationConfig, importProvidersFrom, provideZoneChangeDetection } from '@angular/core';
|
||||
import { ApplicationConfig, importProvidersFrom, provideZoneChangeDetection, isDevMode } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHotToastConfig } from '@ngxpert/hot-toast';
|
||||
|
||||
@@ -6,6 +6,8 @@ import { routes } from './app.routes';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { tokenInterceptor } from './core/interceptor/token.interceptor';
|
||||
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||||
import { provideServiceWorker } from '@angular/service-worker';
|
||||
import { OVERLAY_DEFAULT_CONFIG } from "@angular/cdk/overlay";
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes), provideHttpClient(withInterceptors([tokenInterceptor]))
|
||||
@@ -16,8 +18,18 @@ export const appConfig: ApplicationConfig = {
|
||||
theme: 'toast',
|
||||
autoClose: true,
|
||||
dismissible: false,
|
||||
duration: 5000
|
||||
duration: 5000,
|
||||
|
||||
}),
|
||||
provideAnimationsAsync()
|
||||
provideAnimationsAsync(), provideServiceWorker('ngsw-worker.js', {
|
||||
enabled: !isDevMode(),
|
||||
registrationStrategy: 'registerWhenStable:30000'
|
||||
}),
|
||||
{
|
||||
provide: OVERLAY_DEFAULT_CONFIG,
|
||||
useValue: {
|
||||
usePopover: false,
|
||||
},
|
||||
},
|
||||
]
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { inject, Injectable } from "@angular/core";
|
||||
import { ActivatedRouteSnapshot, Router } from "@angular/router";
|
||||
import { HotToastService } from "@ngxpert/hot-toast";
|
||||
import { AuthService } from "./auth.service";
|
||||
|
||||
@Injectable({
|
||||
@@ -9,7 +8,6 @@ import { AuthService } from "./auth.service";
|
||||
export class AuthenticatedGuard {
|
||||
public isLoading = false;
|
||||
private router = inject(Router);
|
||||
private toast = inject(HotToastService);
|
||||
private authService = inject(AuthService);
|
||||
|
||||
async canActivate(route: ActivatedRouteSnapshot):
|
||||
|
||||
@@ -5,6 +5,7 @@ import { BehaviorSubject, Observable, tap, of, catchError } from 'rxjs';
|
||||
import { IUser } from '../../model/interface/user.interface';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { HotToastService } from '@ngxpert/hot-toast';
|
||||
import { ApiService } from '../../shared/api.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@@ -16,6 +17,7 @@ export class AuthService {
|
||||
private http: HttpClient = inject(HttpClient);
|
||||
private router: Router = inject(Router);
|
||||
private toast: HotToastService = inject(HotToastService);
|
||||
private api: ApiService = inject(ApiService);
|
||||
|
||||
private _user: IUser | null = null;
|
||||
|
||||
@@ -35,21 +37,27 @@ export class AuthService {
|
||||
return this.user != null && this.user.role == 'admin';
|
||||
}
|
||||
|
||||
getMe() {
|
||||
async getMe() {
|
||||
if (!this.getAccessToken()) {
|
||||
return false;
|
||||
}
|
||||
return new Promise(resolve => {
|
||||
this.http.get<IUser>('/api/auth/me').subscribe({
|
||||
next: user => {
|
||||
const user = await this.api.getMe();
|
||||
if (user) {
|
||||
this._user = user;
|
||||
resolve(true)
|
||||
},
|
||||
error: () => {
|
||||
resolve(false)
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
})
|
||||
})
|
||||
return Promise.resolve(false)
|
||||
// return new Promise(resolve => {
|
||||
// this.http.get<IUser>('/api/auth/me').subscribe({
|
||||
// next: user => {
|
||||
// this._user = user;
|
||||
// resolve(true)
|
||||
// },
|
||||
// error: () => {
|
||||
// resolve(false)
|
||||
// }
|
||||
// })
|
||||
// })
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -12,14 +12,14 @@
|
||||
|
||||
<mat-drawer-container class="example-container" autosize>
|
||||
<mat-drawer #drawer class="main_sidenav" mode="side" opened="true" style="border-right: 1px solid #dfdfdf">
|
||||
<button matButton routerLink="/" routerLinkActive="mat-elevation-z1" [routerLinkActiveOptions]="{exact: true}">Home</button>
|
||||
<button matButton routerLink="/keys" routerLinkActive="mat-elevation-z1">Schlüssel</button>
|
||||
<button matButton routerLink="/cylinders" routerLinkActive="mat-elevation-z1">Zylinder</button>
|
||||
<button matButton routerLink="/systems" routerLinkActive="mat-elevation-z1">Schließanlagen</button>
|
||||
<div class="nav-button" routerLink="/" routerLinkActive="active-link" [routerLinkActiveOptions]="{exact: true}"><mat-icon>home</mat-icon>Home</div>
|
||||
<div class="nav-button" routerLink="/keys" routerLinkActive="active-link"><mat-icon>key</mat-icon>Schlüssel</div>
|
||||
<div class="nav-button" routerLink="/cylinders" routerLinkActive="active-link"><mat-icon>lock</mat-icon>Zylinder</div>
|
||||
<div class="nav-button" routerLink="/systems" routerLinkActive="active-link"><mat-icon>admin_panel_settings</mat-icon>Schließanlagen</div>
|
||||
@if (isAdmin) {
|
||||
<button matButton routerLink="/users" routerLinkActive="mat-elevation-z1">Alle User</button>
|
||||
<div class="nav-button" routerLink="/users" routerLinkActive="active-link"><mat-icon>user_attributes</mat-icon>Alle User</div>
|
||||
}
|
||||
<button matButton (click)="openSidebar()">Einstellungen</button>
|
||||
<div class="nav-button" (click)="openSidebar()"><mat-icon>settings</mat-icon>Einstellungen</div>
|
||||
|
||||
</mat-drawer>
|
||||
|
||||
@@ -33,5 +33,3 @@
|
||||
</div> -->
|
||||
|
||||
</mat-drawer-container>
|
||||
|
||||
<app-settings #settings/>
|
||||
@@ -16,6 +16,10 @@ mat-drawer-container {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
mat-toolbar {
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
|
||||
mat-drawer, mat-toolbar {
|
||||
background-color: #fff;
|
||||
border-radius: 0;
|
||||
@@ -35,3 +39,19 @@ mat-drawer {
|
||||
mat-toolbar {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
display: flex;
|
||||
padding: 12px 12px;
|
||||
margin: 4px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
background-color: rgb(246, 246, 247);
|
||||
}
|
||||
}
|
||||
@@ -5,21 +5,27 @@ import { MatSidenavModule } from '@angular/material/sidenav';
|
||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { SettingsComponent } from '../../modules/settings/settings.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-layout',
|
||||
imports: [MatButtonModule, MatIconModule, MatSidenavModule, RouterModule, MatToolbarModule, SettingsComponent],
|
||||
imports: [MatButtonModule, MatIconModule, MatSidenavModule, RouterModule, MatToolbarModule, MatDialogModule],
|
||||
templateUrl: './layout.component.html',
|
||||
styleUrl: './layout.component.scss'
|
||||
})
|
||||
export class LayoutComponent {
|
||||
private authService: AuthService = inject(AuthService);
|
||||
@ViewChild('settings') settings!: SettingsComponent;
|
||||
|
||||
private dialog: MatDialog = inject(MatDialog);
|
||||
|
||||
openSidebar() {
|
||||
console.log(this.settings)
|
||||
this.settings.open();
|
||||
this.dialog.open(SettingsComponent, {
|
||||
maxWidth: "calc(100vw - 48px)",
|
||||
width: "600px",
|
||||
minWidth: "200px",
|
||||
disableClose: true,
|
||||
})
|
||||
}
|
||||
|
||||
logout(){
|
||||
|
||||
6
client/src/app/model/interface/customer.interface.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface ICustomer {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
updatetAt: String;
|
||||
}
|
||||
@@ -3,10 +3,12 @@ import { IKey } from "./key.interface";
|
||||
export interface ICylinder {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string;
|
||||
system: any;
|
||||
keys: IKey[];
|
||||
keyCount: number;
|
||||
digital: boolean;
|
||||
}
|
||||
@@ -10,4 +10,5 @@ export interface IKey {
|
||||
nr: number;
|
||||
deletedAt?: string;
|
||||
keyLost: Date | null;
|
||||
digital: boolean;
|
||||
}
|
||||
7
client/src/app/model/interface/keysystem.interface.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface ISystem {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt?: string;
|
||||
name: string;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
@if (gridOptions || true) {
|
||||
@if (myTheme && gridOptions) {
|
||||
<ag-grid-angular
|
||||
style="width: 100%; height: 100%;"
|
||||
(gridReady)="onGridReady($event)"
|
||||
[gridOptions]="gridOptions!"
|
||||
[theme]="myTheme"
|
||||
/>
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { AuthService } from '../../../core/auth/auth.service';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { AG_GRID_LOCALE_DE } from '@ag-grid-community/locale';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { AgGridContainerComponent } from '../../../shared/ag-grid/components/ag-grid-container/ag-grid-container.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-all-users',
|
||||
@@ -16,7 +17,7 @@ import { MatButtonModule } from '@angular/material/button';
|
||||
templateUrl: './all-users.component.html',
|
||||
styleUrl: './all-users.component.scss'
|
||||
})
|
||||
export class AllUsersComponent {
|
||||
export class AllUsersComponent extends AgGridContainerComponent {
|
||||
|
||||
private toast: HotToastService = inject(HotToastService);
|
||||
private api: ApiService = inject(ApiService);
|
||||
@@ -79,7 +80,7 @@ export class AllUsersComponent {
|
||||
children: [
|
||||
{ columnGroupShow: "closed", width: 180 , cellRenderer: 'agCheckboxCellRenderer', valueGetter: (data: any) => { return Object.values(data.data.settings).filter(v => typeof v == 'boolean').some((x: any) => x)}, type: 'boolean' },
|
||||
{ field: 'settings.sendSystemAccessMails', headerName: 'Schlüssesystemzugriff', editable: true, columnGroupShow: "open" },
|
||||
{ field: 'settings.sendSystemUpdateMails', headerName: 'Schließsystemupdates', editable: true, columnGroupShow: "open" },
|
||||
{ field: 'settings.sendSystemUpdateMails', headerName: 'Schließanlageupdates', editable: true, columnGroupShow: "open" },
|
||||
{ field: 'settings.sendUserDisabledMails', headerName: 'User deaktiviert', editable: true, columnGroupShow: "open" }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<h2 mat-dialog-title>Neuen Zylinder anlegen</h2>
|
||||
<mat-dialog-content>
|
||||
<div class="mat-body" style="margin-bottom: 24px;">Hier können Zylinder angelegt werden. Jeder Zylinder muss genau einer Schließanlage zugeordnet werden. Es können mehrere Schlüssel zu einem Zylinder zugeordnet werden.</div>
|
||||
<form [formGroup]="createForm" class="flex flex-col gap-3">
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>Name</mat-label>
|
||||
<input type="text" matInput formControlName="name" maxlength="100">
|
||||
<input type="text" matInput formControlName="name" maxlength="100" placeholder="Bsp.: Haustür Ferienhaus">
|
||||
@if ((createForm.controls.name.value || '').length > 20) {
|
||||
<mat-hint>{{ (createForm.controls.name.value || '').length }} / 100 Zeichen</mat-hint>
|
||||
} @else {
|
||||
@@ -12,6 +13,18 @@
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<div class="flex items-center gap-6">
|
||||
<mat-form-field class="flex-auto">
|
||||
<mat-label>Beschreibung</mat-label>
|
||||
<input type="text" matInput formControlName="description" maxlength="255" placeholder="Bsp.: 30/30">
|
||||
<mat-hint>Zylinderlänge und co.</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-checkbox formControlName="digital">Digitales Schloss</mat-checkbox>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>Schließanlage</mat-label>
|
||||
<mat-select formControlName="system">
|
||||
@@ -20,8 +33,11 @@
|
||||
}
|
||||
</mat-select>
|
||||
<mat-hint>Zu welcher Schließanlage gehört der Zylinder?</mat-hint>
|
||||
</mat-form-field>
|
||||
@if (isLoading) {
|
||||
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||
}
|
||||
|
||||
</mat-form-field>
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions>
|
||||
|
||||
@@ -9,10 +9,13 @@ import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
|
||||
@Component({
|
||||
selector: 'app-create-cylinder',
|
||||
imports: [MatFormFieldModule, MatInputModule, MatDialogModule, ReactiveFormsModule, FormsModule, MatSelectModule, MatButtonModule, MatIconModule],
|
||||
imports: [MatFormFieldModule, MatInputModule, MatDialogModule, ReactiveFormsModule, FormsModule, MatSelectModule, MatButtonModule, MatIconModule, MatProgressBarModule, CommonModule, MatCheckboxModule],
|
||||
templateUrl: './create-cylinder.component.html',
|
||||
styleUrl: './create-cylinder.component.scss'
|
||||
})
|
||||
@@ -22,18 +25,28 @@ export class CreateCylinderComponent {
|
||||
readonly dialogRef = inject(MatDialogRef<CreateCylinderComponent>);
|
||||
|
||||
systems: any[] = [];
|
||||
isLoading = true;
|
||||
|
||||
createForm = new FormGroup({
|
||||
name: new FormControl<string | null>(null, Validators.required),
|
||||
system: new FormControl<any>(null, Validators.required)
|
||||
system: new FormControl<any>(null, Validators.required),
|
||||
description: new FormControl<string | null>(null),
|
||||
digital: new FormControl<boolean>(false)
|
||||
});
|
||||
|
||||
ngOnInit() {
|
||||
this.api.getSystems().subscribe({
|
||||
this.api.systems.asObservable().subscribe({
|
||||
next: systems => {
|
||||
this.systems = systems;
|
||||
}
|
||||
});
|
||||
this.loadCylinders();
|
||||
}
|
||||
|
||||
private async loadCylinders() {
|
||||
this.isLoading = true;
|
||||
await this.api.refreshSystems();
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
save() {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<h2 mat-dialog-title>Gelöschte Zylinder</h2>
|
||||
<mat-dialog-content>
|
||||
@if(myTheme && gridOptions) {
|
||||
<ag-grid-angular
|
||||
style="width: 100%; height: 100%;"
|
||||
(gridReady)="onGridReady($event)"
|
||||
[gridOptions]="gridOptions!"
|
||||
[theme]="myTheme"
|
||||
/>
|
||||
}
|
||||
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions>
|
||||
<button matButton mat-dialog-close>Schließen</button>
|
||||
</mat-dialog-actions>
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Component, inject, LOCALE_ID } from '@angular/core';
|
||||
import { AgGridContainerComponent } from '../../../../shared/ag-grid/components/ag-grid-container/ag-grid-container.component';
|
||||
import { CommonModule, DatePipe } from '@angular/common';
|
||||
import { HotToastService } from '@ngxpert/hot-toast';
|
||||
import { ApiService } from '../../../../shared/api.service';
|
||||
import { GridApi, GridOptions, GridReadyEvent } from 'ag-grid-community';
|
||||
import { HELPER } from '../../../../shared/helper.service';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { AgGridAngular } from 'ag-grid-angular';
|
||||
import { ICylinder } from '../../../../model/interface/cylinder.interface';
|
||||
|
||||
@Component({
|
||||
selector: 'app-cylinder-archive',
|
||||
imports: [MatDialogModule, AgGridAngular, MatButtonModule, MatIconModule, CommonModule],
|
||||
providers: [DatePipe, { provide: LOCALE_ID, useValue: 'de-DE' }],
|
||||
templateUrl: './cylinder-archive.component.html',
|
||||
styleUrl: './cylinder-archive.component.scss',
|
||||
})
|
||||
export class CylinderArchiveComponent extends AgGridContainerComponent {
|
||||
private api: ApiService = inject(ApiService);
|
||||
private datePipe = inject(DatePipe);
|
||||
private toast = inject(HotToastService);
|
||||
|
||||
gridApi!: GridApi;
|
||||
|
||||
gridOptions: GridOptions = HELPER.getGridOptions();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.createGridOptions();
|
||||
|
||||
}
|
||||
|
||||
private createGridOptions() {
|
||||
this.gridOptions.columnDefs = [
|
||||
{ colId: 'name', field: 'name' , headerName: 'Name', flex: 1, editable: true, sort: 'asc', filter: true },
|
||||
{ colId: 'nr', field: 'nr' , headerName: 'Name', flex: 1, editable: true, filter: true },
|
||||
{
|
||||
field: 'deletedAt'
|
||||
, headerName: 'Gelöscht'
|
||||
, width: 160
|
||||
, cellRenderer: (data: any) => this.datePipe.transform(new Date(data.value), 'short')
|
||||
},
|
||||
{
|
||||
width: 40,
|
||||
cellRenderer: () => '<div class="icon-btn-sm restore icon-btn-xs" ></div>',
|
||||
onCellClicked: (event) => { this.restoreCylinder(event.data);},
|
||||
tooltipValueGetter: () => 'Wiederherstellen',
|
||||
sortable: false
|
||||
}
|
||||
];
|
||||
this.gridOptions.rowHeight = 36;
|
||||
this.gridOptions.overlayNoRowsTemplate = 'Bisher wurden keine Zylinder gelöscht. Sobald dies der Fall ist, werden sie hier angezeigt.';
|
||||
}
|
||||
|
||||
|
||||
async restoreCylinder(data: ICylinder) {
|
||||
this.gridApi.setGridOption("loading", true);
|
||||
await this.api.restoreCylinder(data);
|
||||
this.loadCylinders();
|
||||
}
|
||||
|
||||
onGridReady(params: GridReadyEvent) {
|
||||
this.gridApi = params.api;
|
||||
this.loadCylinders();
|
||||
}
|
||||
|
||||
async loadCylinders() {
|
||||
this.gridApi.setGridOption("loading", true);
|
||||
const cylinders = await this.api.getCylinderArchive();
|
||||
this.gridApi.setGridOption("rowData", cylinders);
|
||||
this.gridApi.setGridOption("loading", false);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
<ag-grid-angular
|
||||
@if (myTheme) {
|
||||
<ag-grid-angular
|
||||
style="width: 100%; height: 100%;"
|
||||
(gridReady)="onGridReady($event)"
|
||||
[gridOptions]="gridOptions!"
|
||||
[theme]="myTheme"
|
||||
/>
|
||||
}
|
||||
<div class="floating-btn-container">
|
||||
<button mat-flat-button class="btn-create mat-elevation-z8" (click)="openCreateCylinder()" >Zylinder anlegen</button>
|
||||
<button mat-mini-fab disabled><mat-icon>inventory_2</mat-icon></button>
|
||||
<button mat-mini-fab (click)="openArchive()"><mat-icon>inventory_2</mat-icon></button>
|
||||
</div>
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { HELPER } from '../../shared/helper.service';
|
||||
import { GridApi, GridOptions, GridReadyEvent } from 'ag-grid-community';
|
||||
import { CellEditingStoppedEvent, GridApi, GridOptions, GridReadyEvent } from 'ag-grid-community';
|
||||
import { AgGridAngular } from 'ag-grid-angular';
|
||||
import { ApiService } from '../../shared/api.service';
|
||||
import { DatePipe } from '@angular/common';
|
||||
@@ -9,6 +9,9 @@ import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { CreateCylinderComponent } from './components/create-cylinder/create-cylinder.component';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { AgGridContainerComponent } from '../../shared/ag-grid/components/ag-grid-container/ag-grid-container.component';
|
||||
import { CylinderArchiveComponent } from './components/cylinder-archive/cylinder-archive.component';
|
||||
import { ICylinder } from '../../model/interface/cylinder.interface';
|
||||
|
||||
@Component({
|
||||
selector: 'app-cylinder',
|
||||
@@ -17,7 +20,7 @@ import { MatButtonModule } from '@angular/material/button';
|
||||
templateUrl: './cylinder.component.html',
|
||||
styleUrl: './cylinder.component.scss'
|
||||
})
|
||||
export class CylinderComponent {
|
||||
export class CylinderComponent extends AgGridContainerComponent {
|
||||
private api: ApiService = inject(ApiService);
|
||||
private datePipe = inject(DatePipe);
|
||||
private dialog = inject(MatDialog);
|
||||
@@ -28,9 +31,11 @@ export class CylinderComponent {
|
||||
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.gridOptions.columnDefs = [
|
||||
{ field: 'name', headerName: 'Name', sort: 'asc', flex: 1, filter: true },
|
||||
{ field: 'name', headerName: 'Name', sort: 'asc', flex: 1, filter: true, editable: true },
|
||||
{ field: 'description', headerName: 'Beschreibung', flex: 1, filter: true, editable: true },
|
||||
{ field: 'system.name', headerName: 'System', flex: 1, filter: true },
|
||||
{ field: 'keyCount', headerName: 'Anzahl Schlüssel', flex: 0, type: 'number' },
|
||||
{ field: 'createdAt', headerName: 'Angelegt', cellRenderer: (data: any) => data.value ? this.datePipe.transform(new Date(data.value)) : '-' },
|
||||
@@ -45,25 +50,37 @@ export class CylinderComponent {
|
||||
}
|
||||
|
||||
loadCylinders() {
|
||||
this.api.getCylinders().subscribe({
|
||||
next: n => {
|
||||
this.gridApi.setGridOption("rowData", n);
|
||||
this.gridApi.setGridOption("loading", true);
|
||||
this.api.refreshCylinders();
|
||||
}
|
||||
|
||||
onGridReady(params: GridReadyEvent) {
|
||||
this.gridApi = params.api;
|
||||
this.gridApi.addEventListener("cellEditingStopped", evt => this.cellEditEnd(evt));
|
||||
this.loadCylinders();
|
||||
this.api.cylinders.asObservable().subscribe({
|
||||
next: (data) => {
|
||||
this.gridApi.setGridOption("rowData", data);
|
||||
this.gridApi.setGridOption("loading", false);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onGridReady(params: GridReadyEvent) {
|
||||
this.gridApi = params.api;
|
||||
this.loadCylinders();
|
||||
private async cellEditEnd(event: CellEditingStoppedEvent) {
|
||||
const cylinder: ICylinder = event.data;
|
||||
|
||||
if (!event.valueChanged || event.newValue == event.oldValue) { return; }
|
||||
|
||||
await this.api.updateCylinder(cylinder)
|
||||
|
||||
}
|
||||
|
||||
openCreateCylinder() {
|
||||
this.dialog.open(CreateCylinderComponent, {
|
||||
maxWidth: "calc(100vw - 24px)",
|
||||
width: "30vw",
|
||||
maxWidth: "calc(100vw - 48px)",
|
||||
width: "800px",
|
||||
minWidth: "200px",
|
||||
disableClose: true
|
||||
disableClose: true,
|
||||
}).afterClosed().subscribe({
|
||||
next: (cylinder) => {
|
||||
if (cylinder) {
|
||||
@@ -72,4 +89,14 @@ export class CylinderComponent {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openArchive() {
|
||||
this.dialog.open(CylinderArchiveComponent, {
|
||||
maxHeight: "calc(100vh - 48px)",
|
||||
maxWidth: "calc(100vw - 48px)",
|
||||
width: "800px",
|
||||
minWidth: "min(700px,calc(100vw - 24px))",
|
||||
height: "70vh",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
<p>Derzeit ausgegebene Schlüssel</p>
|
||||
</mat-card-content>
|
||||
<mat-card-actions>
|
||||
<button matButton routerLink="/keys">Verwalten</button>
|
||||
<button matButton routerLink="/keys" [queryParams]="{handedOut: true }">Verwalten</button>
|
||||
</mat-card-actions>
|
||||
</mat-card>
|
||||
</div>
|
||||
@@ -73,6 +73,7 @@
|
||||
style="width: 100%; height: 100%;"
|
||||
[gridOptions]="gridOptions"
|
||||
(gridReady)="onGridReady($event)"
|
||||
[theme]="myTheme"
|
||||
>
|
||||
</ag-grid-angular>
|
||||
</mat-card-content>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { RouterModule } from '@angular/router';
|
||||
import { AgLoadingComponent } from '../../shared/ag-grid/components/ag-loading/ag-loading.component';
|
||||
import { AG_GRID_LOCALE_DE } from '@ag-grid-community/locale';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { AgGridContainerComponent } from '../../shared/ag-grid/components/ag-grid-container/ag-grid-container.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
@@ -17,7 +18,7 @@ import { MatButtonModule } from '@angular/material/button';
|
||||
templateUrl: './dashboard.component.html',
|
||||
styleUrl: './dashboard.component.scss'
|
||||
})
|
||||
export class DashboardComponent {
|
||||
export class DashboardComponent extends AgGridContainerComponent {
|
||||
private api = inject(ApiService);
|
||||
private datePipe = inject(DatePipe);
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<h2 mat-dialog-title>Gelöschte Schlüssel</h2>
|
||||
<mat-dialog-content>
|
||||
@if(myTheme) {
|
||||
<ag-grid-angular
|
||||
style="width: 100%; height: 100%;"
|
||||
(gridReady)="onGridReady($event)"
|
||||
[gridOptions]="gridOptions!"
|
||||
[theme]="myTheme"
|
||||
/>
|
||||
}
|
||||
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { IKey } from '../../../../model/interface/key.interface';
|
||||
import { HotToastService } from '@ngxpert/hot-toast';
|
||||
import { AgLoadingComponent } from '../../../../shared/ag-grid/components/ag-loading/ag-loading.component';
|
||||
import { HELPER } from '../../../../shared/helper.service';
|
||||
import { AgGridContainerComponent } from '../../../../shared/ag-grid/components/ag-grid-container/ag-grid-container.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-archive',
|
||||
@@ -19,7 +20,7 @@ import { HELPER } from '../../../../shared/helper.service';
|
||||
templateUrl: './archive.component.html',
|
||||
styleUrl: './archive.component.scss'
|
||||
})
|
||||
export class ArchiveComponent {
|
||||
export class ArchiveComponent extends AgGridContainerComponent {
|
||||
private api: ApiService = inject(ApiService);
|
||||
private datePipe = inject(DatePipe);
|
||||
private toast = inject(HotToastService);
|
||||
@@ -31,6 +32,7 @@ export class ArchiveComponent {
|
||||
gridOptions: GridOptions = HELPER.getGridOptions();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.gridOptions.columnDefs = [
|
||||
{ colId: 'name', field: 'name' , headerName: 'Name', flex: 1, editable: true, sort: 'asc', filter: true },
|
||||
{ colId: 'nr', field: 'nr' , headerName: 'Schlüsselnummer', flex: 1, editable: true, filter: true },
|
||||
@@ -87,6 +89,9 @@ export class ArchiveComponent {
|
||||
},
|
||||
error: () => {
|
||||
this.gridApi.setGridOption("loading", false);
|
||||
},
|
||||
complete: () => {
|
||||
this.api.refreshKeys();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<h2 mat-dialog-title>Schlüssel <u>{{key.name}}</u> löschen?</h2>
|
||||
<mat-dialog-content>
|
||||
<div class="warning-message">
|
||||
<mat-icon>warning</mat-icon>
|
||||
<p>
|
||||
<b>{{key.name}}</b> wirklich endgültig löschen?
|
||||
</p>
|
||||
<p class="additional-info">
|
||||
<!-- Additional information -->
|
||||
<small>Gelöschte Schlüssel können nicht mehr wiederhergestellt werden</small>
|
||||
</p>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button matButton [mat-dialog-close]="false">Abbrechen</button>
|
||||
<button matButton="elevated" [mat-dialog-close]="true" class="btn-warning">
|
||||
<mat-icon>delete</mat-icon>
|
||||
Entfernen
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { IKey } from '../../../../../model/interface/key.interface';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
|
||||
@Component({
|
||||
selector: 'app-delete-key-from-archive',
|
||||
imports: [MatDialogModule, MatButtonModule, MatIconModule],
|
||||
templateUrl: './delete-key-from-archive.component.html',
|
||||
styleUrl: './delete-key-from-archive.component.scss',
|
||||
})
|
||||
export class DeleteKeyFromArchiveComponent {
|
||||
|
||||
readonly dialogRef = inject(MatDialogRef<DeleteKeyFromArchiveComponent>);
|
||||
readonly key = inject<IKey>(MAT_DIALOG_DATA);
|
||||
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
<!-- <h6>Neue Übergabe anlegen:</h6> -->
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>Kunde</mat-label>
|
||||
<mat-label>Mieter</mat-label>
|
||||
<input type="text"
|
||||
matInput
|
||||
formControlName="customer"
|
||||
@@ -36,7 +36,7 @@
|
||||
<mat-form-field>
|
||||
<mat-label>Datum der Übergabe</mat-label>
|
||||
<input matInput [matDatepicker]="picker" formControlName="timestamp">
|
||||
<mat-hint>TT/MM/JJJJ</mat-hint>
|
||||
<mat-hint>TT.MM.JJJJ (Beispiel: {{ exampleDate | date:'dd.MM.yyyy'}})</mat-hint>
|
||||
<mat-datepicker-toggle matIconSuffix [for]="picker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #picker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
@@ -44,7 +44,7 @@
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions>
|
||||
<button matButton mat-dialog-close class="btn-warning">Schließen</button>
|
||||
<button matButton="elevated" (click)="save()" class="btn-primary" [disabled]="handoverForm.invalid || handoverForm.pristine">
|
||||
<button matButton="elevated" (click)="save()" class="btn-primary" [disabled]="handoverForm.invalid">
|
||||
<mat-icon>save</mat-icon>
|
||||
Speichern
|
||||
</button>
|
||||
@@ -58,6 +58,7 @@
|
||||
style="width: 100%; height: 100%;"
|
||||
(gridReady)="onGridReady($event)"
|
||||
[gridOptions]="gridOptions!"
|
||||
[theme]="myTheme"
|
||||
/>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { MAT_DATE_LOCALE, provideNativeDateAdapter } from '@angular/material/cor
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { AsyncPipe, DatePipe } from '@angular/common';
|
||||
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
||||
import { map, Observable, startWith, timestamp } from 'rxjs';
|
||||
import { from, map, Observable, startWith, timestamp } from 'rxjs';
|
||||
import {
|
||||
MatBottomSheet,
|
||||
MatBottomSheetModule,
|
||||
@@ -25,10 +25,12 @@ import { GridApi, GridOptions, GridReadyEvent } from 'ag-grid-community';
|
||||
import { AG_GRID_LOCALE_DE } from '@ag-grid-community/locale';
|
||||
import { AgGridAngular } from 'ag-grid-angular';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { AgGridContainerComponent } from '../../../../shared/ag-grid/components/ag-grid-container/ag-grid-container.component';
|
||||
import { ICustomer } from '../../../../model/interface/customer.interface';
|
||||
|
||||
@Component({
|
||||
selector: 'app-handover-dialog',
|
||||
imports: [FormsModule, MatTabsModule, AgGridAngular, ReactiveFormsModule, MatDatepickerModule, MatFormFieldModule, MatInputModule, MatButtonModule, MatDialogModule, MatAutocompleteModule, MatProgressSpinnerModule, MatRadioModule, AsyncPipe, MatIconModule],
|
||||
imports: [FormsModule, MatTabsModule, AgGridAngular, ReactiveFormsModule, MatDatepickerModule, MatFormFieldModule, MatInputModule, MatButtonModule, MatDialogModule, MatAutocompleteModule, MatProgressSpinnerModule, MatRadioModule, AsyncPipe, MatIconModule, DatePipe],
|
||||
providers: [
|
||||
provideNativeDateAdapter(),
|
||||
{ provide: LOCALE_ID, useValue: 'de-DE' },
|
||||
@@ -38,7 +40,7 @@ import { MatIconModule } from '@angular/material/icon';
|
||||
templateUrl: './handover-dialog.component.html',
|
||||
styleUrl: './handover-dialog.component.scss'
|
||||
})
|
||||
export class HandoverDialogComponent {
|
||||
export class HandoverDialogComponent extends AgGridContainerComponent {
|
||||
|
||||
private api: ApiService = inject(ApiService);
|
||||
readonly dialogRef = inject(MatDialogRef<HandoverDialogComponent>);
|
||||
@@ -47,13 +49,15 @@ export class HandoverDialogComponent {
|
||||
private datePipe = inject(DatePipe);
|
||||
private toast: HotToastService = inject(HotToastService);
|
||||
|
||||
public exampleDate = new Date();
|
||||
|
||||
gridApi!: GridApi;
|
||||
gridOptions: GridOptions = {
|
||||
localeText: AG_GRID_LOCALE_DE,
|
||||
rowData: [],
|
||||
isRowSelectable: () => false,
|
||||
columnDefs: [
|
||||
{ colId: 'customer', field: 'customer.name' , headerName: 'Kunde', flex: 1, editable: false, filter: false},
|
||||
{ colId: 'customer', field: 'customer.name' , headerName: 'Mieter', flex: 1, editable: false, filter: false},
|
||||
{
|
||||
colId: 'handedOut',
|
||||
field: 'direction' ,
|
||||
@@ -79,8 +83,8 @@ export class HandoverDialogComponent {
|
||||
|
||||
isLoading: boolean = false;
|
||||
|
||||
customers: { name: string, id: string }[] = [];
|
||||
filteredCustomers: Observable<any[]> = new Observable();
|
||||
customers: ICustomer[] = [];
|
||||
filteredCustomers: Observable<ICustomer[]> = new Observable();
|
||||
|
||||
handoverForm = new FormGroup({
|
||||
customer: new FormControl<any>(null, Validators.required),
|
||||
@@ -97,7 +101,7 @@ export class HandoverDialogComponent {
|
||||
this.isLoading = true;
|
||||
const promises: Observable<any>[] = [
|
||||
this.getHandovers(),
|
||||
this.loadCustomers()
|
||||
from(this.loadCustomers())
|
||||
];
|
||||
|
||||
Promise.all(promises).then(() => {
|
||||
@@ -122,21 +126,14 @@ export class HandoverDialogComponent {
|
||||
return promise;
|
||||
}
|
||||
|
||||
loadCustomers() {
|
||||
const promise = this.api.getCustomers()
|
||||
|
||||
promise.subscribe({
|
||||
next: customers => {
|
||||
async loadCustomers() {
|
||||
const customers = await this.api.refreshCustomers()
|
||||
this.customers = customers;
|
||||
this.filteredCustomers = this.handoverForm.controls.customer.valueChanges.pipe(
|
||||
startWith(''),
|
||||
map(value => this._filter(value || '')),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return promise;
|
||||
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
private _filter(value: string): any[] {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<h2 mat-dialog-title>Verlorene Schlüssel</h2>
|
||||
<mat-dialog-content>
|
||||
@if(myTheme) {
|
||||
<ag-grid-angular
|
||||
style="width: 100%; height: 100%;"
|
||||
(gridReady)="onGridReady($event)"
|
||||
[gridOptions]="gridOptions!"
|
||||
[theme]="myTheme"
|
||||
/>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions>
|
||||
<button matButton [mat-dialog-close]="dataChanged">Schließen</button>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { HELPER } from '../../../../shared/helper.service';
|
||||
import { AgGridAngular } from 'ag-grid-angular';
|
||||
import { LostKeyComponent } from '../lost-key/lost-key.component';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { AgGridContainerComponent } from '../../../../shared/ag-grid/components/ag-grid-container/ag-grid-container.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-lost-keys',
|
||||
@@ -18,7 +19,7 @@ import { MatButtonModule } from '@angular/material/button';
|
||||
templateUrl: './lost-keys.component.html',
|
||||
styleUrl: './lost-keys.component.scss'
|
||||
})
|
||||
export class LostKeysComponent {
|
||||
export class LostKeysComponent extends AgGridContainerComponent {
|
||||
private api: ApiService = inject(ApiService);
|
||||
private datePipe = inject(DatePipe);
|
||||
private dialog: MatDialog = inject(MatDialog);
|
||||
@@ -31,6 +32,7 @@ export class LostKeysComponent {
|
||||
gridOptions: GridOptions = HELPER.getGridOptions();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.gridOptions.columnDefs = [
|
||||
{ colId: 'name', field: 'name', headerName: 'Name', sort: 'asc', flex: 1, filter: true },
|
||||
{ colId: 'nr', field: 'nr', headerName: 'Schlüsselnummer', flex: 1, filter: true },
|
||||
@@ -38,7 +40,7 @@ export class LostKeysComponent {
|
||||
cellRenderer: (data: any) => data.value?.map((m: ICylinder) => m.name).join(', ')
|
||||
},
|
||||
{
|
||||
colId: 'customer', field: 'customer.name', headerName: 'Kunde', flex: 1, filter: true,
|
||||
colId: 'customer', field: 'customer.name', headerName: 'Mieter', flex: 1, filter: true,
|
||||
},
|
||||
{ colId: 'keyLost', field: 'keyLost', headerName: 'Verloren seit', width: 100,
|
||||
cellRenderer: (data: any) => this.datePipe.transform(new Date(data.value), 'dd.MM.yyyy'),
|
||||
@@ -76,15 +78,11 @@ export class LostKeysComponent {
|
||||
|
||||
markAsFound(key: IKey) {
|
||||
this.dialog.open(LostKeyComponent, { data: key, autoFocus: false }).afterClosed().subscribe({
|
||||
next: (result) => {
|
||||
next: async (result) => {
|
||||
if (result == "") {
|
||||
key.keyLost = null;
|
||||
this.api.updateKey(key).subscribe({
|
||||
next: () => {
|
||||
this.toast.success('Schlüssel als gefunden markiert');
|
||||
await this.api.updateKey(key);
|
||||
this.loadLostKeys();
|
||||
}
|
||||
});
|
||||
this.dataChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,19 +4,19 @@
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>Name</mat-label>
|
||||
<input type="text" matInput formControlName="name" maxlength="100">
|
||||
<input type="text" matInput formControlName="name" maxlength="100" placeholder="Bsp.: Kellerschlüssel Ferienhaus">
|
||||
@if ((createForm.controls.name.value || '').length > 20) {
|
||||
<mat-hint>{{ (createForm.controls.name.value || '').length }} / 100 Zeichen</mat-hint>
|
||||
} @else {
|
||||
<mat-hint>Wie soll der Schlüssel heißen?</mat-hint>
|
||||
<mat-hint>Wie soll der Schlüssel heißen? Der Name beschreibt den Schlüssel. Er sollte aber nicht einem Mieter zugeordnet werden.</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<div class="flex items-center gap-6">
|
||||
<mat-form-field class="flex-auto">
|
||||
<mat-label>Schlüsselnummer</mat-label>
|
||||
<input type="text" matInput formControlName="nr" maxlength="100">
|
||||
<mat-hint>Nummer auf dem Schlüssel</mat-hint>
|
||||
<input type="text" matInput formControlName="nr" maxlength="100" placeholder="12 - R115843">
|
||||
<mat-hint>Die Nummer auf dem Schlüssel oder dem Chip.</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<div>
|
||||
@@ -29,12 +29,12 @@
|
||||
<mat-label>Schließzylinder</mat-label>
|
||||
<mat-select formControlName="cylinder" multiple>
|
||||
@for (item of cylinders; track $index) {
|
||||
<mat-option [value]="item">{{ item.name }}</mat-option>
|
||||
<mat-option [value]="item">{{ item.name }} ({{item.system.name}}) </mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
<mat-hint>Wo sperrt der Schlüssel?</mat-hint>
|
||||
</mat-form-field>
|
||||
<button mat-icon-button (click)="openSelectMultipleCylinders()" style="margin-bottom: 12px;">
|
||||
<button mat-icon-button (click)="openSelectMultipleCylinders()" style="margin-bottom: 12px;" matTooltip="Zylinderauswahl in neuem Fenster öffnen">
|
||||
<mat-icon>open_in_new</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -14,10 +14,11 @@ import { MatIconModule } from '@angular/material/icon';
|
||||
import {MatCheckboxModule} from '@angular/material/checkbox';
|
||||
import { IKey } from '../../../model/interface/key.interface';
|
||||
import { ICylinder } from '../../../model/interface/cylinder.interface';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
|
||||
@Component({
|
||||
selector: 'app-create',
|
||||
imports: [MatDialogModule, MatButtonModule, ReactiveFormsModule, FormsModule, MatFormFieldModule, MatInputModule, MatSelectModule, MatDialogModule, MatIconModule, MatCheckboxModule],
|
||||
imports: [MatDialogModule, MatButtonModule, ReactiveFormsModule, FormsModule, MatFormFieldModule, MatInputModule, MatSelectModule, MatDialogModule, MatIconModule, MatCheckboxModule, MatTooltipModule],
|
||||
templateUrl: './create.component.html',
|
||||
styleUrl: './create.component.scss'
|
||||
})
|
||||
@@ -44,6 +45,12 @@ export class CreateKeyComponent {
|
||||
}
|
||||
|
||||
async doSetup() {
|
||||
this.api.cylinders.subscribe({
|
||||
next: data => {
|
||||
this.cylinders = data;
|
||||
this.createForm.controls.cylinder.patchValue(null);
|
||||
}
|
||||
})
|
||||
await this.loadCylinders();
|
||||
|
||||
this.filteredCylinders = this.createForm.controls.cylinder.valueChanges.pipe(
|
||||
@@ -63,15 +70,7 @@ export class CreateKeyComponent {
|
||||
}
|
||||
|
||||
loadCylinders() {
|
||||
return new Promise(resolve => {
|
||||
this.api.getCylinders().subscribe({
|
||||
next: n => {
|
||||
this.cylinders = n;
|
||||
this.createForm.controls.cylinder.patchValue(null);
|
||||
resolve(null)
|
||||
}
|
||||
});
|
||||
})
|
||||
return this.api.refreshCylinders();
|
||||
|
||||
}
|
||||
|
||||
@@ -94,9 +93,9 @@ export class CreateKeyComponent {
|
||||
|
||||
openSelectMultipleCylinders() {
|
||||
this.dialog.open(SelectKeyCylinderComponent, {
|
||||
maxHeight: "calc(100vh - 24px)",
|
||||
maxWidth: "calc(100vw - 24px)",
|
||||
width: "50vw",
|
||||
maxHeight: "calc(100vh - 48px)",
|
||||
maxWidth: "calc(100vw - 48px)",
|
||||
width: "800px",
|
||||
minWidth: "300px",
|
||||
height: "70vh",
|
||||
disableClose: true,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<h2 mat-dialog-title>Mehrere Schließanlagen gewählt!</h2>
|
||||
<mat-dialog-content>
|
||||
<div class="warning-message">
|
||||
<mat-icon>warning</mat-icon>
|
||||
<p>
|
||||
Der Schlüssel ist Zylindern in mehreren Schließanlagen zugeordnet!
|
||||
</p>
|
||||
<p class="additional-info">
|
||||
<!-- Additional information -->
|
||||
<small>Zum Korrigieren abbrechen, ansonsten speichern</small>
|
||||
</p>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button matButton [mat-dialog-close]="false">
|
||||
<mat-icon>close</mat-icon>
|
||||
Vorgang abbrechen
|
||||
</button>
|
||||
<button matButton="elevated" [mat-dialog-close]="true" class="btn-warning">
|
||||
<mat-icon>check</mat-icon>
|
||||
Schlüssel speichern
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||