Compare commits

66 Commits

Author SHA1 Message Date
Bastian Wagner
ef4485115d pdf 2026-03-14 09:31:47 +01:00
Bastian Wagner
0d30e01a5f auth 2026-03-13 21:56:53 +01:00
Bastian Wagner
46d65a778c logs 2026-03-13 21:44:41 +01:00
Bastian Wagner
59d2253561 url 2026-03-13 21:36:05 +01:00
Bastian Wagner
2962f2c68a fix urls 2026-03-13 21:32:38 +01:00
Bastian Wagner
a7b5a4c465 Docker 2026-03-13 21:27:29 +01:00
Bastian Wagner
8a9295c309 PDF Download 2026-03-13 21:19:52 +01:00
Bastian Wagner
7973d4563e ui 2026-03-12 15:17:59 +01:00
Bastian Wagner
c2ac1dd949 titel vom pdf 2026-03-12 15:15:07 +01:00
Bastian Wagner
ed2070abd9 pdf 2026-03-12 15:09:07 +01:00
Bastian Wagner
d5d1e450f3 disable 2026-03-12 14:29:56 +01:00
Bastian Wagner
2c66764587 puppeteer 2026-03-12 13:52:52 +01:00
Bastian Wagner
bd76746b4f chromium 2026-03-12 13:47:41 +01:00
Bastian Wagner
a71f1260b4 ci 2026-03-12 13:39:13 +01:00
Bastian Wagner
482e1fbdb9 faker entfernt 2026-03-12 13:36:00 +01:00
Bastian Wagner
a76069f1a4 ci 2026-03-12 13:14:46 +01:00
Bastian Wagner
76c3e8b4ef ci3 2026-03-12 12:40:50 +01:00
Bastian Wagner
7558b56d16 ci3 2026-03-12 12:27:50 +01:00
Bastian Wagner
c5b2ca4ab9 puppeteer 2 2026-03-12 12:19:49 +01:00
Bastian Wagner
5e6862573d chromium 2026-03-12 12:13:50 +01:00
Bastian Wagner
8b61e903a9 minio bucket aus env 2026-03-12 12:00:48 +01:00
Bastian Wagner
843e595a37 ci 2026-03-12 11:54:48 +01:00
Bastian Wagner
81ccfa3acf ci 2026-03-12 11:51:49 +01:00
Bastian Wagner
f8b635b967 unit 2026-03-12 11:45:23 +01:00
f5827907ec Merge pull request 'handover pdf' (#1) from uebergabe-export into master
Reviewed-on: #1
2026-03-12 11:42:42 +01:00
Bastian Wagner
b72e2d6784 handover pdf 2026-03-12 11:41:29 +01:00
Bastian Wagner
93053e0101 Change Detection bei Schlüsseländerungen 2026-03-12 09:37:46 +01:00
Bastian Wagner
ccbdc7cefa Settings repariert 2026-03-12 09:33:48 +01:00
Bastian Wagner
0a7285c6c3 unit test 2026-03-11 14:46:36 +01:00
Bastian Wagner
5a15847c4a Unit Tests Frontend 2026-03-11 14:41:57 +01:00
Bastian Wagner
1480e8d7b2 Unregister & Unsubscribe vom SSE für Keys eingebaut 2026-03-09 12:59:59 +01:00
Bastian Wagner
b3fd7fbf03 Unit tests 2026-03-09 10:54:50 +01:00
Bastian Wagner
ac2117b64b Keys auf SSE umgestellt 2026-03-05 14:51:45 +01:00
Bastian Wagner
f88fe93182 styling 2026-03-05 10:34:10 +01:00
Bastian Wagner
020216026e Einstellungen auf Dialog umgebaut 2026-03-05 10:13:41 +01:00
Bastian Wagner
026e47cd1b docs 2026-02-27 15:11:12 +01:00
Bastian Wagner
f1680ae07a bump 2026-02-27 11:32:06 +01:00
Bastian Wagner
f86c9c681a Unit tests 2026-02-27 11:03:35 +01:00
Bastian Wagner
5aa97cd8ea cleaning 2026-02-25 16:01:06 +01:00
Bastian Wagner
f15df81fed hidden columns 2026-02-25 14:17:36 +01:00
Bastian Wagner
53fa657099 pwa name angepasst 2026-02-25 10:29:50 +01:00
Bastian Wagner
d9f633deef zylinder entity 2026-02-25 10:28:24 +01:00
Bastian Wagner
447ac5d6ca cylinder entity umbenannnt 2026-02-24 15:04:31 +01:00
Bastian Wagner
f7e9ee493b Digitale Zylinder eingebaut 2026-02-24 14:55:19 +01:00
Bastian Wagner
e5c590165c schlüssel neuen zylindern zuordnen geht 2026-02-22 17:08:50 +01:00
Bastian Wagner
6797b73eb1 Schließanlagen können gelöscht werden 2026-02-20 16:44:59 +01:00
Bastian Wagner
955faa5cd5 styling 2026-02-20 13:52:19 +01:00
Bastian Wagner
62520466dc Impersination backend 2026-02-20 13:17:58 +01:00
Bastian Wagner
affea90e91 Renaming 2026-02-20 10:39:11 +01:00
Bastian Wagner
4e051a1f40 Logging und sowas 2026-02-20 10:28:48 +01:00
Bastian Wagner
29bfffc505 fixes 2026-02-19 22:29:46 +01:00
Bastian Wagner
4df51e0698 Filter Styling 2026-02-19 17:34:53 +01:00
Bastian Wagner
c542575046 Wording und API 2026-02-19 17:19:03 +01:00
Bastian Wagner
7bd6dfae27 Archive und Logging 2026-02-19 16:19:46 +01:00
Bastian Wagner
ef45e91141 Ag Grid anpassungen 2026-02-19 12:21:30 +01:00
Bastian Wagner
d7cfc89ba5 pwa icon 2026-02-18 14:14:15 +01:00
Bastian Wagner
0fd4967c44 pwa support 2026-02-18 14:02:45 +01:00
Bastian Wagner
dd59a62e96 Api umgebaut 2026-02-18 13:56:29 +01:00
Bastian Wagner
40e3ac187e queryparams aus url 2026-02-17 15:38:38 +01:00
Bastian Wagner
a292b29cb1 html angepasst 2026-02-17 13:46:12 +01:00
Bastian Wagner
df41dda7dc refactor 2026-02-17 10:56:08 +01:00
Bastian Wagner
eb5d9dd088 Ui
Some checks failed
Run Unit-Tests / test_frontend (push) Has been cancelled
Run Unit-Tests / test_backend (push) Has been cancelled
2026-02-16 15:13:05 +01:00
Bastian Wagner
8545ef3b36 docker
Some checks failed
Run Unit-Tests / test_frontend (push) Has been cancelled
Run Unit-Tests / test_backend (push) Has been cancelled
2026-02-16 15:03:47 +01:00
Bastian Wagner
57c1faa3ba docker
Some checks failed
Run Unit-Tests / test_frontend (push) Has been cancelled
Run Unit-Tests / test_backend (push) Has been cancelled
2026-02-16 14:52:15 +01:00
Bastian Wagner
e5bad1163b Docker
Some checks failed
Run Unit-Tests / test_frontend (push) Has been cancelled
Run Unit-Tests / test_backend (push) Has been cancelled
2026-02-16 14:42:55 +01:00
Bastian Wagner
62e7431112 typo
Some checks failed
Run Unit-Tests / test_frontend (push) Has been cancelled
Run Unit-Tests / test_backend (push) Has been cancelled
2026-02-16 14:35:08 +01:00
150 changed files with 8240 additions and 4462 deletions

View File

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

View File

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

View File

@@ -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
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 install --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
View File

@@ -0,0 +1,2 @@
export * from './repositories';
export * from './services';

View File

@@ -0,0 +1,2 @@
export * from './system.repository.mock';
export * from './user.repository.mock';

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

View File

@@ -0,0 +1 @@
export * from './mail.service.mock';

View File

@@ -0,0 +1,3 @@
export class MockMailService {
}

7767
api/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,39 +20,40 @@
"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",
"@aws-sdk/client-s3": "^3.1007.0",
"@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.16",
"@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",

View File

@@ -11,10 +11,10 @@ import { KeyModule } from './modules/key/key.module';
import { CustomerModule } from './modules/customer/customer.module';
import { CylinderModule } from './modules/cylinder/cylinder.module';
import { SystemModule } from './modules/system/system.module';
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';
import { StorageModule } from './shared/storage/storage.module';
@Module({
imports: [
@@ -22,7 +22,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 +33,17 @@ import { LogModule } from './modules/log/log.module';
SystemModule,
MailModule,
LogModule,
SseModule,
StorageModule
],
controllers: [AppController],
providers: [
AppService,
AuthGuard,
{
provide: APP_INTERCEPTOR,
useClass: CacheInterceptor,
},
// {
// provide: APP_INTERCEPTOR,
// useClass: CacheInterceptor,
// },
],
})
export class AppModule {}

View File

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

View File

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

View File

@@ -0,0 +1,17 @@
export class KeyHandoutPDFDataDto {
handoverId: string;
handoverDate: Date;
place: string;
giverName: string;
giverAddress: string;
receiverName: string;
receiverAddress: string;
keyType: string;
keyNumber: string;
quantity: number;
objectDescription: string;
notes: string;
}

View File

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

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

View File

@@ -5,3 +5,4 @@ export * from './customer.entity';
export * from './key-handout.entity';
export * from './activity.entity';
export * from './user';
export * from './key-handout-pdf-data.entity';

View File

@@ -0,0 +1,52 @@
import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";
import { KeyHandout } from "./key-handout.entity";
@Entity()
export class KeyHandoutPdfDataEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@ManyToOne(() => KeyHandout, handout => handout.pdfs)
handout: KeyHandout
@Column({ type: 'date' })
handoverDate: Date;
@Column({ name: 'giver_name' })
giverName: string;
@Column({ name: 'giver_address', nullable: true })
giverAddress: string;
@Column({ name: 'receiver_name' })
receiverName: string;
@Column({ name: 'receiver_address', nullable: true })
receiverAddress: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@Column({ name: 'file_name', nullable: true })
fileName: string;
@Column({ name: 'key_nr' })
keyNumber: string;
@Column({ type: 'int', default: 1 })
quantity: number;
@Column({ name: 'object_description', nullable: true })
objectDescription: string;
@Column({ nullable: true })
notes: string;
@UpdateDateColumn({ name: 'updatet_at' })
updatedAt: Date;
}

View File

@@ -4,11 +4,13 @@ import {
CreateDateColumn,
Entity,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
} from 'typeorm';
import { Key } from './key.entity';
import { Customer } from './customer.entity';
import { User } from './user';
import { KeyHandoutPdfDataEntity } from './key-handout-pdf-data.entity';
@Entity()
export class KeyHandout {
@@ -33,6 +35,10 @@ export class KeyHandout {
@ManyToOne(() => User)
user: User;
@OneToMany(() => KeyHandoutPdfDataEntity, pdf => pdf.handout)
pdfs: KeyHandoutPdfDataEntity[];
@BeforeInsert()
insertTimestamp() {
if (this.timestamp == null) {

View File

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

View 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
}

View File

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

View File

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

View File

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

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

View File

@@ -7,3 +7,4 @@ export * from './key.repository';
export * from './customer.repository';
export * from './activity.repository';
export * from './user.settings.repository';
export * from './key-handout-pdf-data.repository';

View File

@@ -0,0 +1,10 @@
import { Injectable } from '@nestjs/common';
import { Repository, DataSource } from 'typeorm';
import { KeyHandoutPdfDataEntity } from '../entitites';
@Injectable()
export class KeyHandoutPdfDataEntityRepository extends Repository<KeyHandoutPdfDataEntity> {
constructor(dataSource: DataSource) {
super(KeyHandoutPdfDataEntity, dataSource.createEntityManager());
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>) {
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
});
try {
const c = await this.cylinderRepo.save(this.cylinderRepo.create(cylinder));
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);
}
}

View File

@@ -7,12 +7,16 @@ import {
Post,
Put,
Req,
Res,
UseGuards,
} from '@nestjs/common';
import { Response } from 'express';
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 { KeyHandoutPDFDataDto } from 'src/model/dto/key-handover.dto';
@UseGuards(AuthGuard)
@Controller('key')
@@ -44,9 +48,26 @@ export class KeyController {
return this.service.restoreKey(req.user, id);
}
@Delete(':id')
deleteKey(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
return this.service.deleteKey(req.user, id);
@Get(':id/handover/pdf')
async getHandoverPDF(@Param('id') id: string, @Res() res: Response) {
const { pdf, response } = await this.service.getPdf(id);
res.setHeader(
'Content-Type',
response.headers['content-type'] ?? 'application/pdf',
);
if (response.headers['content-length']) {
res.setHeader('Content-Length', response.headers['content-length']);
}
if (response.headers['content-disposition']) {
res.setHeader('Content-Disposition', response.headers['content-disposition']);
} else {
res.setHeader('Content-Disposition', `inline; filename="uebergabe.pdf"`);
}
res.end(pdf);
}
@Post(':id/handover')
@@ -63,8 +84,35 @@ export class KeyController {
return this.service.getKeyHandovers(req.user, id);
}
@Get('Archive')
@Get('archive')
getArchive(@Req() req: AuthenticatedRequest) {
return this.service.getDeletedKeys(req.user);
}
@Post('pdf')
async generatePdf(@Body() dto: KeyHandoutPDFDataDto, @Res() res: Response) {
const { pdf, response } = await this.service.createPdf(dto);
res.setHeader(
'Content-Type',
response.headers['content-type'] ?? 'application/pdf',
);
if (response.headers['content-length']) {
res.setHeader('Content-Length', response.headers['content-length']);
}
if (response.headers['content-disposition']) {
res.setHeader('Content-Disposition', response.headers['content-disposition']);
} else {
res.setHeader('Content-Disposition', `inline; filename="uebergabe.pdf"`);
}
res.end(pdf);
}
@Delete(':id')
deleteKey(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
return this.service.deleteKey(req.user, id);
}
}

View File

@@ -5,10 +5,14 @@ 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';
import { HttpModule } from '@nestjs/axios';
import { StorageModule } from 'src/shared/storage/storage.module';
@Module({
controllers: [KeyController],
providers: [KeyService, ConfigService],
imports: [DatabaseModule, AuthModule, SharedServiceModule],
imports: [DatabaseModule, AuthModule, SharedServiceModule, MailModule, SseModule, HttpModule, StorageModule],
})
export class KeyModule {}

View File

@@ -2,15 +2,19 @@ import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { Customer, Cylinder, Key, User } from 'src/model/entitites';
import {
CylinderRepository,
KeyHandoutPdfDataEntityRepository,
KeyRepository,
KeySystemRepository,
} from 'src/model/repositories';
import { KeyHandoutRepository } from 'src/model/repositories/key-handout.repository';
import { ActivityHelperService } from 'src/shared/service/activity.logger.service';
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';
import { KeyHandoutPDFDataDto } from 'src/model/dto/key-handover.dto';
import { PdfService } from 'src/shared/storage/services/pdf/pdf.service';
import { AxiosResponse } from 'axios';
@Injectable()
export class KeyService {
@@ -20,8 +24,14 @@ 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,
private readonly pdfService: PdfService,
private readonly handoutPDFRepo: KeyHandoutPdfDataEntityRepository
) {
// this.getLatestHandoverPDF('a4f4f32b-96de-4f20-b5bd-ac3a128da121')
}
get isDevelopMode(): boolean {
return (this.configService.get('DEVELOP_MODE') || '').toLowerCase() == 'true';
@@ -72,8 +82,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 +143,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;
}
@@ -121,7 +169,7 @@ export class KeyService {
timestamp: { direction: 'DESC' },
created: { direction: 'DESC' },
},
relations: ['customer'],
relations: ['customer', 'pdfs'],
});
}
@@ -144,18 +192,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 +221,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 +230,39 @@ 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;
}
async createPdf(dto: KeyHandoutPDFDataDto): Promise<{ pdf: Buffer, response: AxiosResponse}> {
const handout = await this.handoverRepo.findOneByOrFail({ id: dto.handoverId })
const data = {
...dto,
handout
}
data.handoverDate = new Date(data.handoverDate);
const entity = await this.handoutPDFRepo.save(this.handoutPDFRepo.create(data));
const fileName = await this.pdfService.generatePDF(dto);
entity.createdAt = new Date();
entity.fileName = fileName;
await this.handoutPDFRepo.save(entity);
const file = await this.pdfService.getPDFByHandoverKey(fileName);
return file;
}
async getPdf(handoverId: string): Promise<{ pdf: Buffer, response: AxiosResponse}> {
return this.pdfService.getPDFByHandoverKey(handoverId);
}
async getLatestHandoverPDF(handoverId: string) {
const pdf = await this.handoutPDFRepo.findOne({
where: { handout: { id: handoverId}},
order: {createdAt: 'DESC' },
});
console.log(pdf)
}
}

View File

@@ -0,0 +1,7 @@
import { LogType } from "./log.service";
export class LogMockService {
log = jest.fn().mockImplementation((type: LogType, data: any) => {
return true;
})
}

View File

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

View File

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -6,17 +6,20 @@ import {
Cylinder,
Key,
KeyHandout,
KeyHandoutPdfDataEntity,
Role,
SSOUser,
User,
} 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,
CustomerRepository,
CylinderRepository,
KeyHandoutPdfDataEntityRepository,
KeyRepository,
KeySystemRepository,
RoleRepository,
@@ -26,6 +29,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 +43,8 @@ const ENTITIES = [
Activity,
EmailLog,
UserSettings,
Impersonation,
KeyHandoutPdfDataEntity
];
const REPOSITORIES = [
UserRepository,
@@ -51,7 +57,9 @@ const REPOSITORIES = [
KeyHandoutRepository,
ActivityRepository,
EmailLogRepository,
UserSettingsRepository
UserSettingsRepository,
ImpersonationRepository,
KeyHandoutPdfDataEntityRepository
];
@Module({

View File

@@ -0,0 +1,3 @@
export class ActivityHelperMockService {
}

View File

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

View File

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

View File

@@ -0,0 +1,47 @@
import { HttpService } from '@nestjs/axios';
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AxiosResponse } from 'axios';
import { firstValueFrom } from 'rxjs';
import { KeyHandoutPDFDataDto } from 'src/model/dto/key-handover.dto';
import { KeyHandoutRepository } from 'src/model/repositories/key-handout.repository';
@Injectable()
export class PdfService {
private readonly STORAGESERVER = this.configService.get('STORAGE_HOST')
constructor(
private readonly configService: ConfigService,
private readonly httpService: HttpService
) { }
public async generatePDF(dto: KeyHandoutPDFDataDto): Promise<string> {
const response = await this.post(`${this.STORAGESERVER}/pdf/keyhandover`, dto);
if (response?.data == null) { throw new HttpException('Konnte nicht erstellt werden', HttpStatus.INTERNAL_SERVER_ERROR) }
return response.data;
}
public async getPDFByHandoverKey(key: string): Promise<{ pdf: Buffer, response: AxiosResponse}> {
return new Promise(resolve => {
this.httpService.get(`${this.STORAGESERVER}/pdf/keyhandover/${key}`, {
responseType: 'arraybuffer',
}).subscribe({
next: pdf => {
const pdfBuffer = Buffer.from(pdf.data);
resolve({ pdf: pdfBuffer, response: pdf})
},
error: e => console.log(e)
})
})
}
private async post(url: string, data: any): Promise<AxiosResponse> {
return new Promise(resolve => {
this.httpService.post(url, data).subscribe({
next: data => resolve(data)
})
})
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { StorageService } from './storage.service';
import { HttpModule } from '@nestjs/axios';
import { ConfigModule } from '@nestjs/config';
import { PdfService } from './services/pdf/pdf.service';
@Module({
imports: [HttpModule, ConfigModule],
providers: [StorageService, PdfService ],
exports: [ StorageService, PdfService ]
})
export class StorageModule {}

View File

@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class StorageService {
constructor() {}
}

View 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>

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,11 +1,8 @@
{
"/api": {
"target": "http://keyvaultpro.de:3701",
"secure": false,
"logLevel": "debug",
"changeOrigin": true,
"pathRewrite": {
"^/api": ""
}
}
"/api": {
"target": "https://keyvaultpro.de",
"secure": true,
"changeOrigin": true,
"logLevel": "debug"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View 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"
}
]
}

View File

@@ -1,3 +0,0 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv();

View File

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

View File

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

View File

@@ -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 => {
this._user = user;
resolve(true)
},
error: () => {
resolve(false)
}
})
})
const user = await this.api.getMe();
if (user) {
this._user = user;
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)
// }
// })
// })
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
export interface ICustomer {
id: string;
name: string;
createdAt: string;
updatetAt: String;
}

View File

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

View File

@@ -0,0 +1,17 @@
export interface IKeyHandoverPDF {
handoverId: string;
handoverDate: Date;
place: string;
giverName: string;
giverAddress?: string;
receiverName: string;
receiverAddress?: string;
keyType: string;
keyNumber?: string;
quantity: number;
objectDescription?: string;
notes?: string;
}

View File

@@ -10,4 +10,5 @@ export interface IKey {
nr: number;
deletedAt?: string;
keyLost: Date | null;
digital: boolean;
}

View File

@@ -0,0 +1,7 @@
export interface ISystem {
id: string;
createdAt: string;
updatedAt: string;
deletedAt?: string;
name: string;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,13 @@
<h2 mat-dialog-title>Gelöschte Schlüssel</h2>
<mat-dialog-content>
<ag-grid-angular
@if(myTheme) {
<ag-grid-angular
style="width: 100%; height: 100%;"
(gridReady)="onGridReady($event)"
[gridOptions]="gridOptions!"
[theme]="myTheme"
/>
}
</mat-dialog-content>
<mat-dialog-actions>

View File

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

Some files were not shown because too many files have changed in this diff Show More