@@ -0,0 +1,23 @@
|
||||
name: Deploy to Dev Server
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Deploy via SSH
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: ${{ secrets.DEV_SERVER_HOST }}
|
||||
username: ${{ secrets.DEV_SERVER_USER }}
|
||||
password: ${{ secrets.DEV_SERVER_PASSWORD }}
|
||||
script: |
|
||||
cd /home/tom/lub/website
|
||||
git pull origin main
|
||||
docker compose down
|
||||
docker compose up -d --build
|
||||
docker system prune -f
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
tmp/
|
||||
out-tsc/
|
||||
bazel-out/
|
||||
|
||||
# Angular
|
||||
.angular/
|
||||
.sass-cache/
|
||||
|
||||
# Vite
|
||||
.vite/
|
||||
vite/
|
||||
|
||||
# Environment (aber example behalten!)
|
||||
.env
|
||||
.env.development
|
||||
.env.production
|
||||
*.local
|
||||
!.env.example
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Database
|
||||
*.sqlite
|
||||
@@ -0,0 +1,8 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY apps/backend/package*.json ./
|
||||
RUN npm install
|
||||
COPY apps/backend .
|
||||
RUN npm run build
|
||||
EXPOSE 3000
|
||||
CMD ["node", "dist/main.js"]
|
||||
@@ -0,0 +1,12 @@
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY apps/frontend/package*.json ./
|
||||
RUN npm install
|
||||
COPY apps/frontend .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist/*/browser /usr/share/nginx/html
|
||||
COPY apps/frontend/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,60 @@
|
||||
```
|
||||
██╗ ██████╗ ██╗████████╗
|
||||
██║ ██╔══██╗ ██║╚══██╔══╝
|
||||
██║ ██████╔╝ ██║ ██║
|
||||
██║ ██╔══██╗ ██║ ██║
|
||||
███████╗██████╔╝ ██║ ██║
|
||||
╚══════╝╚═════╝ ╚═╝ ╚═╝
|
||||
```
|
||||
|
||||
<div align="center">
|
||||
|
||||
# Leonards & Brandenburger IT
|
||||
|
||||
**Angular • NestJS • PostgreSQL**
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
npm run dev
|
||||
```
|
||||
|
||||
| Service | URL |
|
||||
|----------|---------------------------|
|
||||
| 🎨 Frontend | http://localhost:4200 |
|
||||
| ⚡ Backend | http://localhost:3000 |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Production
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
| Service | URL |
|
||||
|----------|---------------------------|
|
||||
| 🎨 Frontend | http://localhost |
|
||||
| ⚡ Backend | http://localhost:3000 |
|
||||
|
||||
---
|
||||
|
||||
## 🛑 Stop
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
Made with ☕ in Westerwald
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,39 @@
|
||||
!!IN NEXTCLOUD SIND DIE DATEIEN ZU FINDEN!!
|
||||
!!IN NEXTCLOUD SIND DIE DATEIEN ZU FINDEN!!
|
||||
!!IN NEXTCLOUD SIND DIE DATEIEN ZU FINDEN!!
|
||||
!!IN NEXTCLOUD SIND DIE DATEIEN ZU FINDEN!!
|
||||
!!IN NEXTCLOUD SIND DIE DATEIEN ZU FINDEN!!
|
||||
!!IN NEXTCLOUD SIND DIE DATEIEN ZU FINDEN!!
|
||||
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_USER=app
|
||||
DB_PASS=secret
|
||||
DB_NAME=appdb
|
||||
DB_SSL=false
|
||||
|
||||
#Stays Same on all Clusters
|
||||
OPENAI_API_KEY=your_openai_api_key_here
|
||||
JWT_SECRET=your_jwt_secret_here
|
||||
ADMIN_EMAIL=tom@leonardsmedia.de
|
||||
EMAILJS_SERVICE_ID=your_emailjs_service_id_here
|
||||
EMAILJS_TEMPLATE_ID=your_emailjs_template_id_here
|
||||
EMAILJS_PUBLIC_KEY=your_emailjs_public_key_here
|
||||
EMAILJS_PRIVATE_KEY=your_emailjs_private_key_here
|
||||
COMPANY_NAME=LeonardsMedia
|
||||
COMPANY_EMAIL=tom@leonardsmedia.de
|
||||
GOOGLE_CLIENT_ID=your_google_client_id_here
|
||||
GOOGLE_CLIENT_SECRET=your_google_client_secret_here
|
||||
GOOGLE_REFRESH_TOKEN=your_google_refresh_token_here
|
||||
COMPANY_WEBSITE=www.leonardsmedia.de
|
||||
|
||||
#ChangeOnCluster
|
||||
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/google/callback
|
||||
NODE_ENV=development
|
||||
PORT=3000
|
||||
SEND_MAIL=false
|
||||
FRONTEND_URL=http://localhost:4200
|
||||
DATABASE_URL=postgres://app:secret@localhost:5432/appdb
|
||||
DB_SSL=falsev
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
tsconfigRootDir: __dirname,
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['@typescript-eslint/eslint-plugin'],
|
||||
extends: [
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
ignorePatterns: ['.eslintrc.js'],
|
||||
rules: {
|
||||
'@typescript-eslint/interface-name-prefix': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
#Stays Same on all Clusters
|
||||
OPENAI_API_KEY=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
|
||||
JWT_SECRET=420187133769
|
||||
ADMIN_EMAIL=tom@leonardsmedia.de
|
||||
EMAILJS_SERVICE_ID=service_l7xb8om
|
||||
EMAILJS_TEMPLATE_ID=template_wywhnxf
|
||||
EMAILJS_PUBLIC_KEY=qtIi7eLKJLQIuT7Ta
|
||||
EMAILJS_PRIVATE_KEY=cPkM5NiZ2bdeCzqfpOYPw
|
||||
COMPANY_NAME=LeonardsMedia
|
||||
COMPANY_EMAIL=tom@leonardsmedia.de
|
||||
GOOGLE_CLIENT_ID=957243018391-m88a311kc166ealp1qjppp3v3eke900m.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=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
|
||||
GOOGLE_REFRESH_TOKEN=1//036AecH4b3sEeCgYIARAAGAMSNwF-L9IrBAy_t8ARHngnbNdTS9pGFgrkmBIHtd4Vdpnm6Go8SPMPmPf3-djYhBBwulKD7D8jPzM
|
||||
COMPANY_WEBSITE=www.leonardsmedia.de
|
||||
|
||||
#ChangeOnCluster
|
||||
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/google/callback
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
SEND_MAIL=true
|
||||
FRONTEND_URL=https://leonardsmedia.de
|
||||
DATABASE_URL=postgres://app:secret@db:5432/appdb
|
||||
DB_SSL=false
|
||||
@@ -0,0 +1,85 @@
|
||||
<p align="center">
|
||||
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
|
||||
</p>
|
||||
|
||||
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
||||
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
||||
|
||||
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
|
||||
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
|
||||
<a href="https://coveralls.io/github/nestjs/nest?branch=master" target="_blank"><img src="https://coveralls.io/repos/github/nestjs/nest/badge.svg?branch=master#9" alt="Coverage" /></a>
|
||||
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
|
||||
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
|
||||
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
|
||||
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
|
||||
</p>
|
||||
<!--[](https://opencollective.com/nest#backer)
|
||||
[](https://opencollective.com/nest#sponsor)-->
|
||||
|
||||
## Description
|
||||
|
||||
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
||||
|
||||
## Project setup
|
||||
|
||||
```bash
|
||||
$ npm install
|
||||
```
|
||||
|
||||
## Compile and run the project
|
||||
|
||||
```bash
|
||||
# development
|
||||
$ npm run start
|
||||
|
||||
# watch mode
|
||||
$ npm run start:dev
|
||||
|
||||
# production mode
|
||||
$ npm run start:prod
|
||||
```
|
||||
|
||||
## Run tests
|
||||
|
||||
```bash
|
||||
# unit tests
|
||||
$ npm run test
|
||||
|
||||
# e2e tests
|
||||
$ npm run test:e2e
|
||||
|
||||
# test coverage
|
||||
$ npm run test:cov
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
Check out a few resources that may come in handy when working with NestJS:
|
||||
|
||||
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
|
||||
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
|
||||
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
|
||||
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
|
||||
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
|
||||
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
|
||||
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
|
||||
|
||||
## Support
|
||||
|
||||
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
||||
|
||||
## Stay in touch
|
||||
|
||||
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
|
||||
- Website - [https://nestjs.com](https://nestjs.com/)
|
||||
- Twitter - [@nestframework](https://twitter.com/nestframework)
|
||||
|
||||
## License
|
||||
|
||||
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
|
||||
@@ -0,0 +1,790 @@
|
||||
[
|
||||
{
|
||||
"id": "1a2b3c4d-5e6f-7890-abcd-ef1234567890",
|
||||
"slug": "was-ist-leonards-brandenburger-it",
|
||||
"question": "Was ist Leonards & Brandenburger IT?",
|
||||
"answers": [
|
||||
"Wir sind ein junges IT-Unternehmen aus dem Westerwald, das sich auf umfassende IT-Dienstleistungen für Privatkunden und kleine Unternehmen spezialisiert hat.",
|
||||
"Unser Fokus liegt darauf, professionelle IT-Lösungen auch in ländlichen Regionen zugänglich zu machen."
|
||||
],
|
||||
"listItems": [
|
||||
"Gegründet von zwei erfahrenen Entwicklern",
|
||||
"Spezialisiert auf ländliche Regionen",
|
||||
"Persönlicher Service vor Ort"
|
||||
],
|
||||
"sortOrder": 1,
|
||||
"isPublished": true,
|
||||
"category": "Allgemein",
|
||||
"createdAt": "2026-01-21T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-21T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "2b3c4d5e-6f78-90ab-cdef-234567890abc",
|
||||
"slug": "welche-regionen-bedienen-sie",
|
||||
"question": "Welche Regionen bedienen Sie?",
|
||||
"answers": [
|
||||
"Wir sind primär im Westerwald und der Region um Altenkirchen tätig.",
|
||||
"Auf Anfrage kommen wir auch gerne in umliegende Gebiete wie den Kreis Neuwied, Siegen-Wittgenstein oder den Rhein-Sieg-Kreis."
|
||||
],
|
||||
"listItems": [
|
||||
"Westerwald",
|
||||
"Kreis Altenkirchen",
|
||||
"Umliegende Regionen auf Anfrage"
|
||||
],
|
||||
"sortOrder": 2,
|
||||
"isPublished": true,
|
||||
"category": "Allgemein",
|
||||
"createdAt": "2026-01-21T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-21T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "3c4d5e6f-7890-abcd-ef12-34567890abcd",
|
||||
"slug": "bieten-sie-vor-ort-service",
|
||||
"question": "Bieten Sie Vor-Ort-Service an?",
|
||||
"answers": [
|
||||
"Ja, wir kommen direkt zu Ihnen nach Hause oder in Ihr Büro.",
|
||||
"Gerade in ländlichen Regionen wissen wir, wie wichtig persönlicher Service vor Ort ist."
|
||||
],
|
||||
"listItems": [
|
||||
"Hausbesuche für Privatkunden",
|
||||
"Vor-Ort-Support für Unternehmen",
|
||||
"Flexible Terminvereinbarung"
|
||||
],
|
||||
"sortOrder": 3,
|
||||
"isPublished": true,
|
||||
"category": "Service",
|
||||
"createdAt": "2026-01-21T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-21T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "4d5e6f78-90ab-cdef-1234-567890abcdef",
|
||||
"slug": "was-kostet-eine-beratung",
|
||||
"question": "Was kostet eine Erstberatung?",
|
||||
"answers": [
|
||||
"Die telefonische Erstberatung ist bei uns kostenlos.",
|
||||
"So können wir gemeinsam herausfinden, wie wir Ihnen am besten helfen können, bevor Kosten entstehen."
|
||||
],
|
||||
"listItems": [
|
||||
"Telefonische Beratung kostenlos",
|
||||
"Transparente Preisgestaltung",
|
||||
"Kostenvoranschlag vor Arbeitsbeginn"
|
||||
],
|
||||
"sortOrder": 4,
|
||||
"isPublished": true,
|
||||
"category": "Preise",
|
||||
"createdAt": "2026-01-21T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-21T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "5e6f7890-abcd-ef12-3456-7890abcdef12",
|
||||
"slug": "pc-startet-nicht-mehr",
|
||||
"question": "Mein PC startet nicht mehr – können Sie helfen?",
|
||||
"answers": [
|
||||
"Ja, Computerreparaturen gehören zu unserem Kerngeschäft.",
|
||||
"Wir diagnostizieren das Problem und reparieren Ihren PC – oft noch am selben Tag."
|
||||
],
|
||||
"listItems": [
|
||||
"Hardware-Diagnose",
|
||||
"Softwareprobleme beheben",
|
||||
"Datenrettung wenn möglich"
|
||||
],
|
||||
"sortOrder": 5,
|
||||
"isPublished": true,
|
||||
"category": "PC-Reparatur",
|
||||
"createdAt": "2026-01-21T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-21T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "6f7890ab-cdef-1234-5678-90abcdef1234",
|
||||
"slug": "laptop-ist-langsam",
|
||||
"question": "Mein Laptop ist sehr langsam geworden – was kann ich tun?",
|
||||
"answers": [
|
||||
"Ein langsamer Laptop kann viele Ursachen haben: veraltete Hardware, zu viele Programme oder sogar Schadsoftware.",
|
||||
"Wir analysieren Ihr Gerät und optimieren es oder empfehlen sinnvolle Upgrades wie eine SSD."
|
||||
],
|
||||
"listItems": [
|
||||
"System-Optimierung",
|
||||
"SSD-Upgrade",
|
||||
"RAM-Erweiterung",
|
||||
"Virenentfernung"
|
||||
],
|
||||
"sortOrder": 6,
|
||||
"isPublished": true,
|
||||
"category": "PC-Reparatur",
|
||||
"createdAt": "2026-01-21T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-21T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "7890abcd-ef12-3456-7890-abcdef123456",
|
||||
"slug": "daten-wiederherstellen",
|
||||
"question": "Können Sie gelöschte Daten wiederherstellen?",
|
||||
"answers": [
|
||||
"In vielen Fällen ja. Je schneller Sie uns kontaktieren, desto höher die Erfolgschancen.",
|
||||
"Wir nutzen professionelle Tools zur Datenrettung von Festplatten, SSDs und USB-Sticks."
|
||||
],
|
||||
"listItems": [
|
||||
"Datenrettung von Festplatten",
|
||||
"SSD-Wiederherstellung",
|
||||
"USB-Stick und SD-Karten"
|
||||
],
|
||||
"sortOrder": 7,
|
||||
"isPublished": true,
|
||||
"category": "Datenrettung",
|
||||
"createdAt": "2026-01-21T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-21T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "890abcde-f123-4567-890a-bcdef1234567",
|
||||
"slug": "neuen-pc-kaufen-beratung",
|
||||
"question": "Ich möchte einen neuen PC kaufen – beraten Sie mich?",
|
||||
"answers": [
|
||||
"Selbstverständlich! Wir beraten Sie herstellerunabhängig und finden das beste Gerät für Ihre Bedürfnisse und Ihr Budget.",
|
||||
"Auf Wunsch kümmern wir uns auch um Beschaffung, Einrichtung und Datenübertragung."
|
||||
],
|
||||
"listItems": [
|
||||
"Unabhängige Kaufberatung",
|
||||
"Einrichtung des neuen Geräts",
|
||||
"Datenübertragung vom Altgerät"
|
||||
],
|
||||
"sortOrder": 8,
|
||||
"isPublished": true,
|
||||
"category": "Beratung",
|
||||
"createdAt": "2026-01-21T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-21T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "90abcdef-1234-5678-90ab-cdef12345678",
|
||||
"slug": "wlan-probleme",
|
||||
"question": "Mein WLAN funktioniert nicht richtig – können Sie helfen?",
|
||||
"answers": [
|
||||
"Ja, Netzwerkprobleme sind einer unserer häufigsten Einsätze.",
|
||||
"Wir prüfen Ihre Verbindung, optimieren die Router-Einstellungen und verbessern bei Bedarf die WLAN-Abdeckung."
|
||||
],
|
||||
"listItems": [
|
||||
"Router-Konfiguration",
|
||||
"WLAN-Optimierung",
|
||||
"Repeater-Installation",
|
||||
"Mesh-Netzwerke"
|
||||
],
|
||||
"sortOrder": 9,
|
||||
"isPublished": true,
|
||||
"category": "Netzwerk",
|
||||
"createdAt": "2026-01-21T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-21T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "0abcdef1-2345-6789-0abc-def123456789",
|
||||
"slug": "heimnetzwerk-einrichten",
|
||||
"question": "Können Sie mir ein Heimnetzwerk einrichten?",
|
||||
"answers": [
|
||||
"Ja, wir planen und installieren komplette Heimnetzwerke – von einfachen Lösungen bis hin zu professionellen Setups.",
|
||||
"Dazu gehören Router-Konfiguration, NAS-Einrichtung, Smart-Home-Integration und mehr."
|
||||
],
|
||||
"listItems": [
|
||||
"Router und Access Points",
|
||||
"NAS-Systeme",
|
||||
"Smart-Home-Anbindung",
|
||||
"Netzwerk-Verkabelung"
|
||||
],
|
||||
"sortOrder": 10,
|
||||
"isPublished": true,
|
||||
"category": "Netzwerk",
|
||||
"createdAt": "2026-01-21T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-21T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "abcdef12-3456-7890-abcd-ef1234567890",
|
||||
"slug": "virus-entfernen",
|
||||
"question": "Ich glaube, mein Computer hat einen Virus – was nun?",
|
||||
"answers": [
|
||||
"Keine Panik! Wir entfernen Viren, Trojaner und andere Schadsoftware zuverlässig von Ihrem System.",
|
||||
"Anschließend installieren wir einen aktuellen Virenschutz und zeigen Ihnen, wie Sie sich zukünftig schützen."
|
||||
],
|
||||
"listItems": [
|
||||
"Virenentfernung",
|
||||
"Malware-Bereinigung",
|
||||
"Virenschutz-Installation",
|
||||
"Sicherheitsberatung"
|
||||
],
|
||||
"sortOrder": 11,
|
||||
"isPublished": true,
|
||||
"category": "Sicherheit",
|
||||
"createdAt": "2026-01-21T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-21T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "bcdef123-4567-890a-bcde-f12345678901",
|
||||
"slug": "webseite-erstellen-lassen",
|
||||
"question": "Können Sie mir eine Webseite erstellen?",
|
||||
"answers": [
|
||||
"Ja, wir erstellen professionelle Webseiten für Privatpersonen, Vereine und kleine Unternehmen.",
|
||||
"Von der einfachen Visitenkarten-Seite bis zur komplexen Webanwendung – wir setzen Ihre Wünsche um."
|
||||
],
|
||||
"listItems": [
|
||||
"Responsive Design",
|
||||
"Moderne Technologien",
|
||||
"Suchmaschinenoptimierung",
|
||||
"Wartung und Updates"
|
||||
],
|
||||
"sortOrder": 12,
|
||||
"isPublished": true,
|
||||
"category": "Webentwicklung",
|
||||
"createdAt": "2026-01-21T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-21T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "cdef1234-5678-90ab-cdef-123456789012",
|
||||
"slug": "webseite-kosten",
|
||||
"question": "Was kostet eine Webseite bei Ihnen?",
|
||||
"answers": [
|
||||
"Die Kosten hängen vom Umfang ab. Eine einfache Visitenkarten-Seite gibt es bereits ab wenigen hundert Euro.",
|
||||
"Wir erstellen Ihnen gerne ein individuelles Angebot nach einem kostenlosen Beratungsgespräch."
|
||||
],
|
||||
"listItems": [
|
||||
"Individuelle Angebote",
|
||||
"Transparente Preisgestaltung",
|
||||
"Verschiedene Pakete verfügbar"
|
||||
],
|
||||
"sortOrder": 13,
|
||||
"isPublished": true,
|
||||
"category": "Webentwicklung",
|
||||
"createdAt": "2026-01-21T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-21T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "def12345-6789-0abc-def1-234567890123",
|
||||
"slug": "smart-home-einrichten",
|
||||
"question": "Können Sie mir bei der Smart-Home-Einrichtung helfen?",
|
||||
"answers": [
|
||||
"Ja, wir beraten Sie bei der Auswahl kompatibler Geräte und richten Ihr Smart Home komplett ein.",
|
||||
"Egal ob Beleuchtung, Heizung, Sicherheit oder Sprachassistenten – wir bringen alles zum Laufen."
|
||||
],
|
||||
"listItems": [
|
||||
"Produktberatung",
|
||||
"Installation und Einrichtung",
|
||||
"App-Konfiguration",
|
||||
"Automatisierungen erstellen"
|
||||
],
|
||||
"sortOrder": 14,
|
||||
"isPublished": true,
|
||||
"category": "Smart Home",
|
||||
"createdAt": "2026-01-21T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-21T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "ef123456-7890-abcd-ef12-345678901234",
|
||||
"slug": "drucker-einrichten",
|
||||
"question": "Mein Drucker will nicht drucken – können Sie helfen?",
|
||||
"answers": [
|
||||
"Druckerprobleme sind oft frustrierend, aber meist lösbar.",
|
||||
"Wir richten Ihren Drucker ein, beheben Verbindungsprobleme und konfigurieren auch Netzwerkdrucker."
|
||||
],
|
||||
"listItems": [
|
||||
"Drucker-Installation",
|
||||
"Treiber-Updates",
|
||||
"Netzwerkdrucker einrichten",
|
||||
"WLAN-Drucker konfigurieren"
|
||||
],
|
||||
"sortOrder": 15,
|
||||
"isPublished": true,
|
||||
"category": "Hardware",
|
||||
"createdAt": "2026-01-21T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-21T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "f1234567-890a-bcde-f123-456789012345",
|
||||
"slug": "windows-neu-installieren",
|
||||
"question": "Können Sie Windows neu installieren?",
|
||||
"answers": [
|
||||
"Ja, wir installieren Windows komplett neu und richten alle Treiber und wichtigen Programme ein.",
|
||||
"Auf Wunsch sichern wir vorher Ihre Daten und spielen sie nach der Installation wieder zurück."
|
||||
],
|
||||
"listItems": [
|
||||
"Windows-Installation",
|
||||
"Treiber und Updates",
|
||||
"Grundprogramme einrichten",
|
||||
"Datensicherung und -wiederherstellung"
|
||||
],
|
||||
"sortOrder": 16,
|
||||
"isPublished": true,
|
||||
"category": "Betriebssysteme",
|
||||
"createdAt": "2026-01-21T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-21T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "12345678-90ab-cdef-1234-567890abcdef",
|
||||
"slug": "backup-erstellen",
|
||||
"question": "Wie kann ich meine Daten sichern?",
|
||||
"answers": [
|
||||
"Wir richten für Sie eine automatische Backup-Lösung ein – lokal oder in der Cloud.",
|
||||
"So sind Ihre wichtigen Daten immer geschützt und im Notfall schnell wiederherstellbar."
|
||||
],
|
||||
"listItems": [
|
||||
"Lokale Backups",
|
||||
"Cloud-Backup",
|
||||
"Automatische Sicherung",
|
||||
"NAS als Backup-Ziel"
|
||||
],
|
||||
"sortOrder": 17,
|
||||
"isPublished": true,
|
||||
"category": "Datensicherung",
|
||||
"createdAt": "2026-01-21T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-21T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "23456789-0abc-def1-2345-67890abcdef1",
|
||||
"slug": "smartphone-hilfe",
|
||||
"question": "Helfen Sie auch bei Smartphone-Problemen?",
|
||||
"answers": [
|
||||
"Ja, wir unterstützen Sie bei der Einrichtung und Problemlösung von Android- und iOS-Geräten.",
|
||||
"Von der Ersteinrichtung bis zur Datenübertragung auf ein neues Handy – wir helfen gerne."
|
||||
],
|
||||
"listItems": [
|
||||
"Smartphone-Einrichtung",
|
||||
"Datenübertragung",
|
||||
"App-Installation",
|
||||
"E-Mail-Konfiguration"
|
||||
],
|
||||
"sortOrder": 18,
|
||||
"isPublished": true,
|
||||
"category": "Smartphones",
|
||||
"createdAt": "2026-01-21T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-21T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "34567890-abcd-ef12-3456-7890abcdef12",
|
||||
"slug": "email-einrichten",
|
||||
"question": "Können Sie mir bei der E-Mail-Einrichtung helfen?",
|
||||
"answers": [
|
||||
"Selbstverständlich. Wir richten E-Mail-Konten auf allen Geräten ein – PC, Laptop, Tablet und Smartphone.",
|
||||
"Auch bei Problemen mit bestehenden E-Mail-Konten oder dem Wechsel zu einem neuen Anbieter helfen wir."
|
||||
],
|
||||
"listItems": [
|
||||
"E-Mail auf allen Geräten",
|
||||
"Outlook-Konfiguration",
|
||||
"Thunderbird-Einrichtung",
|
||||
"Webmail-Zugang"
|
||||
],
|
||||
"sortOrder": 19,
|
||||
"isPublished": true,
|
||||
"category": "E-Mail",
|
||||
"createdAt": "2026-01-21T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-21T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "4567890a-bcde-f123-4567-890abcdef123",
|
||||
"slug": "unternehmen-it-support",
|
||||
"question": "Bieten Sie auch IT-Support für Unternehmen?",
|
||||
"answers": [
|
||||
"Ja, wir betreuen kleine und mittelständische Unternehmen mit regelmäßigem IT-Support.",
|
||||
"Von der Wartung über Problemlösung bis zur Beratung bei IT-Entscheidungen – wir sind Ihr verlässlicher Partner."
|
||||
],
|
||||
"listItems": [
|
||||
"Regelmäßige Wartung",
|
||||
"Schnelle Problemlösung",
|
||||
"IT-Beratung",
|
||||
"Flexible Support-Verträge"
|
||||
],
|
||||
"sortOrder": 20,
|
||||
"isPublished": true,
|
||||
"category": "Business",
|
||||
"createdAt": "2026-01-21T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-21T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "567890ab-cdef-1234-5678-90abcdef1234",
|
||||
"slug": "fernwartung",
|
||||
"question": "Bieten Sie auch Fernwartung an?",
|
||||
"answers": [
|
||||
"Ja, viele Probleme können wir per Fernwartung lösen – schnell und ohne Anfahrt.",
|
||||
"Sie müssen lediglich unser Fernwartungstool herunterladen, und wir können direkt auf Ihrem Bildschirm helfen."
|
||||
],
|
||||
"listItems": [
|
||||
"Schnelle Hilfe ohne Wartezeit",
|
||||
"Keine Anfahrtskosten",
|
||||
"Sichere Verbindung",
|
||||
"Sie behalten die Kontrolle"
|
||||
],
|
||||
"sortOrder": 21,
|
||||
"isPublished": true,
|
||||
"category": "Service",
|
||||
"createdAt": "2026-01-21T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-21T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "67890abc-def1-2345-6789-0abcdef12345",
|
||||
"slug": "wie-erreiche-ich-sie",
|
||||
"question": "Wie kann ich Sie am besten erreichen?",
|
||||
"answers": [
|
||||
"Sie erreichen uns telefonisch, per E-Mail oder über unser Kontaktformular auf der Webseite.",
|
||||
"Wir melden uns schnellstmöglich bei Ihnen zurück und vereinbaren einen Termin."
|
||||
],
|
||||
"listItems": [
|
||||
"Telefon",
|
||||
"E-Mail",
|
||||
"Kontaktformular",
|
||||
"Social Media"
|
||||
],
|
||||
"sortOrder": 22,
|
||||
"isPublished": true,
|
||||
"category": "Kontakt",
|
||||
"createdAt": "2026-01-21T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-21T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "7890abcd-ef12-3456-7890-abcdef123456",
|
||||
"slug": "wartezeit-termin",
|
||||
"question": "Wie lange muss ich auf einen Termin warten?",
|
||||
"answers": [
|
||||
"Das hängt von der aktuellen Auslastung ab, aber wir versuchen stets, zeitnah zu helfen.",
|
||||
"Bei dringenden Problemen bemühen wir uns um einen Termin innerhalb weniger Tage."
|
||||
],
|
||||
"listItems": [
|
||||
"Schnelle Terminvergabe",
|
||||
"Notfälle priorisiert",
|
||||
"Flexible Zeiten"
|
||||
],
|
||||
"sortOrder": 23,
|
||||
"isPublished": true,
|
||||
"category": "Service",
|
||||
"createdAt": "2026-01-21T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-21T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "890abcde-f123-4567-890a-bcdef1234567",
|
||||
"slug": "zahlung-bar-karte",
|
||||
"question": "Welche Zahlungsmöglichkeiten gibt es?",
|
||||
"answers": [
|
||||
"Sie können bar, per Überweisung oder auf Rechnung bezahlen.",
|
||||
"Für Geschäftskunden bieten wir auch monatliche Abrechnungen an."
|
||||
],
|
||||
"listItems": [
|
||||
"Barzahlung",
|
||||
"Überweisung",
|
||||
"Rechnung",
|
||||
"Monatliche Abrechnung für Unternehmen"
|
||||
],
|
||||
"sortOrder": 24,
|
||||
"isPublished": true,
|
||||
"category": "Preise",
|
||||
"createdAt": "2026-01-21T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-21T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "90abcdef-1234-5678-90ab-cdef12345678",
|
||||
"slug": "garantie-auf-arbeit",
|
||||
"question": "Gibt es Garantie auf Ihre Arbeit?",
|
||||
"answers": [
|
||||
"Ja, auf unsere Dienstleistungen geben wir eine Zufriedenheitsgarantie.",
|
||||
"Sollte ein Problem nach unserer Arbeit erneut auftreten, beheben wir es kostenlos."
|
||||
],
|
||||
"listItems": [
|
||||
"Zufriedenheitsgarantie",
|
||||
"Kostenlose Nachbesserung",
|
||||
"Transparente Kommunikation"
|
||||
],
|
||||
"sortOrder": 25,
|
||||
"isPublished": true,
|
||||
"category": "Service",
|
||||
"createdAt": "2026-01-21T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-21T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "0abcdef1-2345-6789-0abc-def123456789",
|
||||
"slug": "schulung-angeboten",
|
||||
"question": "Bieten Sie auch Schulungen an?",
|
||||
"answers": [
|
||||
"Ja, wir zeigen Ihnen gerne, wie Sie Ihren Computer, Smartphone oder andere Geräte besser nutzen können.",
|
||||
"Unsere Schulungen sind individuell auf Ihre Bedürfnisse zugeschnitten – vom Einsteiger bis zum Fortgeschrittenen."
|
||||
],
|
||||
"listItems": [
|
||||
"PC-Grundlagen",
|
||||
"Smartphone-Schulung",
|
||||
"Sicherheit im Internet",
|
||||
"Individuelle Themen"
|
||||
],
|
||||
"sortOrder": 26,
|
||||
"isPublished": true,
|
||||
"category": "Schulung",
|
||||
"createdAt": "2026-01-21T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-21T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "abcdef12-3456-7890-abcd-ef1234567891",
|
||||
"slug": "was-unterscheidet-sie",
|
||||
"question": "Was unterscheidet Sie von großen IT-Dienstleistern?",
|
||||
"answers": [
|
||||
"Wir setzen auf persönlichen Service und kennen unsere Kunden. Bei uns sind Sie keine Nummer.",
|
||||
"Als lokales Unternehmen aus der Region verstehen wir die Bedürfnisse vor Ort und sind schnell erreichbar."
|
||||
],
|
||||
"listItems": [
|
||||
"Persönlicher Ansprechpartner",
|
||||
"Kurze Wege",
|
||||
"Faire Preise",
|
||||
"Keine Hotline-Warteschleifen"
|
||||
],
|
||||
"sortOrder": 27,
|
||||
"isPublished": true,
|
||||
"category": "Allgemein",
|
||||
"createdAt": "2026-01-21T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-21T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "bcdef123-4567-890a-bcde-f12345678902",
|
||||
"slug": "apps-entwickeln",
|
||||
"question": "Entwickeln Sie auch Apps?",
|
||||
"answers": [
|
||||
"Ja, wir entwickeln mobile Apps für Android und iOS sowie Web-Applikationen.",
|
||||
"Von der Idee bis zur fertigen App begleiten wir Sie durch den gesamten Entwicklungsprozess."
|
||||
],
|
||||
"listItems": [
|
||||
"Android-Apps",
|
||||
"iOS-Apps",
|
||||
"Cross-Platform mit Flutter",
|
||||
"Web-Applikationen"
|
||||
],
|
||||
"sortOrder": 28,
|
||||
"isPublished": true,
|
||||
"category": "Entwicklung",
|
||||
"createdAt": "2026-01-21T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-21T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "cdef1234-5678-90ab-cdef-123456789013",
|
||||
"slug": "server-einrichten",
|
||||
"question": "Können Sie mir einen Server einrichten?",
|
||||
"answers": [
|
||||
"Ja, wir richten Server für verschiedene Zwecke ein – vom NAS zu Hause bis zum Firmenserver.",
|
||||
"Auch Cloud-Lösungen und Hosting-Konfigurationen gehören zu unserem Angebot."
|
||||
],
|
||||
"listItems": [
|
||||
"NAS-Einrichtung",
|
||||
"Windows Server",
|
||||
"Linux Server",
|
||||
"Cloud-Hosting"
|
||||
],
|
||||
"sortOrder": 29,
|
||||
"isPublished": true,
|
||||
"category": "Server",
|
||||
"createdAt": "2026-01-21T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-21T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "def12345-6789-0abc-def1-234567890124",
|
||||
"slug": "alte-geraete-entsorgen",
|
||||
"question": "Können Sie alte Geräte entsorgen?",
|
||||
"answers": [
|
||||
"Wir können Ihnen bei der fachgerechten Entsorgung alter IT-Geräte behilflich sein.",
|
||||
"Auf Wunsch löschen wir vorher sicher alle Ihre Daten, sodass nichts in falsche Hände gerät."
|
||||
],
|
||||
"listItems": [
|
||||
"Sichere Datenlöschung",
|
||||
"Fachgerechte Entsorgung",
|
||||
"Recycling-Hinweise"
|
||||
],
|
||||
"sortOrder": 30,
|
||||
"isPublished": true,
|
||||
"category": "Service",
|
||||
"createdAt": "2026-01-21T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-21T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "ef123456-7890-abcd-ef12-345678901235",
|
||||
"slug": "gaming-pc-bauen",
|
||||
"question": "Bauen Sie auch Gaming-PCs?",
|
||||
"answers": [
|
||||
"Ja, wir stellen individuelle Gaming-PCs nach Ihren Wünschen und Budget zusammen.",
|
||||
"Von der Komponentenauswahl bis zum fertigen System mit installiertem Betriebssystem."
|
||||
],
|
||||
"listItems": [
|
||||
"Individuelle Konfiguration",
|
||||
"Komponenten-Beratung",
|
||||
"Zusammenbau und Test",
|
||||
"Windows-Installation"
|
||||
],
|
||||
"sortOrder": 31,
|
||||
"isPublished": true,
|
||||
"category": "Hardware",
|
||||
"createdAt": "2026-01-21T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-21T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "f1234567-890a-bcde-f123-456789012346",
|
||||
"slug": "kabelmanagement",
|
||||
"question": "Können Sie mein Kabelchaos ordnen?",
|
||||
"answers": [
|
||||
"Ja, wir bringen Ordnung in Ihr Kabelchaos – zu Hause oder im Büro.",
|
||||
"Mit durchdachtem Kabelmanagement sieht Ihr Arbeitsplatz nicht nur besser aus, sondern ist auch sicherer."
|
||||
],
|
||||
"listItems": [
|
||||
"Kabelkanäle",
|
||||
"Beschriftung",
|
||||
"Ordentliche Verlegung",
|
||||
"Schreibtisch-Organisation"
|
||||
],
|
||||
"sortOrder": 32,
|
||||
"isPublished": true,
|
||||
"category": "Service",
|
||||
"createdAt": "2026-01-21T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-21T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "12345678-90ab-cdef-1234-567890abcde0",
|
||||
"slug": "zoom-teams-einrichten",
|
||||
"question": "Können Sie mir Zoom oder Teams einrichten?",
|
||||
"answers": [
|
||||
"Ja, wir richten Videokonferenz-Tools wie Zoom, Microsoft Teams oder Google Meet für Sie ein.",
|
||||
"Dazu gehören Kamera- und Mikrofon-Tests sowie die Einrichtung auf allen Ihren Geräten."
|
||||
],
|
||||
"listItems": [
|
||||
"Zoom-Installation",
|
||||
"Microsoft Teams",
|
||||
"Google Meet",
|
||||
"Hardware-Test"
|
||||
],
|
||||
"sortOrder": 33,
|
||||
"isPublished": true,
|
||||
"category": "Software",
|
||||
"createdAt": "2026-01-21T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-21T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "23456789-0abc-def1-2345-67890abcde01",
|
||||
"slug": "homeoffice-einrichten",
|
||||
"question": "Können Sie mir beim Homeoffice-Setup helfen?",
|
||||
"answers": [
|
||||
"Ja, wir richten Ihr komplettes Homeoffice ein – von der Hardware bis zur Software.",
|
||||
"VPN-Verbindung zum Arbeitgeber, Drucker, zweiter Monitor – wir kümmern uns um alles."
|
||||
],
|
||||
"listItems": [
|
||||
"VPN-Einrichtung",
|
||||
"Multi-Monitor-Setup",
|
||||
"Drucker und Scanner",
|
||||
"Ergonomie-Beratung"
|
||||
],
|
||||
"sortOrder": 34,
|
||||
"isPublished": true,
|
||||
"category": "Homeoffice",
|
||||
"createdAt": "2026-01-21T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-21T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "34567890-abcd-ef12-3456-7890abcde012",
|
||||
"slug": "passwort-vergessen",
|
||||
"question": "Ich habe mein Windows-Passwort vergessen – können Sie helfen?",
|
||||
"answers": [
|
||||
"In den meisten Fällen ja. Wir können Windows-Passwörter zurücksetzen, ohne dass Daten verloren gehen.",
|
||||
"Bitte bringen Sie einen Eigentumsnachweis mit, damit wir sicherstellen können, dass es Ihr Gerät ist."
|
||||
],
|
||||
"listItems": [
|
||||
"Passwort-Zurücksetzung",
|
||||
"Daten bleiben erhalten",
|
||||
"Eigentumsnachweis erforderlich"
|
||||
],
|
||||
"sortOrder": 35,
|
||||
"isPublished": true,
|
||||
"category": "Betriebssysteme",
|
||||
"createdAt": "2026-01-21T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-21T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "4567890a-bcde-f123-4567-890abcde0123",
|
||||
"slug": "domain-hosting",
|
||||
"question": "Können Sie mir bei Domain und Hosting helfen?",
|
||||
"answers": [
|
||||
"Ja, wir beraten Sie bei der Domain-Auswahl und richten Ihr Hosting-Paket ein.",
|
||||
"Auf Wunsch übernehmen wir auch die laufende Verwaltung Ihrer Domain und Webseite."
|
||||
],
|
||||
"listItems": [
|
||||
"Domain-Registrierung",
|
||||
"Hosting-Einrichtung",
|
||||
"E-Mail-Hosting",
|
||||
"SSL-Zertifikate"
|
||||
],
|
||||
"sortOrder": 36,
|
||||
"isPublished": true,
|
||||
"category": "Webentwicklung",
|
||||
"createdAt": "2026-01-21T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-21T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "567890ab-cdef-1234-5678-90abcde01234",
|
||||
"slug": "senioren-support",
|
||||
"question": "Bieten Sie speziellen Support für Senioren an?",
|
||||
"answers": [
|
||||
"Ja, wir haben viel Erfahrung mit älteren Kunden und nehmen uns besonders viel Zeit für Erklärungen.",
|
||||
"Geduld und verständliche Sprache ohne Fachchinesisch sind bei uns selbstverständlich."
|
||||
],
|
||||
"listItems": [
|
||||
"Geduldige Erklärungen",
|
||||
"Keine Fachbegriffe",
|
||||
"Schritt-für-Schritt-Anleitungen",
|
||||
"Wiederholungen kein Problem"
|
||||
],
|
||||
"sortOrder": 37,
|
||||
"isPublished": true,
|
||||
"category": "Service",
|
||||
"createdAt": "2026-01-21T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-21T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "67890abc-def1-2345-6789-0abcde012345",
|
||||
"slug": "vpn-einrichten",
|
||||
"question": "Können Sie mir ein VPN einrichten?",
|
||||
"answers": [
|
||||
"Ja, wir richten VPN-Verbindungen für mehr Sicherheit und Privatsphäre ein.",
|
||||
"Ob für den Zugriff auf Ihr Heimnetzwerk von unterwegs oder für sicheres Surfen – wir helfen gerne."
|
||||
],
|
||||
"listItems": [
|
||||
"VPN für Privatsphäre",
|
||||
"Zugriff auf Heimnetzwerk",
|
||||
"Firmen-VPN einrichten",
|
||||
"VPN auf Router"
|
||||
],
|
||||
"sortOrder": 38,
|
||||
"isPublished": true,
|
||||
"category": "Sicherheit",
|
||||
"createdAt": "2026-01-21T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-21T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "7890abcd-ef12-3456-7890-abcde0123456",
|
||||
"slug": "raspberry-pi-projekte",
|
||||
"question": "Helfen Sie auch bei Raspberry Pi Projekten?",
|
||||
"answers": [
|
||||
"Ja, wir haben Erfahrung mit Raspberry Pi und unterstützen Sie bei verschiedenen Projekten.",
|
||||
"Von der Einrichtung eines Mediencenters bis zum Smart-Home-Server – wir setzen Ihre Ideen um."
|
||||
],
|
||||
"listItems": [
|
||||
"Raspberry Pi Einrichtung",
|
||||
"Home-Server",
|
||||
"Mediacenter",
|
||||
"Individuelle Projekte"
|
||||
],
|
||||
"sortOrder": 39,
|
||||
"isPublished": true,
|
||||
"category": "Projekte",
|
||||
"createdAt": "2026-01-21T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-21T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "890abcde-f123-4567-890a-bcde01234567",
|
||||
"slug": "office-installation",
|
||||
"question": "Können Sie Microsoft Office installieren?",
|
||||
"answers": [
|
||||
"Ja, wir installieren und konfigurieren Microsoft Office oder kostenlose Alternativen wie LibreOffice.",
|
||||
"Auch bei der Einrichtung von Microsoft 365 und der Verknüpfung mit Ihren Geräten helfen wir."
|
||||
],
|
||||
"listItems": [
|
||||
"Microsoft Office",
|
||||
"Microsoft 365",
|
||||
"LibreOffice",
|
||||
"E-Mail-Einrichtung in Outlook"
|
||||
],
|
||||
"sortOrder": 40,
|
||||
"isPublished": true,
|
||||
"category": "Software",
|
||||
"createdAt": "2026-01-21T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-21T10:00:00.000Z"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,549 @@
|
||||
{
|
||||
"categories": [
|
||||
{
|
||||
"slug": "hardware",
|
||||
"name": "Hardware",
|
||||
"subtitle": "Computer, Laptops & Geräte",
|
||||
"materialIcon": "memory",
|
||||
"sortOrder": 0,
|
||||
"services": [
|
||||
{
|
||||
"slug": "pc-reparatur",
|
||||
"icon": "🔧",
|
||||
"title": "PC & Laptop Reparatur",
|
||||
"description": "Startet nicht, stürzt ab, wird heiß – wir finden das Problem.",
|
||||
"longDescription": "Dein Computer macht Probleme? Egal ob er nicht mehr startet, ständig abstürzt, überhitzt oder merkwürdige Geräusche macht – wir diagnostizieren das Problem und beheben es. Wir reparieren sowohl Desktop-PCs als auch Laptops aller Marken.",
|
||||
"tags": ["Fehlerdiagnose", "Komponentenaustausch"],
|
||||
"keywords": "pc reparatur computer laptop notebook reparieren kaputt defekt",
|
||||
"sortOrder": 0
|
||||
},
|
||||
{
|
||||
"slug": "hardware-upgrade",
|
||||
"icon": "⚡",
|
||||
"title": "Hardware-Aufrüstung",
|
||||
"description": "Mehr RAM, größere SSD, neue Grafikkarte – schneller machen.",
|
||||
"longDescription": "Dein Rechner ist zu langsam? Oft hilft ein gezieltes Upgrade: Mehr Arbeitsspeicher für flüssiges Multitasking, eine SSD für schnelleres Booten und Laden, oder eine bessere Grafikkarte für Gaming und Videobearbeitung. Wir beraten dich, was sich lohnt.",
|
||||
"tags": ["RAM", "SSD", "Grafikkarte"],
|
||||
"keywords": "aufrüstung upgrade ram ssd festplatte arbeitsspeicher schneller",
|
||||
"sortOrder": 1
|
||||
},
|
||||
{
|
||||
"slug": "datenrettung",
|
||||
"icon": "💾",
|
||||
"title": "Datenrettung",
|
||||
"description": "Festplatte kaputt? Daten gelöscht? Wir retten was geht.",
|
||||
"longDescription": "Wichtige Dateien verschwunden oder Festplatte defekt? Wir versuchen deine Daten zu retten – von HDDs, SSDs, USB-Sticks und SD-Karten. Je früher du dich meldest, desto besser die Chancen. Keine Rettung, keine Kosten.",
|
||||
"tags": ["HDD", "SSD", "USB-Stick"],
|
||||
"keywords": "datenrettung daten retten festplatte kaputt backup wiederherstellen recovery",
|
||||
"sortOrder": 2
|
||||
},
|
||||
{
|
||||
"slug": "pc-zusammenbau",
|
||||
"icon": "🖥️",
|
||||
"title": "PC-Zusammenbau",
|
||||
"description": "Wir bauen deinen Wunsch-PC zusammen.",
|
||||
"longDescription": "Du willst einen maßgeschneiderten PC? Wir stellen die Komponenten zusammen, bauen alles sauber auf, installieren das Betriebssystem und testen alles durch. Ob Gaming-Monster, leise Workstation oder kompakter Office-PC.",
|
||||
"tags": ["Gaming-PC", "Workstation", "Office"],
|
||||
"keywords": "zusammenbau pc bauen computer custom gaming workstation zusammenstellen",
|
||||
"sortOrder": 3
|
||||
},
|
||||
{
|
||||
"slug": "kaufberatung",
|
||||
"icon": "🛒",
|
||||
"title": "Kaufberatung",
|
||||
"description": "Welcher PC oder Laptop passt zu dir? Ehrliche Beratung.",
|
||||
"longDescription": "Überfordert von der Auswahl? Wir beraten dich herstellerunabhängig und ehrlich. Kein Upselling, keine Provision – nur die Empfehlung, die zu deinem Budget und deinen Anforderungen passt.",
|
||||
"tags": ["Laptop", "PC", "Preis-Leistung"],
|
||||
"keywords": "kaufberatung hardware beratung welcher pc laptop kaufen",
|
||||
"sortOrder": 4
|
||||
},
|
||||
{
|
||||
"slug": "geraete-einrichtung",
|
||||
"icon": "📦",
|
||||
"title": "Geräte-Einrichtung",
|
||||
"description": "Neues Gerät? Wir richten alles ein.",
|
||||
"longDescription": "Neuen PC oder Laptop gekauft? Wir kümmern uns um alles: Windows einrichten, Programme installieren, Drucker verbinden, Daten vom alten Gerät übertragen. Du bekommst ein fertiges, einsatzbereites System.",
|
||||
"tags": ["Windows", "Datenumzug", "Software"],
|
||||
"keywords": "einrichtung setup neuer pc laptop einrichten installieren konfigurieren",
|
||||
"sortOrder": 5
|
||||
},
|
||||
{
|
||||
"slug": "reinigung-wartung",
|
||||
"icon": "🧹",
|
||||
"title": "Reinigung & Wartung",
|
||||
"description": "Staub raus, neue Wärmeleitpaste – leise und kühl.",
|
||||
"longDescription": "Nach ein paar Jahren sammelt sich Staub an, die Wärmeleitpaste trocknet aus – der PC wird laut und heiß. Wir reinigen alles gründlich, tragen neue Wärmeleitpaste auf und tauschen bei Bedarf Lüfter aus.",
|
||||
"tags": ["Entstaubung", "Wärmeleitpaste"],
|
||||
"keywords": "reinigung pc reinigen staub lüfter wärmeleitpaste thermal wartung",
|
||||
"sortOrder": 6
|
||||
},
|
||||
{
|
||||
"slug": "display-reparatur",
|
||||
"icon": "🖼️",
|
||||
"title": "Display-Reparatur",
|
||||
"description": "Laptop-Display kaputt? Wir tauschen es aus.",
|
||||
"longDescription": "Display gesprungen, Pixelfehler oder Bildausfall? Wir tauschen Laptop-Displays aus und reparieren auch Scharniere und Displaykabel. Die meisten Reparaturen sind innerhalb weniger Tage erledigt.",
|
||||
"tags": ["Displaytausch", "Scharnier"],
|
||||
"keywords": "display bildschirm monitor reparatur kaputt austausch",
|
||||
"sortOrder": 7
|
||||
},
|
||||
{
|
||||
"slug": "drucker-scanner",
|
||||
"icon": "🖨️",
|
||||
"title": "Drucker & Scanner",
|
||||
"description": "Drucker einrichten, WLAN-Druck konfigurieren.",
|
||||
"longDescription": "Drucker will nicht? Wir richten deinen Drucker ein – lokal oder im Netzwerk, per Kabel oder WLAN. Auch Scanner und Multifunktionsgeräte. Inklusive Treiber-Installation und Testdruck.",
|
||||
"tags": ["WLAN-Drucker", "Scanner"],
|
||||
"keywords": "drucker einrichtung scanner multifunktion wlan drucker installieren",
|
||||
"sortOrder": 8
|
||||
},
|
||||
{
|
||||
"slug": "smartphone-tablet",
|
||||
"icon": "📱",
|
||||
"title": "Smartphone & Tablet",
|
||||
"description": "Display-Tausch, Akku-Wechsel, Datenübertragung.",
|
||||
"longDescription": "Handy-Display kaputt oder Akku schwach? Wir reparieren Smartphones und Tablets. Außerdem helfen wir beim Umzug auf ein neues Gerät – alle Daten, Kontakte und Apps sicher übertragen.",
|
||||
"tags": ["Display", "Akku", "Daten"],
|
||||
"keywords": "smartphone handy tablet reparatur display akku tauschen",
|
||||
"sortOrder": 9
|
||||
},
|
||||
{
|
||||
"slug": "nas-speicher",
|
||||
"icon": "💿",
|
||||
"title": "NAS & Speicher",
|
||||
"description": "NAS einrichten, RAID konfigurieren, Backup-Strategien.",
|
||||
"longDescription": "Eigene Cloud zuhause? Wir richten NAS-Systeme von Synology, QNAP und anderen ein. RAID-Konfiguration, Benutzer anlegen, Backup einrichten, Fernzugriff – alles aus einer Hand.",
|
||||
"tags": ["Synology", "QNAP", "RAID"],
|
||||
"keywords": "nas netzwerkspeicher speicher server storage synology qnap",
|
||||
"sortOrder": 10
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "software",
|
||||
"name": "Software",
|
||||
"subtitle": "Programme, Apps & Automatisierung",
|
||||
"materialIcon": "code",
|
||||
"sortOrder": 1,
|
||||
"services": [
|
||||
{
|
||||
"slug": "desktop-anwendungen",
|
||||
"icon": "💻",
|
||||
"title": "Desktop-Anwendungen",
|
||||
"description": "Individuelle Windows-Programme nach deinen Wünschen.",
|
||||
"longDescription": "Du brauchst ein Programm, das genau das macht, was du willst? Wir entwickeln individuelle Desktop-Anwendungen für Windows – von kleinen Tools bis zu komplexen Business-Anwendungen.",
|
||||
"tags": ["Windows", "Cross-Platform"],
|
||||
"keywords": "desktop anwendung programm windows software entwickeln programmieren",
|
||||
"sortOrder": 0
|
||||
},
|
||||
{
|
||||
"slug": "mobile-apps",
|
||||
"icon": "📲",
|
||||
"title": "Mobile Apps",
|
||||
"description": "Apps für iOS und Android mit Flutter.",
|
||||
"longDescription": "Eine App für dein Business? Mit Flutter entwickeln wir Apps, die auf iPhone und Android laufen – mit einer Codebasis. Schneller, günstiger und einfacher zu warten als native Entwicklung.",
|
||||
"tags": ["Flutter", "iOS", "Android"],
|
||||
"keywords": "app mobile ios android smartphone tablet flutter",
|
||||
"sortOrder": 1
|
||||
},
|
||||
{
|
||||
"slug": "automatisierungen",
|
||||
"icon": "🤖",
|
||||
"title": "Automatisierungen",
|
||||
"description": "Wiederkehrende Aufgaben automatisch erledigen lassen.",
|
||||
"longDescription": "Jeden Tag die gleichen Klicks? Wir automatisieren wiederkehrende Aufgaben mit Scripts, Bots und Workflows. Daten übertragen, Reports erstellen, E-Mails versenden – alles auf Autopilot.",
|
||||
"tags": ["Scripts", "Bots", "Workflows"],
|
||||
"keywords": "automatisierung script skript automatisch automatisieren",
|
||||
"sortOrder": 2
|
||||
},
|
||||
{
|
||||
"slug": "api-entwicklung",
|
||||
"icon": "🔌",
|
||||
"title": "API-Entwicklung",
|
||||
"description": "Systeme verbinden, Daten austauschen.",
|
||||
"longDescription": "Deine Systeme sollen miteinander reden? Wir entwickeln REST-APIs und Schnittstellen, die deine Anwendungen verbinden. Sauber dokumentiert und sicher.",
|
||||
"tags": ["REST-API", "NestJS", "Node.js"],
|
||||
"keywords": "api schnittstelle integration rest backend",
|
||||
"sortOrder": 3
|
||||
},
|
||||
{
|
||||
"slug": "datenbanken",
|
||||
"icon": "🗄️",
|
||||
"title": "Datenbanken",
|
||||
"description": "Datenbanken designen, optimieren, verwalten.",
|
||||
"longDescription": "Daten sind das Fundament. Wir designen Datenbankstrukturen, optimieren langsame Queries und migrieren bestehende Datenbanken. PostgreSQL, MySQL, MongoDB – wir kennen sie alle.",
|
||||
"tags": ["PostgreSQL", "MySQL", "MongoDB"],
|
||||
"keywords": "datenbank sql database mysql postgresql mongodb",
|
||||
"sortOrder": 4
|
||||
},
|
||||
{
|
||||
"slug": "excel-vba",
|
||||
"icon": "📊",
|
||||
"title": "Excel & VBA",
|
||||
"description": "Makros, VBA-Scripts, komplexe Formeln.",
|
||||
"longDescription": "Excel kann mehr als du denkst. Wir erstellen Makros und VBA-Scripts, die dir stundenlange Arbeit ersparen. Komplexe Formeln, automatische Reports, Datenvalidierung.",
|
||||
"tags": ["Makros", "VBA", "Formeln"],
|
||||
"keywords": "excel makro vba access tabelle automatisieren",
|
||||
"sortOrder": 5
|
||||
},
|
||||
{
|
||||
"slug": "business-software",
|
||||
"icon": "🏢",
|
||||
"title": "Business-Software",
|
||||
"description": "Lager, Kunden, Aufträge – individuelle Lösungen.",
|
||||
"longDescription": "Standardsoftware passt nicht? Wir entwickeln individuelle Lösungen für Lagerverwaltung, Kundenverwaltung, Auftragsabwicklung – genau auf deine Prozesse zugeschnitten.",
|
||||
"tags": ["Warenwirtschaft", "CRM"],
|
||||
"keywords": "erp crm system business software warenwirtschaft",
|
||||
"sortOrder": 6
|
||||
},
|
||||
{
|
||||
"slug": "daten-import-export",
|
||||
"icon": "🔄",
|
||||
"title": "Daten-Import & Export",
|
||||
"description": "Daten zwischen Systemen austauschen und migrieren.",
|
||||
"longDescription": "Daten müssen von A nach B? Wir schreiben Import/Export-Routinen, konvertieren Formate und migrieren Datenbestände. CSV, XML, JSON, Excel – kein Problem.",
|
||||
"tags": ["CSV", "XML", "JSON"],
|
||||
"keywords": "schnittstelle import export datenaustausch csv xml json",
|
||||
"sortOrder": 7
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "web",
|
||||
"name": "Web",
|
||||
"subtitle": "Websites & Web-Apps",
|
||||
"materialIcon": "language",
|
||||
"sortOrder": 2,
|
||||
"services": [
|
||||
{
|
||||
"slug": "firmenwebsites",
|
||||
"icon": "🌐",
|
||||
"title": "Firmenwebsites",
|
||||
"description": "Professionelle Websites für Unternehmen.",
|
||||
"longDescription": "Dein digitales Aushängeschild. Wir bauen moderne, schnelle Websites, die auf allen Geräten gut aussehen. Für Unternehmen, Handwerker, Freiberufler – individuell gestaltet, nicht von der Stange.",
|
||||
"tags": ["Responsive", "Modern", "Schnell"],
|
||||
"keywords": "website webseite homepage firmenwebsite internetseite erstellen",
|
||||
"sortOrder": 0
|
||||
},
|
||||
{
|
||||
"slug": "landingpages",
|
||||
"icon": "🎯",
|
||||
"title": "Landingpages",
|
||||
"description": "Conversion-optimierte Seiten für Kampagnen.",
|
||||
"longDescription": "Eine Seite, ein Ziel. Wir bauen Landingpages, die konvertieren – für Produktlaunches, Kampagnen oder Lead-Generierung. Klare Struktur, überzeugender Text, schnelle Ladezeit.",
|
||||
"tags": ["Conversion", "Marketing"],
|
||||
"keywords": "landingpage landing page marketing conversion",
|
||||
"sortOrder": 1
|
||||
},
|
||||
{
|
||||
"slug": "web-applikationen",
|
||||
"icon": "⚡",
|
||||
"title": "Web-Applikationen",
|
||||
"description": "Komplexe Browser-Anwendungen mit Angular.",
|
||||
"longDescription": "Mehr als eine Website – eine vollwertige Anwendung im Browser. Dashboards, interne Tools, Kundenportale. Mit Angular, TypeScript und modernen Technologien.",
|
||||
"tags": ["Angular", "TypeScript", "SPA"],
|
||||
"keywords": "web app webanwendung angular react frontend browser",
|
||||
"sortOrder": 2
|
||||
},
|
||||
{
|
||||
"slug": "online-shops",
|
||||
"icon": "🛍️",
|
||||
"title": "Online-Shops",
|
||||
"description": "E-Commerce Lösungen von einfach bis komplex.",
|
||||
"longDescription": "Verkaufen im Internet? Wir bauen Online-Shops – von einfachen Shopify-Lösungen bis zu individuellen E-Commerce-Plattformen. Payment, Versand, Bestandsverwaltung inklusive.",
|
||||
"tags": ["Shopify", "WooCommerce"],
|
||||
"keywords": "online shop e-commerce webshop shopify woocommerce",
|
||||
"sortOrder": 3
|
||||
},
|
||||
{
|
||||
"slug": "buchungssysteme",
|
||||
"icon": "📅",
|
||||
"title": "Buchungssysteme",
|
||||
"description": "Online-Terminbuchung und Reservierungen.",
|
||||
"longDescription": "Kunden sollen online buchen können? Wir entwickeln Buchungssysteme für Termine, Kurse, Ressourcen. Mit Kalenderansicht, automatischen Bestätigungen und Erinnerungen.",
|
||||
"tags": ["Terminbuchung", "Kalender"],
|
||||
"keywords": "buchungssystem terminbuchung kalender online buchen reservierung",
|
||||
"sortOrder": 4
|
||||
},
|
||||
{
|
||||
"slug": "wordpress-cms",
|
||||
"icon": "📝",
|
||||
"title": "WordPress & CMS",
|
||||
"description": "WordPress-Seiten, Themes, Plugins.",
|
||||
"longDescription": "WordPress ist der Klassiker – und wir kennen ihn in- und auswendig. Neue Seiten aufsetzen, Themes anpassen, Plugins entwickeln, bestehende Seiten reparieren.",
|
||||
"tags": ["WordPress", "Themes", "Blog"],
|
||||
"keywords": "wordpress cms content management blog",
|
||||
"sortOrder": 5
|
||||
},
|
||||
{
|
||||
"slug": "seo-optimierung",
|
||||
"icon": "📈",
|
||||
"title": "SEO-Optimierung",
|
||||
"description": "Google-Rankings verbessern.",
|
||||
"longDescription": "Gefunden werden bei Google. Wir optimieren deine Website technisch: Ladezeit, Struktur, Meta-Tags, Schema Markup. Damit du bei relevanten Suchanfragen oben stehst.",
|
||||
"tags": ["On-Page SEO", "Core Web Vitals"],
|
||||
"keywords": "seo suchmaschine google optimierung ranking",
|
||||
"sortOrder": 6
|
||||
},
|
||||
{
|
||||
"slug": "hosting-domains",
|
||||
"icon": "☁️",
|
||||
"title": "Hosting & Domains",
|
||||
"description": "Domain, Hosting, SSL, DNS – alles einrichten.",
|
||||
"longDescription": "Das technische Fundament. Wir registrieren Domains, richten Hosting ein, konfigurieren DNS und SSL-Zertifikate. Damit deine Website sicher und erreichbar ist.",
|
||||
"tags": ["Domain", "SSL", "DNS"],
|
||||
"keywords": "hosting domain webspace server ssl",
|
||||
"sortOrder": 7
|
||||
},
|
||||
{
|
||||
"slug": "website-wartung",
|
||||
"icon": "🛠️",
|
||||
"title": "Website-Wartung",
|
||||
"description": "Updates, Backups, Security-Patches.",
|
||||
"longDescription": "Eine Website braucht Pflege. Wir kümmern uns um Updates, Backups, Sicherheits-Patches und kleine Änderungen. Damit du dich um dein Business kümmern kannst.",
|
||||
"tags": ["Updates", "Backups", "Security"],
|
||||
"keywords": "website wartung pflege update aktualisierung",
|
||||
"sortOrder": 8
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "netzwerk",
|
||||
"name": "Netzwerk",
|
||||
"subtitle": "WLAN, Server & Cloud",
|
||||
"materialIcon": "lan",
|
||||
"sortOrder": 3,
|
||||
"services": [
|
||||
{
|
||||
"slug": "wlan-einrichtung",
|
||||
"icon": "📶",
|
||||
"title": "WLAN-Einrichtung",
|
||||
"description": "WLAN optimieren, Reichweite verbessern, Mesh.",
|
||||
"longDescription": "WLAN zu langsam oder Funklöcher? Wir analysieren dein Netzwerk und optimieren es. Access Points platzieren, Mesh-Systeme einrichten, Kanäle optimieren.",
|
||||
"tags": ["Mesh", "Access Points", "5GHz"],
|
||||
"keywords": "wlan wifi wireless funk netzwerk einrichten langsam reichweite",
|
||||
"sortOrder": 0
|
||||
},
|
||||
{
|
||||
"slug": "netzwerk-verkabelung",
|
||||
"icon": "🔗",
|
||||
"title": "Netzwerk-Verkabelung",
|
||||
"description": "LAN-Verkabelung planen und umsetzen.",
|
||||
"longDescription": "Kabel ist King. Wir planen und verlegen Netzwerkkabel, richten Switches ein und dokumentieren alles sauber. Cat6, Cat7 – für stabiles, schnelles Internet.",
|
||||
"tags": ["Cat6/Cat7", "Switches"],
|
||||
"keywords": "netzwerk lan kabel ethernet switch router verkabelung",
|
||||
"sortOrder": 1
|
||||
},
|
||||
{
|
||||
"slug": "router-firewall",
|
||||
"icon": "🌐",
|
||||
"title": "Router & Firewall",
|
||||
"description": "Router, Firewall-Regeln, Port-Forwarding.",
|
||||
"longDescription": "Das Tor zum Internet. Wir konfigurieren Router und Firewalls, richten Port-Forwarding ein und sorgen für Sicherheit. FritzBox, pfSense, Ubiquiti – wir kennen sie alle.",
|
||||
"tags": ["FritzBox", "pfSense", "VPN"],
|
||||
"keywords": "router firewall fritz fritzbox konfiguration internet",
|
||||
"sortOrder": 2
|
||||
},
|
||||
{
|
||||
"slug": "vpn-loesungen",
|
||||
"icon": "🔒",
|
||||
"title": "VPN-Lösungen",
|
||||
"description": "Sichere Fernzugriffe, Homeoffice-Anbindung.",
|
||||
"longDescription": "Sicher von überall arbeiten. Wir richten VPN-Verbindungen ein – für Homeoffice, Außendienst oder Standortvernetzung. WireGuard, OpenVPN, IPSec.",
|
||||
"tags": ["WireGuard", "OpenVPN"],
|
||||
"keywords": "vpn virtual private network fernzugriff remote tunnel",
|
||||
"sortOrder": 3
|
||||
},
|
||||
{
|
||||
"slug": "server-administration",
|
||||
"icon": "🖥️",
|
||||
"title": "Server-Administration",
|
||||
"description": "Windows Server und Linux einrichten und warten.",
|
||||
"longDescription": "Server sind unser Ding. Windows Server oder Linux – wir installieren, konfigurieren und warten. Updates, Monitoring, Troubleshooting.",
|
||||
"tags": ["Windows Server", "Linux", "Debian"],
|
||||
"keywords": "server windows linux debian ubuntu einrichten administrieren",
|
||||
"sortOrder": 4
|
||||
},
|
||||
{
|
||||
"slug": "active-directory",
|
||||
"icon": "👥",
|
||||
"title": "Active Directory",
|
||||
"description": "Domänen, Benutzer, Gruppen, Richtlinien.",
|
||||
"longDescription": "Zentrale Benutzerverwaltung für Unternehmen. Wir richten Active Directory ein, verwalten Benutzer und Gruppen, konfigurieren Gruppenrichtlinien.",
|
||||
"tags": ["AD", "GPO", "Benutzer"],
|
||||
"keywords": "active directory ad domäne benutzer gruppen rechte",
|
||||
"sortOrder": 5
|
||||
},
|
||||
{
|
||||
"slug": "cloud-loesungen",
|
||||
"icon": "☁️",
|
||||
"title": "Cloud-Lösungen",
|
||||
"description": "Microsoft 365, Azure, AWS einrichten.",
|
||||
"longDescription": "Ab in die Cloud. Wir richten Microsoft 365 ein, migrieren Daten zu Azure oder AWS und verwalten Cloud-Infrastruktur. Hybrid oder Full Cloud.",
|
||||
"tags": ["Microsoft 365", "Azure", "AWS"],
|
||||
"keywords": "cloud microsoft 365 office azure aws google cloud",
|
||||
"sortOrder": 6
|
||||
},
|
||||
{
|
||||
"slug": "backup-strategien",
|
||||
"icon": "💾",
|
||||
"title": "Backup-Strategien",
|
||||
"description": "Backup-Konzepte, automatische Sicherungen.",
|
||||
"longDescription": "Daten sind Gold wert. Wir entwickeln Backup-Konzepte nach der 3-2-1 Regel, richten automatische Sicherungen ein und testen die Wiederherstellung.",
|
||||
"tags": ["3-2-1 Regel", "Cloud-Backup"],
|
||||
"keywords": "backup datensicherung sicherung restore wiederherstellung",
|
||||
"sortOrder": 7
|
||||
},
|
||||
{
|
||||
"slug": "virtualisierung",
|
||||
"icon": "📦",
|
||||
"title": "Virtualisierung",
|
||||
"description": "VMs, Hyper-V, Proxmox, Docker.",
|
||||
"longDescription": "Mehr aus der Hardware rausholen. Wir richten Virtualisierung ein – Hyper-V, Proxmox, VMware. Oder Container mit Docker für moderne Anwendungen.",
|
||||
"tags": ["Hyper-V", "Proxmox", "Docker"],
|
||||
"keywords": "virtualisierung vm vmware hyper-v proxmox docker container",
|
||||
"sortOrder": 8
|
||||
},
|
||||
{
|
||||
"slug": "it-sicherheit",
|
||||
"icon": "🛡️",
|
||||
"title": "IT-Sicherheit",
|
||||
"description": "Firewall, Virenschutz, Security-Audits.",
|
||||
"longDescription": "Sicherheit ist kein Zustand, sondern ein Prozess. Wir prüfen deine IT auf Schwachstellen, richten Firewalls und Virenschutz ein, implementieren 2FA.",
|
||||
"tags": ["Firewall", "Antivirus", "2FA"],
|
||||
"keywords": "sicherheit security firewall antivirus virenschutz malware",
|
||||
"sortOrder": 9
|
||||
},
|
||||
{
|
||||
"slug": "email-systeme",
|
||||
"icon": "📧",
|
||||
"title": "E-Mail-Systeme",
|
||||
"description": "Exchange, IMAP/SMTP einrichten.",
|
||||
"longDescription": "E-Mail ist Kommunikation Nr. 1. Wir richten E-Mail-Server ein, migrieren Postfächer, konfigurieren Spam-Filter und sorgen für zuverlässige Zustellung.",
|
||||
"tags": ["Exchange", "IMAP", "Spam-Filter"],
|
||||
"keywords": "e-mail mail exchange postfach mailserver imap smtp",
|
||||
"sortOrder": 10
|
||||
},
|
||||
{
|
||||
"slug": "monitoring",
|
||||
"icon": "📊",
|
||||
"title": "Monitoring",
|
||||
"description": "Server und Netzwerk überwachen, Alerts.",
|
||||
"longDescription": "Probleme erkennen, bevor sie eskalieren. Wir richten Monitoring ein – für Server, Netzwerk, Dienste. Mit Dashboards und Alerts bei Problemen.",
|
||||
"tags": ["Uptime", "Alerts", "Grafana"],
|
||||
"keywords": "monitoring überwachung netzwerk server nagios zabbix",
|
||||
"sortOrder": 11
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "support",
|
||||
"name": "Support",
|
||||
"subtitle": "Hilfe remote oder vor Ort",
|
||||
"materialIcon": "support_agent",
|
||||
"sortOrder": 4,
|
||||
"services": [
|
||||
{
|
||||
"slug": "remote-support",
|
||||
"icon": "🖱️",
|
||||
"title": "Remote-Support",
|
||||
"description": "Schnelle Hilfe per Fernwartung.",
|
||||
"longDescription": "Problem schildern, wir schalten uns drauf. Per TeamViewer oder AnyDesk helfen wir dir sofort – ohne Anfahrt, ohne Wartezeit. Die schnellste Lösung für die meisten Probleme.",
|
||||
"tags": ["TeamViewer", "AnyDesk"],
|
||||
"keywords": "remote support fernwartung fernzugriff teamviewer hilfe",
|
||||
"sortOrder": 0
|
||||
},
|
||||
{
|
||||
"slug": "vor-ort-service",
|
||||
"icon": "🚗",
|
||||
"title": "Vor-Ort-Service",
|
||||
"description": "Wir kommen zu dir – im Westerwald und Umgebung.",
|
||||
"longDescription": "Manchmal muss man vor Ort sein. Wir kommen zu dir – im Westerwald, Altenkirchen und Umgebung. Für Hardware-Probleme, Netzwerk-Einrichtung oder wenn Remote nicht reicht.",
|
||||
"tags": ["Westerwald", "Altenkirchen"],
|
||||
"keywords": "vor ort vor-ort vorort service techniker kommen westerwald",
|
||||
"sortOrder": 1
|
||||
},
|
||||
{
|
||||
"slug": "wartungsvertraege",
|
||||
"icon": "📋",
|
||||
"title": "Wartungsverträge",
|
||||
"description": "Regelmäßige Wartung, bevorzugter Support.",
|
||||
"longDescription": "Planbare IT-Kosten und bevorzugter Support. Mit einem Wartungsvertrag kümmern wir uns regelmäßig um deine Systeme und du hast einen festen Ansprechpartner.",
|
||||
"tags": ["Regelmäßig", "Priorität"],
|
||||
"keywords": "wartung wartungsvertrag regelmäßig service monatlich",
|
||||
"sortOrder": 2
|
||||
},
|
||||
{
|
||||
"slug": "schulungen",
|
||||
"icon": "🎓",
|
||||
"title": "Schulungen",
|
||||
"description": "Einweisungen, IT-Grundlagen, Workshops.",
|
||||
"longDescription": "Wissen ist Macht. Wir schulen dich und dein Team – in neuer Software, IT-Grundlagen oder speziellen Themen. Einzeln oder als Workshop.",
|
||||
"tags": ["Einweisung", "Workshop"],
|
||||
"keywords": "schulung training einweisung lernen erklären workshop",
|
||||
"sortOrder": 3
|
||||
},
|
||||
{
|
||||
"slug": "software-installation",
|
||||
"icon": "📀",
|
||||
"title": "Software-Installation",
|
||||
"description": "Programme installieren und konfigurieren.",
|
||||
"longDescription": "Neue Software soll aufs System? Wir installieren und konfigurieren Programme, sorgen für die richtigen Einstellungen und weisen dich ein.",
|
||||
"tags": ["Installation", "Konfiguration"],
|
||||
"keywords": "installation software installieren programm einrichten",
|
||||
"sortOrder": 4
|
||||
},
|
||||
{
|
||||
"slug": "updates-patches",
|
||||
"icon": "🔄",
|
||||
"title": "Updates & Patches",
|
||||
"description": "Betriebssystem, Treiber, Security-Patches.",
|
||||
"longDescription": "Aktuell bleiben ist wichtig. Wir bringen deine Systeme auf den neuesten Stand – Windows-Updates, Treiber, Security-Patches. Kontrolliert und ohne böse Überraschungen.",
|
||||
"tags": ["Windows Update", "Treiber"],
|
||||
"keywords": "update aktualisierung windows treiber patch",
|
||||
"sortOrder": 5
|
||||
},
|
||||
{
|
||||
"slug": "virenentfernung",
|
||||
"icon": "🦠",
|
||||
"title": "Virenentfernung",
|
||||
"description": "Malware, Trojaner, Adware – sauber machen.",
|
||||
"longDescription": "System verseucht? Wir entfernen Viren, Trojaner, Adware und andere Schadsoftware. Gründlich und nachhaltig – damit dein System wieder sauber läuft.",
|
||||
"tags": ["Malware", "Trojaner"],
|
||||
"keywords": "virus malware trojaner entfernen reinigen säubern infiziert",
|
||||
"sortOrder": 6
|
||||
},
|
||||
{
|
||||
"slug": "performance-optimierung",
|
||||
"icon": "🚀",
|
||||
"title": "Performance-Optimierung",
|
||||
"description": "PC zu langsam? Wir machen ihn wieder flott.",
|
||||
"longDescription": "Rechner lahm? Wir finden die Ursache: Autostart aufräumen, Bloatware entfernen, Festplatte bereinigen, Dienste optimieren. Danach läuft er wieder.",
|
||||
"tags": ["Autostart", "Bereinigung"],
|
||||
"keywords": "performance langsam optimieren schneller tuning beschleunigen",
|
||||
"sortOrder": 7
|
||||
},
|
||||
{
|
||||
"slug": "it-beratung",
|
||||
"icon": "💡",
|
||||
"title": "IT-Beratung",
|
||||
"description": "Welche Lösung passt? Ehrliche Beratung.",
|
||||
"longDescription": "Nicht sicher, was du brauchst? Wir beraten dich herstellerunabhängig und ehrlich. Keine versteckten Interessen – nur die Lösung, die für dich passt.",
|
||||
"tags": ["Strategie", "Neutral"],
|
||||
"keywords": "beratung consulting it-beratung strategie konzept planen",
|
||||
"sortOrder": 8
|
||||
},
|
||||
{
|
||||
"slug": "notfall-support",
|
||||
"icon": "🚨",
|
||||
"title": "Notfall-Support",
|
||||
"description": "Server down? Hilfe auch außerhalb der Geschäftszeiten.",
|
||||
"longDescription": "IT-Notfall wartet nicht auf Bürozeiten. Bei dringenden Problemen helfen wir auch außerhalb der regulären Zeiten. Server down, Datenverlust, Hackerangriff – wir sind da.",
|
||||
"tags": ["24/7", "Notfall"],
|
||||
"keywords": "notfall notdienst dringend schnell sofort hilfe",
|
||||
"sortOrder": 9
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"name": "leonards-media-api",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emailjs/nodejs": "^5.0.2",
|
||||
"@nestjs-modules/mailer": "^2.0.2",
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
"@nestjs/jwt": "^11.0.0",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^10.4.22",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.2",
|
||||
"googleapis": "^163.0.0",
|
||||
"helmet": "^8.1.0",
|
||||
"nodemailer": "^7.0.9",
|
||||
"openai": "^6.2.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.16.3",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"typeorm": "^0.3.27"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.0.0",
|
||||
"@nestjs/schematics": "^10.0.0",
|
||||
"@nestjs/testing": "^10.0.0",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/jest": "^29.5.2",
|
||||
"@types/node": "^20.3.1",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/supertest": "^6.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||
"@typescript-eslint/parser": "^8.0.0",
|
||||
"eslint": "^8.42.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"jest": "^29.5.0",
|
||||
"prettier": "^3.0.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.1.0",
|
||||
"ts-loader": "^9.4.3",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.1.3"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { Controller, Post, Get, Body, Req, UseGuards, HttpCode, HttpStatus, Delete, Query } from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { AnalyticsService } from './analytics.service';
|
||||
import { CreateAnalyticsEventDto, AnalyticsDashboardDto } from './analytics.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { AdminGuard } from '../auth/guards/admin.guard';
|
||||
|
||||
@Controller('analytics')
|
||||
export class AnalyticsController {
|
||||
constructor(private readonly analyticsService: AnalyticsService) {}
|
||||
|
||||
/**
|
||||
* Empfängt Analytics-Events vom Frontend
|
||||
* Nur mit Consent-Header erlaubt
|
||||
*/
|
||||
@Post('event')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async trackEvent(
|
||||
@Body() dto: CreateAnalyticsEventDto,
|
||||
@Req() req: Request,
|
||||
): Promise<void> {
|
||||
// Consent-Header prüfen
|
||||
const consentHeader = req.headers['x-consent-analytics'];
|
||||
const hasConsent = consentHeader === 'true';
|
||||
|
||||
console.log('[Analytics] Received event:', dto.type, dto.page);
|
||||
console.log('[Analytics] Consent header:', consentHeader, '-> hasConsent:', hasConsent);
|
||||
|
||||
// IP aus verschiedenen Headers extrahieren (Proxy-Support)
|
||||
const ip = this.getClientIP(req);
|
||||
|
||||
await this.analyticsService.trackEvent(dto, ip, hasConsent);
|
||||
console.log('[Analytics] Event saved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard-Daten für Admin
|
||||
*/
|
||||
@Get('dashboard')
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
async getDashboard(): Promise<AnalyticsDashboardDto> {
|
||||
return this.analyticsService.getDashboard();
|
||||
}
|
||||
|
||||
/**
|
||||
* Overview-Statistiken
|
||||
*/
|
||||
@Get('overview')
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
async getOverview() {
|
||||
return this.analyticsService.getOverview();
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeitreihen-Daten
|
||||
*/
|
||||
@Get('timeseries')
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
async getTimeSeries(@Query('days') days?: string) {
|
||||
const numDays = parseInt(days || '30', 10);
|
||||
return this.analyticsService.getTimeSeries(Math.min(numDays, 90));
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-Seiten
|
||||
*/
|
||||
@Get('pages')
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
async getTopPages(@Query('days') days?: string) {
|
||||
const numDays = parseInt(days || '30', 10);
|
||||
return this.analyticsService.getTopPages(Math.min(numDays, 90));
|
||||
}
|
||||
|
||||
/**
|
||||
* Referrer-Statistiken
|
||||
*/
|
||||
@Get('referrers')
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
async getReferrers(@Query('days') days?: string) {
|
||||
const numDays = parseInt(days || '30', 10);
|
||||
return this.analyticsService.getReferrers(Math.min(numDays, 90));
|
||||
}
|
||||
|
||||
/**
|
||||
* Geräte-Statistiken
|
||||
*/
|
||||
@Get('devices')
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
async getDevices(@Query('days') days?: string) {
|
||||
const numDays = parseInt(days || '30', 10);
|
||||
return this.analyticsService.getDeviceStats(Math.min(numDays, 90));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup alter Events (Admin-Only)
|
||||
*/
|
||||
@Delete('cleanup')
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
async cleanup(@Query('days') days?: string): Promise<{ deleted: number }> {
|
||||
const daysToKeep = parseInt(days || '90', 10);
|
||||
const deleted = await this.analyticsService.cleanupOldEvents(daysToKeep);
|
||||
return { deleted };
|
||||
}
|
||||
|
||||
/**
|
||||
* DSGVO: Auskunftsrecht - Gibt alle Daten zu einer Session-ID zurück
|
||||
* Nutzer können ihre Session-ID aus dem SessionStorage auslesen
|
||||
*/
|
||||
@Get('my-data')
|
||||
async getMyData(@Query('sessionId') sessionId: string, @Req() req: Request) {
|
||||
if (!sessionId || sessionId.length < 10) {
|
||||
return { error: 'Ungültige Session-ID', data: [] };
|
||||
}
|
||||
|
||||
const data = await this.analyticsService.getDataBySessionId(sessionId);
|
||||
return {
|
||||
sessionId,
|
||||
recordCount: data.length,
|
||||
data: data.map(event => ({
|
||||
type: event.type,
|
||||
page: event.page,
|
||||
timestamp: event.createdAt,
|
||||
screenSize: event.screenSize,
|
||||
userAgent: event.userAgent,
|
||||
// Keine IP, keine sensiblen Metadaten
|
||||
})),
|
||||
info: 'Diese Daten werden nach 90 Tagen automatisch gelöscht.'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* DSGVO: Löschrecht - Löscht alle Daten zu einer Session-ID
|
||||
*/
|
||||
@Delete('my-data')
|
||||
async deleteMyData(@Query('sessionId') sessionId: string) {
|
||||
if (!sessionId || sessionId.length < 10) {
|
||||
return { error: 'Ungültige Session-ID', deleted: 0 };
|
||||
}
|
||||
|
||||
const deleted = await this.analyticsService.deleteDataBySessionId(sessionId);
|
||||
return {
|
||||
sessionId,
|
||||
deleted,
|
||||
message: deleted > 0
|
||||
? `${deleted} Analytics-Einträge wurden gelöscht.`
|
||||
: 'Keine Daten zu dieser Session-ID gefunden.'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrahiert die echte Client-IP (berücksichtigt Proxys)
|
||||
*/
|
||||
private getClientIP(req: Request): string {
|
||||
const forwarded = req.headers['x-forwarded-for'];
|
||||
if (forwarded) {
|
||||
const ips = Array.isArray(forwarded) ? forwarded[0] : forwarded;
|
||||
return ips.split(',')[0].trim();
|
||||
}
|
||||
|
||||
const realIp = req.headers['x-real-ip'];
|
||||
if (realIp) {
|
||||
return Array.isArray(realIp) ? realIp[0] : realIp;
|
||||
}
|
||||
|
||||
return req.ip || req.socket?.remoteAddress || '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { IsString, IsOptional, IsNumber, IsObject, MaxLength, IsIn } from 'class-validator';
|
||||
|
||||
const EVENT_TYPES = ['pageview', 'click', 'scroll', 'form_submit', 'conversion', 'error', 'custom'] as const;
|
||||
|
||||
export class CreateAnalyticsEventDto {
|
||||
@IsString()
|
||||
@IsIn(EVENT_TYPES)
|
||||
type: typeof EVENT_TYPES[number];
|
||||
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
page: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
referrer?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
userAgent?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
screenSize?: string;
|
||||
|
||||
@IsNumber()
|
||||
timestamp: number;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
sessionId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
// ===== RESPONSE DTOs =====
|
||||
|
||||
export interface AnalyticsOverviewDto {
|
||||
today: {
|
||||
pageviews: number;
|
||||
uniqueSessions: number;
|
||||
conversions: number;
|
||||
};
|
||||
yesterday: {
|
||||
pageviews: number;
|
||||
uniqueSessions: number;
|
||||
conversions: number;
|
||||
};
|
||||
last7Days: {
|
||||
pageviews: number;
|
||||
uniqueSessions: number;
|
||||
conversions: number;
|
||||
};
|
||||
last30Days: {
|
||||
pageviews: number;
|
||||
uniqueSessions: number;
|
||||
conversions: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PageStatsDto {
|
||||
page: string;
|
||||
views: number;
|
||||
uniqueSessions: number;
|
||||
}
|
||||
|
||||
export interface TimeSeriesPointDto {
|
||||
date: string;
|
||||
pageviews: number;
|
||||
uniqueSessions: number;
|
||||
conversions: number;
|
||||
}
|
||||
|
||||
export interface ReferrerStatsDto {
|
||||
referrer: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface DeviceStatsDto {
|
||||
desktop: number;
|
||||
mobile: number;
|
||||
tablet: number;
|
||||
}
|
||||
|
||||
export interface RecentEventDto {
|
||||
type: string;
|
||||
page: string;
|
||||
timestamp: Date;
|
||||
sessionId: string;
|
||||
userAgent?: string;
|
||||
screenSize?: string;
|
||||
referrer?: string;
|
||||
metadata?: Record<string, any>;
|
||||
deviceType?: 'desktop' | 'mobile' | 'tablet';
|
||||
}
|
||||
|
||||
export interface AnalyticsDashboardDto {
|
||||
overview: AnalyticsOverviewDto;
|
||||
timeSeries: TimeSeriesPointDto[];
|
||||
topPages: PageStatsDto[];
|
||||
referrers: ReferrerStatsDto[];
|
||||
devices: DeviceStatsDto;
|
||||
recentEvents: RecentEventDto[];
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index } from 'typeorm';
|
||||
|
||||
export type AnalyticsEventType =
|
||||
| 'pageview'
|
||||
| 'click'
|
||||
| 'scroll'
|
||||
| 'form_submit'
|
||||
| 'conversion'
|
||||
| 'error'
|
||||
| 'custom';
|
||||
|
||||
@Entity('analytics_events')
|
||||
@Index(['createdAt'])
|
||||
@Index(['sessionId'])
|
||||
@Index(['type'])
|
||||
@Index(['page'])
|
||||
export class AnalyticsEvent {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50 })
|
||||
type: AnalyticsEventType;
|
||||
|
||||
@Column({ type: 'varchar', length: 500 })
|
||||
page: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 500, nullable: true })
|
||||
referrer?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 500, nullable: true })
|
||||
userAgent?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||
screenSize?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50 })
|
||||
sessionId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||
ipAnonymized?: string; // Anonymisierte IP (z.B. 192.168.1.0)
|
||||
|
||||
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||
country?: string;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata?: Record<string, any>;
|
||||
|
||||
@Column({ type: 'bigint' })
|
||||
clientTimestamp: number;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// ===== AGGREGATED STATS ENTITY =====
|
||||
@Entity('analytics_daily_stats')
|
||||
@Index(['date'], { unique: true })
|
||||
export class AnalyticsDailyStats {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'date', unique: true })
|
||||
date: string;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
pageviews: number;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
uniqueSessions: number;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
conversions: number;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
topPages?: Record<string, number>;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
referrers?: Record<string, number>;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
devices?: { desktop: number; mobile: number; tablet: number };
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AnalyticsController } from './analytics.controller';
|
||||
import { AnalyticsService } from './analytics.service';
|
||||
import { AnalyticsEvent, AnalyticsDailyStats } from './analytics.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([AnalyticsEvent, AnalyticsDailyStats]),
|
||||
],
|
||||
controllers: [AnalyticsController],
|
||||
providers: [AnalyticsService],
|
||||
exports: [AnalyticsService],
|
||||
})
|
||||
export class AnalyticsModule {}
|
||||
@@ -0,0 +1,424 @@
|
||||
import { Injectable, ForbiddenException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, MoreThanOrEqual, Between, Raw } from 'typeorm';
|
||||
import { AnalyticsEvent, AnalyticsDailyStats } from './analytics.entity';
|
||||
import {
|
||||
CreateAnalyticsEventDto,
|
||||
AnalyticsDashboardDto,
|
||||
AnalyticsOverviewDto,
|
||||
TimeSeriesPointDto,
|
||||
PageStatsDto,
|
||||
ReferrerStatsDto,
|
||||
DeviceStatsDto,
|
||||
RecentEventDto
|
||||
} from './analytics.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AnalyticsService {
|
||||
constructor(
|
||||
@InjectRepository(AnalyticsEvent)
|
||||
private eventRepo: Repository<AnalyticsEvent>,
|
||||
@InjectRepository(AnalyticsDailyStats)
|
||||
private dailyStatsRepo: Repository<AnalyticsDailyStats>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Speichert ein Analytics-Event
|
||||
*/
|
||||
async trackEvent(dto: CreateAnalyticsEventDto, ip: string, hasConsent: boolean): Promise<void> {
|
||||
if (!hasConsent) {
|
||||
throw new ForbiddenException('Analytics consent required');
|
||||
}
|
||||
|
||||
const event = this.eventRepo.create({
|
||||
type: dto.type,
|
||||
page: dto.page,
|
||||
referrer: this.cleanReferrer(dto.referrer),
|
||||
userAgent: this.anonymizeUserAgent(dto.userAgent),
|
||||
screenSize: this.categorizeScreenSize(dto.screenSize),
|
||||
sessionId: dto.sessionId,
|
||||
clientTimestamp: dto.timestamp,
|
||||
ipAnonymized: this.anonymizeIP(ip),
|
||||
metadata: dto.metadata,
|
||||
});
|
||||
|
||||
await this.eventRepo.save(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Anonymisiert IP-Adresse (DSGVO-konform)
|
||||
*/
|
||||
private anonymizeIP(ip: string): string {
|
||||
if (!ip) return '';
|
||||
|
||||
// IPv4: Letztes Oktett auf 0 setzen
|
||||
if (ip.includes('.')) {
|
||||
return ip.replace(/\.\d+$/, '.0');
|
||||
}
|
||||
|
||||
// IPv6: Letzte 80 Bits nullen
|
||||
if (ip.includes(':')) {
|
||||
const parts = ip.split(':');
|
||||
return parts.slice(0, 3).join(':') + '::0';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Anonymisiert User-Agent (DSGVO-konform)
|
||||
* Behält nur OS und Browser-Typ, keine Versionen oder Details
|
||||
*/
|
||||
private anonymizeUserAgent(userAgent?: string): string {
|
||||
if (!userAgent) return 'Unknown';
|
||||
|
||||
// Betriebssystem erkennen
|
||||
let os = 'Unknown OS';
|
||||
if (/Windows/i.test(userAgent)) os = 'Windows';
|
||||
else if (/Mac OS X|Macintosh/i.test(userAgent)) os = 'MacOS';
|
||||
else if (/iPhone|iPad|iPod/i.test(userAgent)) os = 'iOS';
|
||||
else if (/Android/i.test(userAgent)) os = 'Android';
|
||||
else if (/Linux/i.test(userAgent)) os = 'Linux';
|
||||
else if (/CrOS/i.test(userAgent)) os = 'ChromeOS';
|
||||
|
||||
// Browser erkennen (Reihenfolge wichtig wegen User-Agent-Strings)
|
||||
let browser = 'Unknown Browser';
|
||||
if (/Edg\//i.test(userAgent)) browser = 'Edge';
|
||||
else if (/OPR|Opera/i.test(userAgent)) browser = 'Opera';
|
||||
else if (/Firefox/i.test(userAgent)) browser = 'Firefox';
|
||||
else if (/Chrome/i.test(userAgent)) browser = 'Chrome';
|
||||
else if (/Safari/i.test(userAgent)) browser = 'Safari';
|
||||
else if (/MSIE|Trident/i.test(userAgent)) browser = 'IE';
|
||||
|
||||
return `${os} / ${browser}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kategorisiert Bildschirmgröße (DSGVO-konform)
|
||||
* Keine exakten Werte, nur Kategorien
|
||||
*/
|
||||
private categorizeScreenSize(screenSize?: string): string {
|
||||
if (!screenSize) return 'unknown';
|
||||
|
||||
const match = screenSize.match(/^(\d+)x(\d+)$/);
|
||||
if (!match) return 'unknown';
|
||||
|
||||
const width = parseInt(match[1], 10);
|
||||
|
||||
// Kategorisieren statt exakte Werte
|
||||
if (width <= 480) return 'mobile-small';
|
||||
if (width <= 768) return 'mobile';
|
||||
if (width <= 1024) return 'tablet';
|
||||
if (width <= 1440) return 'desktop';
|
||||
return 'desktop-large';
|
||||
}
|
||||
|
||||
/**
|
||||
* Bereinigt Referrer (entfernt Query-Parameter mit sensiblen Daten)
|
||||
*/
|
||||
private cleanReferrer(referrer?: string): string | undefined {
|
||||
if (!referrer) return undefined;
|
||||
|
||||
try {
|
||||
const url = new URL(referrer);
|
||||
// Nur Origin + Pathname, keine Query-Parameter
|
||||
return url.origin + url.pathname;
|
||||
} catch {
|
||||
return referrer;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard-Daten für Admin
|
||||
*/
|
||||
async getDashboard(): Promise<AnalyticsDashboardDto> {
|
||||
const [overview, timeSeries, topPages, referrers, devices, recentEvents] = await Promise.all([
|
||||
this.getOverview(),
|
||||
this.getTimeSeries(30),
|
||||
this.getTopPages(30),
|
||||
this.getReferrers(30),
|
||||
this.getDeviceStats(30),
|
||||
this.getRecentEvents(20),
|
||||
]);
|
||||
|
||||
return {
|
||||
overview,
|
||||
timeSeries,
|
||||
topPages,
|
||||
referrers,
|
||||
devices,
|
||||
recentEvents,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Overview-Statistiken
|
||||
*/
|
||||
async getOverview(): Promise<AnalyticsOverviewDto> {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const last7Days = new Date(today);
|
||||
last7Days.setDate(last7Days.getDate() - 7);
|
||||
const last30Days = new Date(today);
|
||||
last30Days.setDate(last30Days.getDate() - 30);
|
||||
|
||||
const [todayStats, yesterdayStats, last7Stats, last30Stats] = await Promise.all([
|
||||
this.getStatsForPeriod(today, now),
|
||||
this.getStatsForPeriod(yesterday, today),
|
||||
this.getStatsForPeriod(last7Days, now),
|
||||
this.getStatsForPeriod(last30Days, now),
|
||||
]);
|
||||
|
||||
return {
|
||||
today: todayStats,
|
||||
yesterday: yesterdayStats,
|
||||
last7Days: last7Stats,
|
||||
last30Days: last30Stats,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Statistiken für einen Zeitraum
|
||||
*/
|
||||
private async getStatsForPeriod(from: Date, to: Date): Promise<{ pageviews: number; uniqueSessions: number; conversions: number }> {
|
||||
const events = await this.eventRepo.find({
|
||||
where: {
|
||||
createdAt: Between(from, to),
|
||||
},
|
||||
select: ['type', 'sessionId'],
|
||||
});
|
||||
|
||||
const pageviews = events.filter(e => e.type === 'pageview').length;
|
||||
const uniqueSessions = new Set(events.map(e => e.sessionId)).size;
|
||||
const conversions = events.filter(e => e.type === 'conversion').length;
|
||||
|
||||
return { pageviews, uniqueSessions, conversions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeitreihen-Daten
|
||||
*/
|
||||
async getTimeSeries(days: number): Promise<TimeSeriesPointDto[]> {
|
||||
const result: TimeSeriesPointDto[] = [];
|
||||
const now = new Date();
|
||||
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const date = new Date(now);
|
||||
date.setDate(date.getDate() - i);
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
|
||||
const dayStart = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
const dayEnd = new Date(dayStart);
|
||||
dayEnd.setDate(dayEnd.getDate() + 1);
|
||||
|
||||
const events = await this.eventRepo.find({
|
||||
where: {
|
||||
createdAt: Between(dayStart, dayEnd),
|
||||
},
|
||||
select: ['type', 'sessionId'],
|
||||
});
|
||||
|
||||
result.push({
|
||||
date: dateStr,
|
||||
pageviews: events.filter(e => e.type === 'pageview').length,
|
||||
uniqueSessions: new Set(events.map(e => e.sessionId)).size,
|
||||
conversions: events.filter(e => e.type === 'conversion').length,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-Seiten
|
||||
*/
|
||||
async getTopPages(days: number): Promise<PageStatsDto[]> {
|
||||
const since = new Date();
|
||||
since.setDate(since.getDate() - days);
|
||||
|
||||
const events = await this.eventRepo.find({
|
||||
where: {
|
||||
type: 'pageview',
|
||||
createdAt: MoreThanOrEqual(since),
|
||||
},
|
||||
select: ['page', 'sessionId'],
|
||||
});
|
||||
|
||||
const pageMap = new Map<string, { views: number; sessions: Set<string> }>();
|
||||
|
||||
for (const event of events) {
|
||||
const existing = pageMap.get(event.page) || { views: 0, sessions: new Set<string>() };
|
||||
existing.views++;
|
||||
existing.sessions.add(event.sessionId);
|
||||
pageMap.set(event.page, existing);
|
||||
}
|
||||
|
||||
return Array.from(pageMap.entries())
|
||||
.map(([page, stats]) => ({
|
||||
page,
|
||||
views: stats.views,
|
||||
uniqueSessions: stats.sessions.size,
|
||||
}))
|
||||
.sort((a, b) => b.views - a.views)
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Referrer-Statistiken
|
||||
*/
|
||||
async getReferrers(days: number): Promise<ReferrerStatsDto[]> {
|
||||
const since = new Date();
|
||||
since.setDate(since.getDate() - days);
|
||||
|
||||
const events = await this.eventRepo.find({
|
||||
where: {
|
||||
type: 'pageview',
|
||||
createdAt: MoreThanOrEqual(since),
|
||||
},
|
||||
select: ['referrer'],
|
||||
});
|
||||
|
||||
const referrerMap = new Map<string, number>();
|
||||
|
||||
for (const event of events) {
|
||||
const referrer = event.referrer ? this.parseReferrer(event.referrer) : 'Direkt';
|
||||
referrerMap.set(referrer, (referrerMap.get(referrer) || 0) + 1);
|
||||
}
|
||||
|
||||
return Array.from(referrerMap.entries())
|
||||
.map(([referrer, count]) => ({ referrer, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrahiert Domain aus Referrer
|
||||
*/
|
||||
private parseReferrer(referrer: string): string {
|
||||
try {
|
||||
const url = new URL(referrer);
|
||||
return url.hostname.replace('www.', '');
|
||||
} catch {
|
||||
return referrer || 'Direkt';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Geräte-Statistiken (basierend auf User-Agent)
|
||||
*/
|
||||
async getDeviceStats(days: number): Promise<DeviceStatsDto> {
|
||||
const since = new Date();
|
||||
since.setDate(since.getDate() - days);
|
||||
|
||||
const events = await this.eventRepo.find({
|
||||
where: {
|
||||
type: 'pageview',
|
||||
createdAt: MoreThanOrEqual(since),
|
||||
},
|
||||
select: ['userAgent', 'sessionId'],
|
||||
});
|
||||
|
||||
// Unique sessions
|
||||
const sessionDevices = new Map<string, string>();
|
||||
|
||||
for (const event of events) {
|
||||
if (!sessionDevices.has(event.sessionId)) {
|
||||
sessionDevices.set(event.sessionId, this.detectDevice(event.userAgent));
|
||||
}
|
||||
}
|
||||
|
||||
const stats: DeviceStatsDto = { desktop: 0, mobile: 0, tablet: 0 };
|
||||
|
||||
for (const device of sessionDevices.values()) {
|
||||
if (device === 'mobile') stats.mobile++;
|
||||
else if (device === 'tablet') stats.tablet++;
|
||||
else stats.desktop++;
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erkennt Gerätetyp aus User-Agent
|
||||
*/
|
||||
private detectDevice(userAgent?: string): 'desktop' | 'mobile' | 'tablet' {
|
||||
if (!userAgent) return 'desktop';
|
||||
|
||||
const ua = userAgent.toLowerCase();
|
||||
|
||||
if (/ipad|tablet|playbook|silk/i.test(ua)) {
|
||||
return 'tablet';
|
||||
}
|
||||
|
||||
if (/mobile|iphone|ipod|android|blackberry|opera mini|iemobile/i.test(ua)) {
|
||||
return 'mobile';
|
||||
}
|
||||
|
||||
return 'desktop';
|
||||
}
|
||||
|
||||
/**
|
||||
* Letzte Events
|
||||
*/
|
||||
async getRecentEvents(limit: number): Promise<RecentEventDto[]> {
|
||||
const events = await this.eventRepo.find({
|
||||
order: { createdAt: 'DESC' },
|
||||
take: limit,
|
||||
select: ['type', 'page', 'createdAt', 'metadata', 'sessionId', 'userAgent', 'screenSize', 'referrer'],
|
||||
});
|
||||
|
||||
return events.map(e => ({
|
||||
type: e.type,
|
||||
page: e.page,
|
||||
timestamp: e.createdAt,
|
||||
sessionId: e.sessionId,
|
||||
userAgent: e.userAgent,
|
||||
screenSize: e.screenSize,
|
||||
referrer: e.referrer,
|
||||
metadata: e.metadata,
|
||||
deviceType: this.detectDevice(e.userAgent),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht alte Events (Datensparsamkeit)
|
||||
*/
|
||||
async cleanupOldEvents(daysToKeep: number = 90): Promise<number> {
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - daysToKeep);
|
||||
|
||||
const result = await this.eventRepo
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.where('createdAt < :cutoff', { cutoff })
|
||||
.execute();
|
||||
|
||||
return result.affected || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* DSGVO: Auskunftsrecht - Gibt alle Daten zu einer Session-ID zurück
|
||||
*/
|
||||
async getDataBySessionId(sessionId: string): Promise<AnalyticsEvent[]> {
|
||||
return this.eventRepo.find({
|
||||
where: { sessionId },
|
||||
order: { createdAt: 'DESC' },
|
||||
select: ['type', 'page', 'createdAt', 'screenSize', 'userAgent'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DSGVO: Löschrecht - Löscht alle Daten zu einer Session-ID
|
||||
*/
|
||||
async deleteDataBySessionId(sessionId: string): Promise<number> {
|
||||
const result = await this.eventRepo
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.where('sessionId = :sessionId', { sessionId })
|
||||
.execute();
|
||||
|
||||
return result.affected || 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) { }
|
||||
|
||||
@Get('health')
|
||||
health() {
|
||||
return { status: 'ok' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// app.module.ts
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { ThrottlerBehindProxyGuard } from './guards/throttler-behind-proxy.guard';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { ContactRequestsModule } from './contact-requests/contact-requests.module';
|
||||
import { ContactRequest } from './contact-requests/contact-requests.entity';
|
||||
import { User } from './users/users.entity';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { EmailModule } from './email/email.module';
|
||||
import { BookingModule } from './booking/booking.module';
|
||||
import { BookingSlot } from './booking/booking-slots.entity';
|
||||
import { Booking } from './booking/bookings.entity';
|
||||
import { GoogleCalendarModule } from './booking/google-calendar.module';
|
||||
import { NewsletterModule } from './newsletter/newsletter.module';
|
||||
import { NewsletterSubscriber } from './newsletter/newsletter.entity';
|
||||
import { SettingsModule } from './settings/settings.module';
|
||||
import { Settings } from './settings/settings.entity';
|
||||
import { FaqModule } from './faq/faq.module';
|
||||
import { Faq } from './faq/faq.entity';
|
||||
import { ServicesCatalogModule } from './services-catalog/services-catalog.module';
|
||||
import { ServiceCategoryEntity, ServiceEntity } from './services-catalog/services-catalog.entity';
|
||||
import { InvoicesModule } from './invoices/invoices.module';
|
||||
import { Invoice } from './invoices/invoices.entity';
|
||||
import { AnalyticsModule } from './analytics/analytics.module';
|
||||
import { AnalyticsEvent, AnalyticsDailyStats } from './analytics/analytics.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: process.env.NODE_ENV === 'production'
|
||||
? '.env.production'
|
||||
: '.env.development',
|
||||
}),
|
||||
// 🛡️ Global Rate Limiting - DDoS Schutz
|
||||
ThrottlerModule.forRoot([
|
||||
{
|
||||
name: 'short',
|
||||
ttl: 1000, // 1 Sekunde
|
||||
limit: 15, // Max 15 Requests pro Sekunde
|
||||
},
|
||||
{
|
||||
name: 'medium',
|
||||
ttl: 10000, // 10 Sekunden
|
||||
limit: 80, // Max 80 Requests pro 10 Sekunden
|
||||
},
|
||||
{
|
||||
name: 'long',
|
||||
ttl: 60000, // 1 Minute
|
||||
limit: 300, // Max 300 Requests pro Minute
|
||||
},
|
||||
]),
|
||||
TypeOrmModule.forRoot({
|
||||
type: 'postgres',
|
||||
host: process.env.DB_HOST ?? 'localhost',
|
||||
port: parseInt(process.env.DB_PORT ?? '5432', 10),
|
||||
username: process.env.DB_USER ?? 'app',
|
||||
password: process.env.DB_PASS ?? 'secret',
|
||||
database: process.env.DB_NAME ?? 'appdb',
|
||||
entities: [User, ContactRequest, BookingSlot, Booking, NewsletterSubscriber, Settings, Faq, ServiceCategoryEntity, ServiceEntity, Invoice, AnalyticsEvent, AnalyticsDailyStats],
|
||||
synchronize: true,
|
||||
logging: process.env.NODE_ENV === 'development',
|
||||
ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false,
|
||||
}),
|
||||
UsersModule,
|
||||
AuthModule,
|
||||
ContactRequestsModule,
|
||||
EmailModule,
|
||||
BookingModule,
|
||||
GoogleCalendarModule,
|
||||
NewsletterModule,
|
||||
SettingsModule,
|
||||
FaqModule,
|
||||
ServicesCatalogModule,
|
||||
InvoicesModule,
|
||||
AnalyticsModule,
|
||||
],
|
||||
providers: [
|
||||
// 🛡️ Global Rate Limit Guard mit Proxy-Support und Headers
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: ThrottlerBehindProxyGuard,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule { }
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Controller, Post, Body, Get, UseGuards, Request } from '@nestjs/common';
|
||||
import { Throttle, SkipThrottle } from '@nestjs/throttler';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { IsEmail, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class RegisterDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password: string;
|
||||
}
|
||||
|
||||
export class LoginDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
password: string;
|
||||
}
|
||||
|
||||
@Controller('auth')
|
||||
@Throttle({ default: { limit: 20, ttl: 60000 } }) // 🛡️ Basis: 20 Requests/Minute
|
||||
export class AuthController {
|
||||
constructor(private authService: AuthService) {}
|
||||
|
||||
// 🛡️ STRENG: 3 Versuche pro Minute (Spam-Schutz)
|
||||
@Throttle({ default: { limit: 3, ttl: 60000 } })
|
||||
@Post('register')
|
||||
async register(@Body() dto: RegisterDto) {
|
||||
return this.authService.register(dto.email, dto.name, dto.password);
|
||||
}
|
||||
|
||||
// 🛡️ STRENG: 5 Versuche pro Minute (Brute-Force Schutz)
|
||||
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||
@Post('login')
|
||||
async login(@Body() dto: LoginDto) {
|
||||
return this.authService.login(dto.email, dto.password);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async getProfile(@Request() req) {
|
||||
return req.user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { User } from 'src/users/users.entity';
|
||||
import { GoogleAuthController } from 'src/booking/google-auth.controller';
|
||||
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([User]),
|
||||
PassportModule,
|
||||
// 🛡️ JWT Secret aus Umgebungsvariable laden (KEIN Fallback!)
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => {
|
||||
const secret = configService.get<string>('JWT_SECRET');
|
||||
if (!secret) {
|
||||
throw new Error('❌ JWT_SECRET Umgebungsvariable ist nicht gesetzt! Die Anwendung kann nicht sicher starten.');
|
||||
}
|
||||
return {
|
||||
secret,
|
||||
signOptions: { expiresIn: '7d' },
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController, GoogleAuthController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule { }
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Injectable, UnauthorizedException, ConflictException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { UserRole, User } from 'src/users/users.entity';
|
||||
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
role: UserRole;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string;
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
createdAt: Date;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private userRepo: Repository<User>,
|
||||
private jwtService: JwtService,
|
||||
) { }
|
||||
|
||||
async register(email: string, name: string, password: string): Promise<LoginResponse> {
|
||||
// 1) Eingaben prüfen
|
||||
if (!email || !password) {
|
||||
throw new UnauthorizedException('Email and password are required');
|
||||
}
|
||||
email = email.trim().toLowerCase();
|
||||
|
||||
// 2) Existenz prüfen (bei CITEXT ist das case-insensitive)
|
||||
const existing = await this.userRepo.findOne({ where: { email } });
|
||||
if (existing) {
|
||||
throw new ConflictException('Email already exists');
|
||||
}
|
||||
|
||||
// 3) Passwort hashen
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
// 4) User speichern
|
||||
const user = this.userRepo.create({
|
||||
email,
|
||||
name,
|
||||
password: hashedPassword,
|
||||
role: UserRole.USER,
|
||||
});
|
||||
const savedUser = await this.userRepo.save(user);
|
||||
|
||||
// 5) Token zurückgeben
|
||||
return this.generateToken(savedUser);
|
||||
}
|
||||
|
||||
async login(email: string, password: string): Promise<LoginResponse> {
|
||||
// 1) Eingaben prüfen
|
||||
if (!email || !password) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
email = email.trim().toLowerCase();
|
||||
|
||||
// 2) User inkl. Passwort laden (wichtig!)
|
||||
// Funktioniert egal ob @Column({ select: false }) gesetzt ist oder nicht
|
||||
const user = await this.userRepo
|
||||
.createQueryBuilder('user')
|
||||
.addSelect('user.password')
|
||||
.where('LOWER(user.email) = :email', { email }) // falls Spalte kein CITEXT ist
|
||||
.getOne();
|
||||
|
||||
if (!user || !user.password) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
// 3) Passwort prüfen
|
||||
const ok = await bcrypt.compare(password, user.password);
|
||||
if (!ok) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
// 4) Token
|
||||
return this.generateToken(user);
|
||||
}
|
||||
|
||||
async validateUser(userId: string): Promise<User | null> {
|
||||
return this.userRepo.findOne({
|
||||
where: { id: userId },
|
||||
select: ['id', 'email', 'name', 'role', 'wantsNewsletter', 'isVerified', 'createdAt']
|
||||
});
|
||||
}
|
||||
|
||||
// In auth.service.ts - generateToken()
|
||||
private generateToken(user: User): LoginResponse {
|
||||
const payload: JwtPayload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
createdAt: user.createdAt,
|
||||
};
|
||||
|
||||
const token = this.jwtService.sign(payload);
|
||||
|
||||
return {
|
||||
access_token: token,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
createdAt: user.createdAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.user;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||
import { UserRole } from 'src/users/users.entity';
|
||||
|
||||
@Injectable()
|
||||
export class AdminGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
|
||||
if (!user) {
|
||||
throw new ForbiddenException('User not authenticated');
|
||||
}
|
||||
|
||||
if (user.role !== UserRole.ADMIN) {
|
||||
throw new ForbiddenException('Admin access required');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
@@ -0,0 +1,36 @@
|
||||
// auth/strategies/jwt.strategy.ts
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { AuthService } from '../auth.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private configService: ConfigService
|
||||
) {
|
||||
// 🛡️ JWT Secret aus Umgebungsvariable - KEIN Fallback!
|
||||
const secret = configService.get<string>('JWT_SECRET');
|
||||
if (!secret) {
|
||||
throw new Error('❌ JWT_SECRET Umgebungsvariable fehlt!');
|
||||
}
|
||||
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: secret,
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: any) {
|
||||
const user = await this.authService.validateUser(payload.sub);
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Benutzer nicht gefunden');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm';
|
||||
|
||||
@Entity('booking_slots')
|
||||
export class BookingSlot {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'date' })
|
||||
date: string; // Format: YYYY-MM-DD
|
||||
|
||||
@Column({ type: 'time' })
|
||||
timeFrom: string; // Format: HH:MM
|
||||
|
||||
@Column({ type: 'time' })
|
||||
timeTo: string; // Format: HH:MM
|
||||
|
||||
@Index()
|
||||
@Column({ default: true })
|
||||
isAvailable: boolean;
|
||||
|
||||
@Column({ type: 'int', default: 1 })
|
||||
maxBookings: number; // Wie viele Buchungen parallel möglich
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
currentBookings: number; // Aktuelle Anzahl Buchungen
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
@Column({ nullable: true })
|
||||
googleEventId?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
meetLink?: string;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Controller, Get, Post, Body, Param, Patch, Delete, UseGuards, Query } from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { BookingService } from './booking.service';
|
||||
import { CreateBookingSlotDto, UpdateBookingSlotDto, CreateBookingDto, UpdateBookingDto } from './booking.dto';
|
||||
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';
|
||||
import { AdminGuard } from 'src/auth/guards/admin.guard';
|
||||
|
||||
@Controller('bookings')
|
||||
@Throttle({ default: { limit: 30, ttl: 60000 } }) // 🛡️ Basis: 30 Requests/Minute
|
||||
export class BookingController {
|
||||
constructor(private readonly bookingService: BookingService) {}
|
||||
|
||||
// ==================== SLOTS ====================
|
||||
|
||||
// PUBLIC: Verfügbare Slots abrufen
|
||||
@Get('slots/available')
|
||||
async getAvailableSlots(@Query('fromDate') fromDate?: string) {
|
||||
return this.bookingService.getAvailableSlots(fromDate);
|
||||
}
|
||||
|
||||
// PUBLIC: Slots für ein bestimmtes Datum
|
||||
@Get('slots/date/:date')
|
||||
async getSlotsByDate(@Param('date') date: string) {
|
||||
return this.bookingService.getSlotsByDate(date);
|
||||
}
|
||||
|
||||
// ADMIN: Alle Slots abrufen
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Get('slots')
|
||||
async getAllSlots() {
|
||||
return this.bookingService.getAllSlots();
|
||||
}
|
||||
|
||||
// ADMIN: Einzelnen Slot erstellen
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Post('slots')
|
||||
async createSlot(@Body() dto: CreateBookingSlotDto) {
|
||||
return this.bookingService.createSlot(dto);
|
||||
}
|
||||
|
||||
// ADMIN: Mehrere Slots auf einmal erstellen
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Post('slots/bulk')
|
||||
async createMultipleSlots(@Body() dto: { slots: CreateBookingSlotDto[] }) {
|
||||
return this.bookingService.createMultipleSlots(dto.slots);
|
||||
}
|
||||
|
||||
// ADMIN: Slot aktualisieren
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Patch('slots/:id')
|
||||
async updateSlot(@Param('id') id: string, @Body() dto: UpdateBookingSlotDto) {
|
||||
return this.bookingService.updateSlot(id, dto);
|
||||
}
|
||||
|
||||
// ADMIN: Slot löschen
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Delete('slots/:id')
|
||||
async deleteSlot(@Param('id') id: string) {
|
||||
return this.bookingService.deleteSlot(id);
|
||||
}
|
||||
|
||||
// ==================== BOOKINGS ====================
|
||||
|
||||
// 🛡️ Öffentlich - STRENG: 5 Buchungen pro Stunde pro IP
|
||||
@Throttle({ default: { limit: 5, ttl: 3600000 } })
|
||||
@Post()
|
||||
async createBooking(@Body() dto: CreateBookingDto) {
|
||||
return this.bookingService.createBooking(dto);
|
||||
}
|
||||
|
||||
// ADMIN: Alle Bookings abrufen
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Get()
|
||||
async getAllBookings() {
|
||||
return this.bookingService.getAllBookings();
|
||||
}
|
||||
|
||||
// ADMIN: Einzelne Booking abrufen
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Get(':id')
|
||||
async getBookingById(@Param('id') id: string) {
|
||||
return this.bookingService.getBookingById(id);
|
||||
}
|
||||
|
||||
// ADMIN: Booking aktualisieren
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Patch(':id')
|
||||
async updateBooking(@Param('id') id: string, @Body() dto: UpdateBookingDto) {
|
||||
return this.bookingService.updateBooking(id, dto);
|
||||
}
|
||||
|
||||
// ADMIN: Booking stornieren
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Patch(':id/cancel')
|
||||
async cancelBooking(@Param('id') id: string) {
|
||||
return this.bookingService.cancelBooking(id);
|
||||
}
|
||||
|
||||
// ADMIN: Booking löschen
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Delete(':id')
|
||||
async deleteBooking(@Param('id') id: string) {
|
||||
return this.bookingService.deleteBooking(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { IsString, IsEmail, IsOptional, IsDateString, IsBoolean, IsInt, Min, Max, Matches, IsEnum, IsUUID } from 'class-validator';
|
||||
import { BookingStatus } from './bookings.entity';
|
||||
|
||||
export class CreateBookingSlotDto {
|
||||
@IsDateString()
|
||||
date: string; // YYYY-MM-DD
|
||||
|
||||
@Matches(/^([0-1][0-9]|2[0-3]):[0-5][0-9]$/, {
|
||||
message: 'timeFrom must be in format HH:MM',
|
||||
})
|
||||
timeFrom: string;
|
||||
|
||||
@Matches(/^([0-1][0-9]|2[0-3]):[0-5][0-9]$/, {
|
||||
message: 'timeTo must be in format HH:MM',
|
||||
})
|
||||
timeTo: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(10)
|
||||
@IsOptional()
|
||||
maxBookings?: number;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isAvailable?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateBookingSlotDto {
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
date?: string;
|
||||
|
||||
@Matches(/^([0-1][0-9]|2[0-3]):[0-5][0-9]$/)
|
||||
@IsOptional()
|
||||
timeFrom?: string;
|
||||
|
||||
@Matches(/^([0-1][0-9]|2[0-3]):[0-5][0-9]$/)
|
||||
@IsOptional()
|
||||
timeTo?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isAvailable?: boolean;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
maxBookings?: number;
|
||||
}
|
||||
|
||||
export class CreateBookingDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name: string;
|
||||
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
phone?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
message?: string;
|
||||
|
||||
@IsUUID()
|
||||
slotId: string;
|
||||
}
|
||||
|
||||
export class UpdateBookingDto {
|
||||
@IsEnum(BookingStatus)
|
||||
@IsOptional()
|
||||
status?: BookingStatus;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
adminNotes?: string;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BookingService } from './booking.service';
|
||||
import { BookingController } from './booking.controller';
|
||||
import { BookingSlot } from './booking-slots.entity';
|
||||
import { Booking } from './bookings.entity';
|
||||
import { EmailModule } from 'src/email/email.module';
|
||||
import { GoogleCalendarModule } from './google-calendar.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([BookingSlot, Booking]),
|
||||
EmailModule,
|
||||
GoogleCalendarModule,
|
||||
],
|
||||
providers: [BookingService],
|
||||
controllers: [BookingController],
|
||||
exports: [BookingService],
|
||||
})
|
||||
export class BookingModule {}
|
||||
@@ -0,0 +1,282 @@
|
||||
import { Injectable, NotFoundException, BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, MoreThanOrEqual } from 'typeorm';
|
||||
import { BookingSlot } from './booking-slots.entity';
|
||||
import { Booking, BookingStatus } from './bookings.entity';
|
||||
import { CreateBookingSlotDto, UpdateBookingSlotDto, CreateBookingDto, UpdateBookingDto } from './booking.dto';
|
||||
import { EmailService } from 'src/email/email.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { GoogleCalendarService } from './google-calendar.service';
|
||||
|
||||
@Injectable()
|
||||
export class BookingService {
|
||||
constructor(
|
||||
@InjectRepository(BookingSlot)
|
||||
private readonly slotRepo: Repository<BookingSlot>,
|
||||
@InjectRepository(Booking)
|
||||
private readonly bookingRepo: Repository<Booking>,
|
||||
private readonly emailService: EmailService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly googleCalendarService: GoogleCalendarService,
|
||||
) { }
|
||||
|
||||
// ==================== SLOTS (ADMIN) ====================
|
||||
|
||||
async createSlot(dto: CreateBookingSlotDto): Promise<BookingSlot> {
|
||||
// Validierung: timeFrom < timeTo
|
||||
if (dto.timeFrom >= dto.timeTo) {
|
||||
throw new BadRequestException('timeFrom must be before timeTo');
|
||||
}
|
||||
|
||||
const slot = this.slotRepo.create({
|
||||
...dto,
|
||||
maxBookings: dto.maxBookings || 1,
|
||||
isAvailable: dto.isAvailable !== false,
|
||||
});
|
||||
|
||||
return this.slotRepo.save(slot);
|
||||
}
|
||||
|
||||
async createMultipleSlots(slots: CreateBookingSlotDto[]): Promise<BookingSlot[]> {
|
||||
const created = slots.map(dto => {
|
||||
if (dto.timeFrom >= dto.timeTo) {
|
||||
throw new BadRequestException(`Invalid time range for ${dto.date} ${dto.timeFrom}-${dto.timeTo}`);
|
||||
}
|
||||
return this.slotRepo.create({
|
||||
...dto,
|
||||
maxBookings: dto.maxBookings || 1,
|
||||
isAvailable: dto.isAvailable !== false,
|
||||
});
|
||||
});
|
||||
|
||||
return this.slotRepo.save(created);
|
||||
}
|
||||
|
||||
async getAllSlots(): Promise<BookingSlot[]> {
|
||||
return this.slotRepo.find({
|
||||
order: { date: 'ASC', timeFrom: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
toLocalYMD(date = new Date()): string {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
async getAvailableSlots(fromDate?: string): Promise<BookingSlot[]> {
|
||||
const today = fromDate || this.toLocalYMD(); // statt new Date().toISOString().split('T')[0]
|
||||
return this.slotRepo.find({
|
||||
where: { date: MoreThanOrEqual(today), isAvailable: true },
|
||||
order: { date: 'ASC', timeFrom: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async getSlotsByDate(date: string): Promise<BookingSlot[]> {
|
||||
return this.slotRepo.find({
|
||||
where: { date },
|
||||
order: { timeFrom: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async updateSlot(id: string, dto: UpdateBookingSlotDto): Promise<BookingSlot> {
|
||||
const slot = await this.slotRepo.findOne({ where: { id } });
|
||||
if (!slot) {
|
||||
throw new NotFoundException(`Slot with ID ${id} not found`);
|
||||
}
|
||||
|
||||
if (dto.timeFrom && dto.timeTo && dto.timeFrom >= dto.timeTo) {
|
||||
throw new BadRequestException('timeFrom must be before timeTo');
|
||||
}
|
||||
|
||||
Object.assign(slot, dto);
|
||||
return this.slotRepo.save(slot);
|
||||
}
|
||||
|
||||
async deleteSlot(id: string): Promise<void> {
|
||||
const result = await this.slotRepo.delete(id);
|
||||
if (result.affected === 0) {
|
||||
throw new NotFoundException(`Slot with ID ${id} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== BOOKINGS ====================
|
||||
|
||||
async createBooking(dto: CreateBookingDto): Promise<Booking> {
|
||||
const slot = await this.slotRepo.findOne({ where: { id: dto.slotId } });
|
||||
|
||||
if (!slot) {
|
||||
throw new NotFoundException('Slot not found');
|
||||
}
|
||||
|
||||
if (!slot.isAvailable) {
|
||||
throw new ConflictException('This slot is not available');
|
||||
}
|
||||
|
||||
if (slot.currentBookings >= slot.maxBookings) {
|
||||
throw new ConflictException('This slot is fully booked');
|
||||
}
|
||||
|
||||
// Booking erstellen
|
||||
const booking = this.bookingRepo.create(dto);
|
||||
const saved = await this.bookingRepo.save(booking);
|
||||
|
||||
// Google Meet erstellen
|
||||
try {
|
||||
// DateTime richtig formatieren
|
||||
const startDateTime = `${slot.date}T${slot.timeFrom}+02:00`;
|
||||
const endDateTime = `${slot.date}T${slot.timeTo}+02:00`;
|
||||
|
||||
console.log('🕐 DateTime Debug:');
|
||||
console.log(' slot.date:', slot.date);
|
||||
console.log(' slot.timeFrom:', slot.timeFrom);
|
||||
console.log(' slot.timeTo:', slot.timeTo);
|
||||
console.log(' startDateTime:', startDateTime);
|
||||
console.log(' endDateTime:', endDateTime);
|
||||
|
||||
const meetData = await this.googleCalendarService.createMeeting({
|
||||
summary: `Beratungsgespräch LeonardsMedia - ${booking.name}`,
|
||||
description: booking.message || 'Beratungstermin',
|
||||
startDateTime,
|
||||
endDateTime,
|
||||
attendees: [booking.email],
|
||||
});
|
||||
|
||||
// Meet Link im Slot speichern
|
||||
slot.googleEventId = meetData.eventId;
|
||||
slot.meetLink = meetData.meetLink;
|
||||
|
||||
console.log('✅ Meet Link gespeichert:', slot.meetLink);
|
||||
} catch (error) {
|
||||
// Fehler loggen, aber Buchung nicht abbrechen
|
||||
console.error('❌ Google Meet konnte nicht erstellt werden:', error);
|
||||
console.error(' Error Details:', error.message);
|
||||
// Booking wird trotzdem gespeichert, nur ohne Meet Link
|
||||
}
|
||||
|
||||
// Slot-Counter erhöhen
|
||||
slot.currentBookings += 1;
|
||||
if (slot.currentBookings >= slot.maxBookings) {
|
||||
slot.isAvailable = false;
|
||||
}
|
||||
await this.slotRepo.save(slot);
|
||||
|
||||
// Emails senden (mit Meet Link)
|
||||
await this.sendBookingEmails(saved, slot);
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
private async sendBookingEmails(booking: Booking, slot: BookingSlot): Promise<void> {
|
||||
const adminEmail = this.configService.get<string>('ADMIN_EMAIL');
|
||||
|
||||
// Email an Customer
|
||||
await this.emailService.sendBookingConfirmation({
|
||||
to: booking.email,
|
||||
customerName: booking.name,
|
||||
date: slot.date,
|
||||
timeFrom: slot.timeFrom,
|
||||
timeTo: slot.timeTo,
|
||||
bookingId: booking.id,
|
||||
meetLink: slot.meetLink,
|
||||
});
|
||||
|
||||
// Email an Admin
|
||||
if (adminEmail) {
|
||||
await this.emailService.sendBookingNotificationToAdmin({
|
||||
to: adminEmail,
|
||||
customerName: booking.name,
|
||||
customerEmail: booking.email,
|
||||
customerPhone: booking.phone,
|
||||
message: booking.message,
|
||||
date: slot.date,
|
||||
timeFrom: slot.timeFrom,
|
||||
timeTo: slot.timeTo,
|
||||
bookingId: booking.id,
|
||||
meetLink: slot.meetLink, // NEU!
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getAllBookings(): Promise<Booking[]> {
|
||||
return this.bookingRepo.find({
|
||||
relations: ['slot'],
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async getBookingById(id: string): Promise<Booking> {
|
||||
const booking = await this.bookingRepo.findOne({
|
||||
where: { id },
|
||||
relations: ['slot'],
|
||||
});
|
||||
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with ID ${id} not found`);
|
||||
}
|
||||
|
||||
return booking;
|
||||
}
|
||||
|
||||
async updateBooking(id: string, dto: UpdateBookingDto): Promise<Booking> {
|
||||
const booking = await this.getBookingById(id);
|
||||
Object.assign(booking, dto);
|
||||
return this.bookingRepo.save(booking);
|
||||
}
|
||||
|
||||
async cancelBooking(id: string): Promise<Booking> {
|
||||
const booking = await this.getBookingById(id);
|
||||
|
||||
if (booking.status === BookingStatus.CANCELLED) {
|
||||
throw new BadRequestException('Booking is already cancelled');
|
||||
}
|
||||
|
||||
// Slot freigeben
|
||||
const slot = await this.slotRepo.findOne({ where: { id: booking.slotId } });
|
||||
if (slot) {
|
||||
// Google Meet löschen
|
||||
if (slot.googleEventId) {
|
||||
try {
|
||||
await this.googleCalendarService.deleteMeeting(slot.googleEventId);
|
||||
slot.googleEventId = null;
|
||||
slot.meetLink = null;
|
||||
} catch (error) {
|
||||
console.error('Google Meet konnte nicht gelöscht werden:', error);
|
||||
}
|
||||
}
|
||||
|
||||
slot.currentBookings = Math.max(0, slot.currentBookings - 1);
|
||||
slot.isAvailable = true;
|
||||
await this.slotRepo.save(slot);
|
||||
}
|
||||
|
||||
booking.status = BookingStatus.CANCELLED;
|
||||
return this.bookingRepo.save(booking);
|
||||
}
|
||||
|
||||
async deleteBooking(id: string): Promise<void> {
|
||||
const booking = await this.getBookingById(id);
|
||||
|
||||
// Slot freigeben
|
||||
const slot = await this.slotRepo.findOne({ where: { id: booking.slotId } });
|
||||
if (slot) {
|
||||
// Google Meet löschen
|
||||
if (slot.googleEventId) {
|
||||
try {
|
||||
await this.googleCalendarService.deleteMeeting(slot.googleEventId);
|
||||
} catch (error) {
|
||||
console.error('Google Meet konnte nicht gelöscht werden:', error);
|
||||
}
|
||||
}
|
||||
|
||||
slot.currentBookings = Math.max(0, slot.currentBookings - 1);
|
||||
slot.isAvailable = true;
|
||||
await this.slotRepo.save(slot);
|
||||
}
|
||||
|
||||
await this.bookingRepo.delete(id);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn, Index } from 'typeorm';
|
||||
import { BookingSlot } from './booking-slots.entity';
|
||||
|
||||
export enum BookingStatus {
|
||||
PENDING = 'pending',
|
||||
CONFIRMED = 'confirmed',
|
||||
CANCELLED = 'cancelled',
|
||||
COMPLETED = 'completed',
|
||||
}
|
||||
|
||||
@Entity('bookings')
|
||||
export class Booking {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
name: string;
|
||||
|
||||
@Column()
|
||||
email: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
phone: string | null;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
message: string | null;
|
||||
|
||||
@Column('uuid')
|
||||
slotId: string;
|
||||
|
||||
@ManyToOne(() => BookingSlot, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'slotId' })
|
||||
slot: BookingSlot;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: BookingStatus,
|
||||
default: BookingStatus.PENDING,
|
||||
})
|
||||
status: BookingStatus;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
adminNotes: string | null;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// src/auth/google-auth.controller.ts
|
||||
import { Controller, Get, Query, Res, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { google } from 'googleapis';
|
||||
import { Response } from 'express';
|
||||
|
||||
@Controller('auth/google')
|
||||
export class GoogleAuthController {
|
||||
private readonly logger = new Logger(GoogleAuthController.name);
|
||||
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
@Get('login')
|
||||
async googleLogin(@Res() res: Response) {
|
||||
const oauth2Client = new google.auth.OAuth2(
|
||||
this.configService.get<string>('GOOGLE_CLIENT_ID'),
|
||||
this.configService.get<string>('GOOGLE_CLIENT_SECRET'),
|
||||
'http://localhost:3000/auth/google/callback',
|
||||
);
|
||||
|
||||
const url = oauth2Client.generateAuthUrl({
|
||||
access_type: 'offline',
|
||||
scope: ['https://www.googleapis.com/auth/calendar'],
|
||||
prompt: 'consent',
|
||||
});
|
||||
|
||||
this.logger.log('🔗 Redirecting to Google OAuth...');
|
||||
res.redirect(url);
|
||||
}
|
||||
|
||||
@Get('callback')
|
||||
async googleCallback(@Query('code') code: string, @Res() res: Response) {
|
||||
if (!code) {
|
||||
return res.send('❌ Kein Code erhalten. Bitte versuche es erneut.');
|
||||
}
|
||||
|
||||
try {
|
||||
const oauth2Client = new google.auth.OAuth2(
|
||||
this.configService.get<string>('GOOGLE_CLIENT_ID'),
|
||||
this.configService.get<string>('GOOGLE_CLIENT_SECRET'),
|
||||
'http://localhost:3000/auth/google/callback',
|
||||
);
|
||||
|
||||
const { tokens } = await oauth2Client.getToken(code);
|
||||
|
||||
this.logger.log('✅ Tokens erfolgreich erhalten!');
|
||||
this.logger.log(`Refresh Token: ${tokens.refresh_token}`);
|
||||
|
||||
return res.send(`
|
||||
<html>
|
||||
<head>
|
||||
<title>Google OAuth Erfolgreich</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
.token {
|
||||
background: #f0f0f0;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
word-break: break-all;
|
||||
font-family: monospace;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.success {
|
||||
color: #22c55e;
|
||||
font-size: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.instructions {
|
||||
background: #fef3c7;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
border-left: 4px solid #f59e0b;
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="success">✅ Erfolgreich authentifiziert!</div>
|
||||
<h2>Dein Refresh Token:</h2>
|
||||
<div class="token">${tokens.refresh_token}</div>
|
||||
|
||||
<div class="instructions">
|
||||
<strong>📝 Nächste Schritte:</strong>
|
||||
<ol>
|
||||
<li>Kopiere den obigen Refresh Token</li>
|
||||
<li>Füge ihn in deine <code>.env</code> Datei ein:</li>
|
||||
<li><code>GOOGLE_REFRESH_TOKEN=${tokens.refresh_token}</code></li>
|
||||
<li>Starte deinen Server neu</li>
|
||||
<li>Du kannst diesen Controller jetzt löschen!</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
} catch (error) {
|
||||
this.logger.error('❌ Fehler beim Token-Austausch:', error);
|
||||
return res.send(`❌ Fehler: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GoogleCalendarService } from './google-calendar.service';
|
||||
|
||||
@Module({
|
||||
providers: [GoogleCalendarService],
|
||||
exports: [GoogleCalendarService],
|
||||
})
|
||||
export class GoogleCalendarModule {}
|
||||
@@ -0,0 +1,170 @@
|
||||
// src/google-calendar/google-calendar.service.ts
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { google } from 'googleapis';
|
||||
import { OAuth2Client } from 'google-auth-library';
|
||||
|
||||
@Injectable()
|
||||
export class GoogleCalendarService {
|
||||
private readonly logger = new Logger(GoogleCalendarService.name);
|
||||
private oauth2Client: OAuth2Client;
|
||||
private calendar;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
this.oauth2Client = new google.auth.OAuth2(
|
||||
this.configService.get<string>('GOOGLE_CLIENT_ID'),
|
||||
this.configService.get<string>('GOOGLE_CLIENT_SECRET'),
|
||||
this.configService.get<string>('GOOGLE_REDIRECT_URI'),
|
||||
);
|
||||
|
||||
this.oauth2Client.setCredentials({
|
||||
refresh_token: this.configService.get<string>('GOOGLE_REFRESH_TOKEN'),
|
||||
});
|
||||
|
||||
this.calendar = google.calendar({ version: 'v3', auth: this.oauth2Client });
|
||||
}
|
||||
|
||||
// google-calendar.service.ts
|
||||
async createMeeting(data: {
|
||||
summary: string;
|
||||
description?: string;
|
||||
startDateTime: string;
|
||||
endDateTime: string;
|
||||
attendees: string[];
|
||||
}): Promise<{
|
||||
eventId: string;
|
||||
meetLink: string;
|
||||
htmlLink: string;
|
||||
}> {
|
||||
try {
|
||||
this.logger.log('🔄 Versuche Google Meet zu erstellen...');
|
||||
this.logger.log(` Summary: ${data.summary}`);
|
||||
this.logger.log(` Start: ${data.startDateTime}`);
|
||||
this.logger.log(` End: ${data.endDateTime}`);
|
||||
this.logger.log(` Attendees: ${JSON.stringify(data.attendees)}`);
|
||||
|
||||
const event = {
|
||||
summary: data.summary,
|
||||
description: data.description || 'Beratungsgespräch',
|
||||
start: {
|
||||
dateTime: data.startDateTime,
|
||||
timeZone: 'Europe/Berlin',
|
||||
},
|
||||
end: {
|
||||
dateTime: data.endDateTime,
|
||||
timeZone: 'Europe/Berlin',
|
||||
},
|
||||
attendees: data.attendees.map(email => ({ email })),
|
||||
conferenceData: {
|
||||
createRequest: {
|
||||
requestId: `meet-${Date.now()}`,
|
||||
conferenceSolutionKey: { type: 'hangoutsMeet' },
|
||||
},
|
||||
},
|
||||
reminders: {
|
||||
useDefault: false,
|
||||
overrides: [
|
||||
{ method: 'email', minutes: 24 * 60 },
|
||||
{ method: 'popup', minutes: 30 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
this.logger.log('📤 Sende Event an Google Calendar...');
|
||||
this.logger.log(` Event: ${JSON.stringify(event, null, 2)}`);
|
||||
|
||||
const response = await this.calendar.events.insert({
|
||||
calendarId: 'primary',
|
||||
requestBody: event,
|
||||
conferenceDataVersion: 1,
|
||||
sendUpdates: 'all',
|
||||
});
|
||||
|
||||
const meetLink = response.data.conferenceData?.entryPoints?.[0]?.uri || '';
|
||||
|
||||
this.logger.log(`✅ Google Meet erstellt: ${meetLink}`);
|
||||
|
||||
return {
|
||||
eventId: response.data.id,
|
||||
meetLink,
|
||||
htmlLink: response.data.htmlLink,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('❌ Fehler beim Erstellen des Google Meets:');
|
||||
this.logger.error(` Message: ${error.message}`);
|
||||
this.logger.error(` Status: ${error.code}`);
|
||||
this.logger.error(` Response: ${JSON.stringify(error.response?.data, null, 2)}`);
|
||||
this.logger.error(` Full Error: ${JSON.stringify(error, null, 2)}`);
|
||||
throw new Error(`Google Meet konnte nicht erstellt werden: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async updateMeeting(eventId: string, data: {
|
||||
summary?: string;
|
||||
description?: string;
|
||||
startDateTime?: string;
|
||||
endDateTime?: string;
|
||||
attendees?: string[];
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const event: any = {};
|
||||
|
||||
if (data.summary) event.summary = data.summary;
|
||||
if (data.description) event.description = data.description;
|
||||
if (data.startDateTime) {
|
||||
event.start = {
|
||||
dateTime: data.startDateTime,
|
||||
timeZone: 'Europe/Berlin',
|
||||
};
|
||||
}
|
||||
if (data.endDateTime) {
|
||||
event.end = {
|
||||
dateTime: data.endDateTime,
|
||||
timeZone: 'Europe/Berlin',
|
||||
};
|
||||
}
|
||||
if (data.attendees) {
|
||||
event.attendees = data.attendees.map(email => ({ email }));
|
||||
}
|
||||
|
||||
await this.calendar.events.patch({
|
||||
calendarId: 'primary',
|
||||
eventId,
|
||||
requestBody: event,
|
||||
sendUpdates: 'all',
|
||||
});
|
||||
|
||||
this.logger.log(`✅ Google Meet aktualisiert: ${eventId}`);
|
||||
} catch (error) {
|
||||
this.logger.error('❌ Fehler beim Aktualisieren des Google Meets:', error);
|
||||
throw new Error(`Google Meet konnte nicht aktualisiert werden: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMeeting(eventId: string): Promise<void> {
|
||||
try {
|
||||
await this.calendar.events.delete({
|
||||
calendarId: 'primary',
|
||||
eventId,
|
||||
sendUpdates: 'all',
|
||||
});
|
||||
this.logger.log(`🗑️ Google Meet gelöscht: ${eventId}`);
|
||||
} catch (error) {
|
||||
this.logger.error('❌ Fehler beim Löschen des Google Meets:', error);
|
||||
// Nicht werfen, da Meeting vielleicht schon gelöscht wurde
|
||||
}
|
||||
}
|
||||
|
||||
async getMeeting(eventId: string): Promise<any> {
|
||||
try {
|
||||
const response = await this.calendar.events.get({
|
||||
calendarId: 'primary',
|
||||
eventId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.logger.error('❌ Fehler beim Abrufen des Google Meets:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Controller, Get, Post, Body, Param, Patch, Delete, UseGuards, HttpCode, HttpStatus } from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { ContactRequestsService } from './contact-requests.service';
|
||||
import { CreateContactRequestDto, UpdateContactRequestDto } from './contact-requests.dto';
|
||||
import { AdminGuard } from 'src/auth/guards/admin.guard';
|
||||
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';
|
||||
|
||||
@Controller('contact-requests')
|
||||
@Throttle({ default: { limit: 30, ttl: 60000 } }) // 🛡️ Basis: 30 Requests/Minute für alle Endpoints
|
||||
export class ContactRequestsController {
|
||||
constructor(private readonly contactService: ContactRequestsService) {}
|
||||
|
||||
// 🛡️ Öffentlich - STRENG: 5 Anfragen pro Stunde pro IP (Spam-Schutz)
|
||||
@Throttle({ default: { limit: 5, ttl: 3600000 } })
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async create(@Body() dto: CreateContactRequestDto) {
|
||||
return this.contactService.create(dto);
|
||||
}
|
||||
|
||||
// NUR ADMIN
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Get()
|
||||
async findAll() {
|
||||
return this.contactService.findAll();
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Get('unprocessed')
|
||||
async findUnprocessed() {
|
||||
return this.contactService.findUnprocessed();
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string) {
|
||||
return this.contactService.findOne(id);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Patch(':id')
|
||||
async update(@Param('id') id: string, @Body() dto: UpdateContactRequestDto) {
|
||||
return this.contactService.update(id, dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Patch(':id/process')
|
||||
async markAsProcessed(@Param('id') id: string) {
|
||||
return this.contactService.markAsProcessed(id);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async delete(@Param('id') id: string) {
|
||||
return this.contactService.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { IsEmail, IsString, IsBoolean, IsOptional, MinLength, MaxLength } from 'class-validator';
|
||||
|
||||
export class CreateContactRequestDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(100)
|
||||
name: string;
|
||||
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(100)
|
||||
serviceType: string; // Slug aus dem Services-Katalog
|
||||
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
message: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
prefersCallback?: boolean;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
phoneNumber?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
userId?: string; // Falls der User eingeloggt ist
|
||||
}
|
||||
|
||||
export class UpdateContactRequestDto {
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isProcessed?: boolean;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
notes?: string;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { User } from 'src/users/users.entity';
|
||||
import {
|
||||
Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne,
|
||||
JoinColumn, Index
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('contact_requests')
|
||||
export class ContactRequest {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
name: string;
|
||||
|
||||
// In PG per Migration auf CITEXT umstellen (case-insensitive Unique/Filter möglich)
|
||||
@Column()
|
||||
email: string;
|
||||
|
||||
// Service-Slug aus dem Services-Katalog (dynamisch)
|
||||
@Column({ default: 'allgemeine-anfrage' })
|
||||
serviceType: string;
|
||||
|
||||
@Column('text')
|
||||
message: string;
|
||||
|
||||
@Index()
|
||||
@Column({ default: false })
|
||||
prefersCallback: boolean;
|
||||
|
||||
@Column({ nullable: true })
|
||||
phoneNumber: string | null;
|
||||
|
||||
@Index()
|
||||
@Column({ default: false })
|
||||
isProcessed: boolean;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes: string | null;
|
||||
|
||||
@Column('uuid', { nullable: true })
|
||||
userId: string | null;
|
||||
|
||||
@ManyToOne(() => User, user => user.contactRequests, {
|
||||
nullable: true,
|
||||
onDelete: 'SET NULL',
|
||||
})
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: User | null;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz', default: () => 'now()' })
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ContactRequestsService } from './contact-requests.service';
|
||||
import { ContactRequestsController } from './contact-requests.controller';
|
||||
import { ContactRequest } from './contact-requests.entity';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { EmailModule } from 'src/email/email.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ContactRequest]),
|
||||
EmailModule
|
||||
],
|
||||
providers: [ContactRequestsService],
|
||||
exports: [ContactRequestsService],
|
||||
controllers: [ContactRequestsController],
|
||||
})
|
||||
export class ContactRequestsModule {}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { CreateContactRequestDto, UpdateContactRequestDto } from './contact-requests.dto';
|
||||
import { ContactRequest } from './contact-requests.entity';
|
||||
import { EmailService } from 'src/email/email.service';
|
||||
|
||||
@Injectable()
|
||||
export class ContactRequestsService {
|
||||
constructor(
|
||||
@InjectRepository(ContactRequest)
|
||||
private readonly contactRepo: Repository<ContactRequest>,
|
||||
private readonly emailService: EmailService
|
||||
) { }
|
||||
|
||||
async create(dto: CreateContactRequestDto): Promise<ContactRequest> {
|
||||
const request = this.contactRepo.create(dto);
|
||||
const saved = await this.contactRepo.save(request);
|
||||
|
||||
// Danke-Email an den User schicken
|
||||
await this.emailService.sendContactRequestConfirmation({
|
||||
userEmail: dto.email,
|
||||
userName: dto.name,
|
||||
serviceType: dto.serviceType,
|
||||
});
|
||||
|
||||
// Admin-Email an den Admin schicken
|
||||
await this.emailService.sendContactRequestConfirmationAdmin({
|
||||
userEmail: dto.email,
|
||||
userName: dto.name,
|
||||
serviceType: dto.serviceType,
|
||||
message: dto.message,
|
||||
phoneNumber: dto.phoneNumber,
|
||||
prefersCallback: dto.prefersCallback,
|
||||
});
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
async findAll(): Promise<ContactRequest[]> {
|
||||
return this.contactRepo.find({
|
||||
order: { createdAt: 'DESC' },
|
||||
relations: ['user'],
|
||||
});
|
||||
}
|
||||
|
||||
async findUnprocessed(): Promise<ContactRequest[]> {
|
||||
return this.contactRepo.find({
|
||||
where: { isProcessed: false },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<ContactRequest> {
|
||||
const request = await this.contactRepo.findOne({
|
||||
where: { id },
|
||||
relations: ['user'],
|
||||
});
|
||||
|
||||
if (!request) {
|
||||
throw new NotFoundException(`Contact request with ID ${id} not found`);
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateContactRequestDto): Promise<ContactRequest> {
|
||||
const request = await this.findOne(id);
|
||||
Object.assign(request, dto);
|
||||
return this.contactRepo.save(request);
|
||||
}
|
||||
|
||||
async markAsProcessed(id: string): Promise<ContactRequest> {
|
||||
return this.update(id, { isProcessed: true });
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const result = await this.contactRepo.delete(id);
|
||||
if (result.affected === 0) {
|
||||
throw new NotFoundException(`Contact request with ID ${id} not found`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { EmailService } from './email.service';
|
||||
|
||||
@Module({
|
||||
providers: [EmailService],
|
||||
exports: [EmailService],
|
||||
|
||||
})
|
||||
export class EmailModule {}
|
||||
@@ -0,0 +1,452 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import emailjs from '@emailjs/nodejs';
|
||||
|
||||
// Toggle hier: true = wirklich senden, false = nur console.log
|
||||
const SEND_REAL_EMAILS = true;
|
||||
|
||||
interface EmailParams extends Record<string, unknown> {
|
||||
to_email: string;
|
||||
subject: string;
|
||||
company_name: string;
|
||||
greeting: string;
|
||||
customer_name: string;
|
||||
message: string;
|
||||
highlight_message?: string;
|
||||
button_url?: string;
|
||||
button_text?: string;
|
||||
company_email: string;
|
||||
company_website: string;
|
||||
footer_note?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EmailService {
|
||||
private readonly logger = new Logger(EmailService.name);
|
||||
private readonly serviceId: string;
|
||||
private readonly templateId: string;
|
||||
private readonly publicKey: string;
|
||||
private readonly privateKey: string;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
this.serviceId = this.configService.get<string>('EMAILJS_SERVICE_ID');
|
||||
this.templateId = this.configService.get<string>('EMAILJS_TEMPLATE_ID');
|
||||
this.publicKey = this.configService.get<string>('EMAILJS_PUBLIC_KEY');
|
||||
this.privateKey = this.configService.get<string>('EMAILJS_PRIVATE_KEY');
|
||||
|
||||
if (!SEND_REAL_EMAILS) {
|
||||
this.logger.warn('⚠️ EMAIL SERVICE IM MOCK MODE - Emails werden nur geloggt!');
|
||||
}
|
||||
}
|
||||
|
||||
formatTimeFromHHMMSStoHHMM(time: string): string {
|
||||
const [hours, minutes] = time.split(':');
|
||||
return `${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
async sendEmail(params: EmailParams): Promise<void> {
|
||||
if (!SEND_REAL_EMAILS) {
|
||||
// Nur Console-Logging
|
||||
this.logger.log('📧 [MOCK] Email würde gesendet werden:');
|
||||
this.logger.log(` An: ${params.to_email}`);
|
||||
this.logger.log(` Betreff: ${params.subject}`);
|
||||
this.logger.log(` Kunde: ${params.customer_name}`);
|
||||
this.logger.log(` Firma: ${params.company_name}`);
|
||||
this.logger.log(` Nachricht: ${params.message}`);
|
||||
if (params.highlight_message) {
|
||||
this.logger.log(` Highlight: ${params.highlight_message}`);
|
||||
}
|
||||
if (params.button_url) {
|
||||
this.logger.log(` Button: ${params.button_text} -> ${params.button_url}`);
|
||||
}
|
||||
this.logger.log(` Template: ${this.templateId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Echtes Senden via EmailJS
|
||||
try {
|
||||
const response = await emailjs.send(
|
||||
this.serviceId,
|
||||
this.templateId,
|
||||
params,
|
||||
{
|
||||
publicKey: this.publicKey,
|
||||
privateKey: this.privateKey,
|
||||
}
|
||||
);
|
||||
|
||||
this.logger.log(`✅ Email erfolgreich gesendet: ${response.status} ${response.text}`);
|
||||
} catch (error) {
|
||||
this.logger.error('❌ Fehler beim Email-Versand:', error);
|
||||
throw new Error(`Email konnte nicht gesendet werden: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async sendContactRequestConfirmation(data: {
|
||||
userEmail: string;
|
||||
userName: string;
|
||||
serviceType: string;
|
||||
}): Promise<void> {
|
||||
if (!SEND_REAL_EMAILS) {
|
||||
this.logger.log('Email would be sent to: ' + JSON.stringify(data));
|
||||
return;
|
||||
}
|
||||
const emailParams: EmailParams = {
|
||||
to_email: data.userEmail,
|
||||
subject: 'Vielen Dank für Ihre Anfrage!',
|
||||
company_name: this.configService.get<string>('COMPANY_NAME', 'LeonardsMedia'),
|
||||
greeting: 'Hallo',
|
||||
customer_name: data.userName,
|
||||
message: `Vielen Dank für Ihre Anfrage!
|
||||
|
||||
Wir haben Ihre Nachricht erhalten und freuen uns über Ihr Interesse. Unser Team wird Ihre Anfrage prüfen und sich schnellstmöglich bei Ihnen melden.
|
||||
|
||||
In der Zwischenzeit können Sie gerne unsere Website besuchen oder uns direkt kontaktieren, falls Sie weitere Fragen haben.`,
|
||||
highlight_message: '🎉 Wir melden uns innerhalb von 24 Stunden bei Ihnen!',
|
||||
button_url: this.configService.get<string>('COMPANY_WEBSITE'),
|
||||
button_text: 'Zur Website',
|
||||
company_email: this.configService.get<string>('COMPANY_EMAIL'),
|
||||
company_website: this.configService.get<string>('COMPANY_WEBSITE'),
|
||||
footer_note: 'Bei Rückfragen stehen wir Ihnen jederzeit zur Verfügung.',
|
||||
};
|
||||
|
||||
await this.sendEmail(emailParams);
|
||||
}
|
||||
|
||||
async sendContactRequestConfirmationAdmin(data: {
|
||||
userEmail: string;
|
||||
userName: string;
|
||||
serviceType: string;
|
||||
message: string;
|
||||
phoneNumber?: string;
|
||||
prefersCallback: boolean;
|
||||
}): Promise<void> {
|
||||
if (!SEND_REAL_EMAILS) {
|
||||
this.logger.log('📧 [MOCK] Admin Contact Request Email würde gesendet werden:');
|
||||
this.logger.log(` Kunde: ${data.userName} (${data.userEmail})`);
|
||||
this.logger.log(` Service: ${data.serviceType}`);
|
||||
this.logger.log(` Rückruf: ${data.prefersCallback ? 'Ja' : 'Nein'}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const adminEmail = this.configService.get<string>('ADMIN_EMAIL');
|
||||
|
||||
const emailParams: EmailParams = {
|
||||
to_email: adminEmail,
|
||||
subject: '🔔 Neue Kontaktanfrage',
|
||||
company_name: this.configService.get<string>('COMPANY_NAME', 'LeonardsMedia'),
|
||||
greeting: 'Hey',
|
||||
customer_name: 'Admin',
|
||||
message: data.message,
|
||||
highlight_message: '📋 Neue Anfrage eingegangen',
|
||||
button_url: `mailto:${data.userEmail}`,
|
||||
button_text: '📧 Kunde antworten',
|
||||
company_email: this.configService.get<string>('COMPANY_EMAIL'),
|
||||
company_website: this.configService.get<string>('COMPANY_WEBSITE'),
|
||||
footer_note: data.prefersCallback && data.phoneNumber
|
||||
? `⚠️ Kunde bevorzugt Rückruf: ${data.phoneNumber}`
|
||||
: undefined,
|
||||
};
|
||||
|
||||
await this.sendEmail(emailParams);
|
||||
}
|
||||
|
||||
async sendWebsiteGenerationComplete(data: {
|
||||
userEmail: string;
|
||||
userName: string;
|
||||
projectName: string;
|
||||
websiteId: string;
|
||||
typeOfWebsite: string;
|
||||
}): Promise<void> {
|
||||
|
||||
if (!SEND_REAL_EMAILS) {
|
||||
this.logger.log('Email would be sent to: ' + JSON.stringify(data));
|
||||
return;
|
||||
}
|
||||
const previewUrl = `${this.configService.get<string>('FRONTEND_URL')}/preview/${data.websiteId}`;
|
||||
|
||||
|
||||
const websiteTypeNames: Record<string, string> = {
|
||||
'praesentation': 'Präsentations-Website',
|
||||
'landing': 'Landing Page',
|
||||
'event': 'Event-Website'
|
||||
};
|
||||
|
||||
const websiteTypeName = websiteTypeNames[data.typeOfWebsite] || 'Website';
|
||||
|
||||
const emailParams: EmailParams = {
|
||||
to_email: data.userEmail,
|
||||
subject: `🎉 Ihre Website "${data.projectName}" ist fertig!`,
|
||||
company_name: this.configService.get<string>('COMPANY_NAME', 'LeonardsMedia'),
|
||||
greeting: 'Hallo',
|
||||
customer_name: data.userName,
|
||||
message: `Großartige Neuigkeiten! Ihre ${websiteTypeName} "${data.projectName}" wurde erfolgreich generiert und steht nun zur Vorschau bereit.
|
||||
|
||||
Sie können Ihre neue Website jetzt ansehen, testen und bei Bedarf Anpassungen vornehmen. Klicken Sie einfach auf den Button unten, um direkt zur Vorschau zu gelangen.
|
||||
|
||||
Wir hoffen, dass das Ergebnis Ihren Erwartungen entspricht. Bei Fragen oder Änderungswünschen stehen wir Ihnen gerne zur Verfügung.`,
|
||||
highlight_message: '✨ Ihre Website ist jetzt online und bereit zur Ansicht!',
|
||||
button_url: previewUrl,
|
||||
button_text: 'Website-Vorschau öffnen',
|
||||
company_email: this.configService.get<string>('COMPANY_EMAIL'),
|
||||
company_website: this.configService.get<string>('COMPANY_WEBSITE'),
|
||||
footer_note: 'Sie können die Vorschau jederzeit über Ihr Dashboard aufrufen.',
|
||||
};
|
||||
|
||||
await this.sendEmail(emailParams);
|
||||
}
|
||||
|
||||
async sendWebsiteReadyEmail(data: {
|
||||
to: string;
|
||||
projectName: string;
|
||||
pageId: string;
|
||||
previewUrl: string;
|
||||
}): Promise<void> {
|
||||
if (!SEND_REAL_EMAILS) {
|
||||
this.logger.log('📧 [MOCK] Website Ready Email würde gesendet werden:');
|
||||
this.logger.log(` An: ${data.to}`);
|
||||
this.logger.log(` Projekt: ${data.projectName}`);
|
||||
this.logger.log(` Page ID: ${data.pageId}`);
|
||||
this.logger.log(` Preview URL: ${data.previewUrl}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const emailParams: EmailParams = {
|
||||
to_email: data.to,
|
||||
subject: `🎉 Deine Website "${data.projectName}" ist fertig!`,
|
||||
company_name: this.configService.get<string>('COMPANY_NAME', 'LeonardsMedia'),
|
||||
greeting: 'Hey',
|
||||
customer_name: '', // optional: aus Email extrahieren
|
||||
message: `Großartige Neuigkeiten! Dein Website-Projekt "${data.projectName}" wurde erfolgreich mit KI generiert und ist jetzt bereit zur Vorschau.
|
||||
|
||||
Die KI hat eine moderne, einzigartige Website basierend auf deinen Vorgaben erstellt. Du kannst sie jetzt ansehen, testen und bei Bedarf weitere Anpassungen vornehmen.
|
||||
|
||||
Klicke einfach auf den Button unten, um direkt zur Vorschau zu gelangen und deine neue Website zu erleben!`,
|
||||
highlight_message: '✨ Deine Website ist fertig und wartet auf dich!',
|
||||
button_url: data.previewUrl,
|
||||
button_text: '🚀 Website jetzt ansehen',
|
||||
company_email: this.configService.get<string>('COMPANY_EMAIL'),
|
||||
company_website: this.configService.get<string>('COMPANY_WEBSITE'),
|
||||
footer_note: 'Du kannst die Vorschau jederzeit über dein Dashboard aufrufen.',
|
||||
};
|
||||
|
||||
await this.sendEmail(emailParams);
|
||||
}
|
||||
|
||||
async sendWebsiteErrorEmail(data: {
|
||||
to: string;
|
||||
projectName: string;
|
||||
error: string;
|
||||
}): Promise<void> {
|
||||
if (!SEND_REAL_EMAILS) {
|
||||
this.logger.log('📧 [MOCK] Website Error Email würde gesendet werden:');
|
||||
this.logger.log(` An: ${data.to}`);
|
||||
this.logger.log(` Projekt: ${data.projectName}`);
|
||||
this.logger.log(` Fehler: ${data.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const retryUrl = `${this.configService.get<string>('FRONTEND_URL')}/preview-form`;
|
||||
|
||||
const emailParams: EmailParams = {
|
||||
to_email: data.to,
|
||||
subject: `⚠️ Problem bei der Erstellung von "${data.projectName}"`,
|
||||
company_name: this.configService.get<string>('COMPANY_NAME', 'LeonardsMedia'),
|
||||
greeting: 'Hallo',
|
||||
customer_name: '',
|
||||
message: `Leider ist bei der Generierung deiner Website "${data.projectName}" ein Problem aufgetreten.
|
||||
|
||||
Fehlerdetails: ${data.error}
|
||||
|
||||
Das kann verschiedene Gründe haben:
|
||||
• Temporäre technische Schwierigkeiten
|
||||
• Ungewöhnliche Eingabedaten
|
||||
• Überlastung des KI-Systems
|
||||
|
||||
Wir empfehlen dir:
|
||||
1. Versuche es in ein paar Minuten erneut
|
||||
2. Überprüfe deine Eingaben
|
||||
3. Kontaktiere uns bei wiederholten Problemen
|
||||
|
||||
Wir entschuldigen uns für die Unannehmlichkeiten und helfen dir gerne weiter!`,
|
||||
highlight_message: '🔧 Keine Sorge – versuch es einfach nochmal oder kontaktiere uns!',
|
||||
button_url: retryUrl,
|
||||
button_text: 'Erneut versuchen',
|
||||
company_email: this.configService.get<string>('COMPANY_EMAIL'),
|
||||
company_website: this.configService.get<string>('COMPANY_WEBSITE'),
|
||||
footer_note: 'Bei Fragen stehen wir dir jederzeit zur Verfügung.',
|
||||
};
|
||||
|
||||
await this.sendEmail(emailParams);
|
||||
}
|
||||
|
||||
async sendBookingConfirmation(data: {
|
||||
to: string;
|
||||
customerName: string;
|
||||
date: string;
|
||||
timeFrom: string;
|
||||
timeTo: string;
|
||||
bookingId: string;
|
||||
meetLink?: string;
|
||||
}): Promise<void> {
|
||||
if (!SEND_REAL_EMAILS) {
|
||||
this.logger.log('📧 [MOCK] Booking Confirmation würde gesendet werden:');
|
||||
this.logger.log(` An: ${data.to}`);
|
||||
this.logger.log(` Meet Link: ${data.meetLink}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const formattedDate = new Date(data.date).toLocaleDateString('de-DE', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
const emailParams: EmailParams = {
|
||||
to_email: data.to,
|
||||
subject: '✅ Dein Termin wurde gebucht!',
|
||||
company_name: this.configService.get<string>('COMPANY_NAME', 'LeonardsMedia'),
|
||||
greeting: 'Hallo',
|
||||
customer_name: data.customerName,
|
||||
message: `Perfekt! Dein Termin wurde erfolgreich gebucht.
|
||||
|
||||
Hier sind deine Termin-Details:
|
||||
📅 ${formattedDate}
|
||||
🕐 ${this.formatTimeFromHHMMSStoHHMM(data.timeFrom)} - ${this.formatTimeFromHHMMSStoHHMM(data.timeTo)} Uhr
|
||||
💻 Online per Google Meet`,
|
||||
highlight_message: '🎉 Wir freuen uns auf das Gespräch mit dir!',
|
||||
button_url: data.meetLink || `${this.configService.get<string>('FRONTEND_URL')}/booking/${data.bookingId}`,
|
||||
button_text: data.meetLink ? '🎥 Zum Google Meet' : 'Termin-Details ansehen',
|
||||
company_email: this.configService.get<string>('COMPANY_EMAIL'),
|
||||
company_website: this.configService.get<string>('COMPANY_WEBSITE'),
|
||||
footer_note: 'Falls du den Termin absagen musst, kontaktiere uns bitte rechtzeitig.',
|
||||
};
|
||||
|
||||
await this.sendEmail(emailParams);
|
||||
}
|
||||
|
||||
async sendBookingNotificationToAdmin(data: {
|
||||
to: string;
|
||||
customerName: string;
|
||||
customerEmail: string;
|
||||
customerPhone: string | null;
|
||||
message: string | null;
|
||||
date: string;
|
||||
timeFrom: string;
|
||||
timeTo: string;
|
||||
bookingId: string;
|
||||
meetLink?: string;
|
||||
}): Promise<void> {
|
||||
if (!SEND_REAL_EMAILS) {
|
||||
this.logger.log('📧 [MOCK] Admin Booking Notification würde gesendet werden:');
|
||||
this.logger.log(` An: ${data.to}`);
|
||||
this.logger.log(` Kunde: ${data.customerName} (${data.customerEmail})`);
|
||||
this.logger.log(` Termin: ${data.date} ${data.timeFrom}-${data.timeTo}`);
|
||||
this.logger.log(` Meet Link: ${data.meetLink || 'Nicht vorhanden'}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const formattedDate = new Date(data.date).toLocaleDateString('de-DE', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
const emailParams: EmailParams = {
|
||||
to_email: data.to,
|
||||
subject: '🔔 Neue Termin-Buchung',
|
||||
company_name: this.configService.get<string>('COMPANY_NAME', 'LeonardsMedia'),
|
||||
greeting: 'Hey',
|
||||
customer_name: 'Admin',
|
||||
message: `Es gibt eine neue Termin-Buchung!
|
||||
|
||||
Kunden-Details:
|
||||
👤 Name: ${data.customerName}
|
||||
📧 Email: ${data.customerEmail}
|
||||
📞 Telefon: ${data.customerPhone || 'Nicht angegeben'}
|
||||
|
||||
Termin:
|
||||
📅 ${formattedDate}
|
||||
🕐 ${this.formatTimeFromHHMMSStoHHMM(data.timeFrom)} - ${this.formatTimeFromHHMMSStoHHMM(data.timeTo)} Uhr
|
||||
${data.meetLink ? `🔗 Google Meet: ${data.meetLink}` : '⚠️ Kein Meet-Link verfügbar'}
|
||||
|
||||
Nachricht vom Kunden:
|
||||
${data.message || 'Keine Nachricht hinterlassen'}`,
|
||||
highlight_message: '📋 Neue Buchung eingegangen',
|
||||
button_url: data.meetLink || `${this.configService.get<string>('FRONTEND_URL')}/admin/bookings/${data.bookingId}`,
|
||||
button_text: data.meetLink ? '🎥 Zum Google Meet' : 'Booking verwalten',
|
||||
company_email: this.configService.get<string>('COMPANY_EMAIL'),
|
||||
company_website: this.configService.get<string>('COMPANY_WEBSITE'),
|
||||
footer_note: data.meetLink ? 'Der Meeting-Link wurde auch an den Kunden gesendet.' : undefined,
|
||||
};
|
||||
|
||||
await this.sendEmail(emailParams);
|
||||
}
|
||||
|
||||
// Newsletter abbestellen (PUBLIC)
|
||||
// DELETE http://localhost:3000/newsletter/unsubscribe?email=user@example.com
|
||||
async sendNewsletterWelcome(data: {
|
||||
to: string;
|
||||
}): Promise<void> {
|
||||
if (!SEND_REAL_EMAILS) {
|
||||
this.logger.log('📧 [MOCK] Newsletter Welcome Email würde gesendet werden:');
|
||||
this.logger.log(` An: ${data.to}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const emailParams: EmailParams = {
|
||||
to_email: data.to,
|
||||
subject: '✅ Anmeldung bestätigt',
|
||||
company_name: this.configService.get<string>('COMPANY_NAME', 'LeonardsMedia'),
|
||||
greeting: 'Hallo',
|
||||
customer_name: '',
|
||||
message: `Danke für deine Anmeldung!
|
||||
|
||||
Du erhältst ab sofort Updates zu neuen Projekten, Features und Angeboten.
|
||||
|
||||
Wir melden uns bald mit spannenden News!`,
|
||||
highlight_message: '📬 Du bist jetzt dabei!',
|
||||
button_url: this.configService.get<string>('COMPANY_WEBSITE'),
|
||||
button_text: 'Zur Website',
|
||||
company_email: this.configService.get<string>('COMPANY_EMAIL'),
|
||||
company_website: this.configService.get<string>('COMPANY_WEBSITE'),
|
||||
footer_note: 'Du kannst dich jederzeit wieder abmelden.',
|
||||
};
|
||||
|
||||
await this.sendEmail(emailParams);
|
||||
}
|
||||
|
||||
async sendNewsletterUnsubscribe(data: {
|
||||
to: string;
|
||||
}): Promise<void> {
|
||||
if (!SEND_REAL_EMAILS) {
|
||||
this.logger.log('📧 [MOCK] Newsletter Unsubscribe Email würde gesendet werden:');
|
||||
this.logger.log(` An: ${data.to}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const emailParams: EmailParams = {
|
||||
to_email: data.to,
|
||||
subject: '👋 Abmeldung bestätigt',
|
||||
company_name: this.configService.get<string>('COMPANY_NAME', 'LeonardsMedia'),
|
||||
greeting: 'Hallo',
|
||||
customer_name: '',
|
||||
message: `Du wurdest erfolgreich von unseren Updates abgemeldet.
|
||||
|
||||
Schade, dass du gehst! Falls du deine Meinung änderst, kannst du dich jederzeit wieder anmelden.
|
||||
|
||||
Wir wünschen dir alles Gute!`,
|
||||
highlight_message: '✓ Abmeldung erfolgreich',
|
||||
button_url: this.configService.get<string>('COMPANY_WEBSITE'),
|
||||
button_text: 'Zur Website',
|
||||
company_email: this.configService.get<string>('COMPANY_EMAIL'),
|
||||
company_website: this.configService.get<string>('COMPANY_WEBSITE'),
|
||||
footer_note: 'Du erhältst keine weiteren E-Mails von uns.',
|
||||
};
|
||||
|
||||
await this.sendEmail(emailParams);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
Controller, Get, Post, Body, Param, Patch, Delete,
|
||||
UseGuards, HttpCode, HttpStatus
|
||||
} from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { FaqService } from './faq.service';
|
||||
import { CreateFaqDto, UpdateFaqDto, BulkImportFaqDto } from './faq.dto';
|
||||
import { AdminGuard } from 'src/auth/guards/admin.guard';
|
||||
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';
|
||||
|
||||
@Controller('faq')
|
||||
@Throttle({ default: { limit: 60, ttl: 60000 } }) // 🛡️ Basis: 60 Requests/Minute (Read-heavy)
|
||||
export class FaqController {
|
||||
constructor(private readonly faqService: FaqService) { }
|
||||
|
||||
// ===== PUBLIC ENDPOINTS =====
|
||||
|
||||
/**
|
||||
* Alle veröffentlichten FAQs abrufen (für öffentliche Seite)
|
||||
*/
|
||||
@Get()
|
||||
async findAllPublished() {
|
||||
return this.faqService.findAllPublished();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein FAQ per Slug abrufen (öffentlich)
|
||||
*/
|
||||
@Get('slug/:slug')
|
||||
async findBySlug(@Param('slug') slug: string) {
|
||||
return this.faqService.findBySlugPublic(slug);
|
||||
}
|
||||
|
||||
// ===== ADMIN ENDPOINTS =====
|
||||
|
||||
/**
|
||||
* Alle FAQs abrufen (inkl. unpublished) - Admin only
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Get('admin/all')
|
||||
async findAllAdmin() {
|
||||
return this.faqService.findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Export aller FAQs als JSON - Admin only
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Get('admin/export')
|
||||
async exportAll() {
|
||||
return this.faqService.exportAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-Import von FAQs (JSON) - Admin only
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Post('admin/import')
|
||||
async bulkImport(@Body() dto: BulkImportFaqDto) {
|
||||
return this.faqService.bulkImport(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sortierung aktualisieren - Admin only
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Patch('admin/sort-order')
|
||||
async updateSortOrder(@Body() items: { id: string; sortOrder: number }[]) {
|
||||
await this.faqService.updateSortOrder(items);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Neues FAQ erstellen - Admin only
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Post('admin')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async create(@Body() dto: CreateFaqDto) {
|
||||
return this.faqService.create(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein FAQ per ID abrufen - Admin only
|
||||
* WICHTIG: Diese Route muss NACH spezifischen Routen wie /export, /import, /all kommen!
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Get('admin/:id')
|
||||
async findOneAdmin(@Param('id') id: string) {
|
||||
return this.faqService.findOne(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* FAQ aktualisieren - Admin only
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Patch('admin/:id')
|
||||
async update(@Param('id') id: string, @Body() dto: UpdateFaqDto) {
|
||||
return this.faqService.update(id, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* FAQ löschen - Admin only
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Delete('admin/:id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async delete(@Param('id') id: string) {
|
||||
return this.faqService.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish-Status togglen - Admin only
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Patch('admin/:id/toggle-publish')
|
||||
async togglePublish(@Param('id') id: string) {
|
||||
return this.faqService.togglePublish(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { IsString, IsArray, IsBoolean, IsOptional, IsNumber, MinLength, ArrayMinSize } from 'class-validator';
|
||||
|
||||
export class CreateFaqDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
slug: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(5)
|
||||
question: string;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsString({ each: true })
|
||||
answers: string[];
|
||||
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
@IsString({ each: true })
|
||||
listItems?: string[];
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
sortOrder?: number;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isPublished?: boolean;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export class UpdateFaqDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@IsOptional()
|
||||
slug?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(5)
|
||||
@IsOptional()
|
||||
question?: string;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
answers?: string[];
|
||||
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
@IsString({ each: true })
|
||||
listItems?: string[];
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
sortOrder?: number;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isPublished?: boolean;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
category?: string;
|
||||
}
|
||||
|
||||
// DTO für JSON-Import (Array von FAQs)
|
||||
export class ImportFaqDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
slug: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(5)
|
||||
question: string;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsString({ each: true })
|
||||
answers: string[];
|
||||
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
@IsString({ each: true })
|
||||
listItems?: string[];
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
sortOrder?: number;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isPublished?: boolean;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export class BulkImportFaqDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
faqs: ImportFaqDto[];
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
overwriteExisting?: boolean;
|
||||
}
|
||||
|
||||
// Response für Import-Ergebnis
|
||||
export class ImportResultDto {
|
||||
imported: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
errors: string[];
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('faqs')
|
||||
export class Faq {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Index()
|
||||
@Column({ unique: true })
|
||||
slug: string;
|
||||
|
||||
@Column()
|
||||
question: string;
|
||||
|
||||
@Column('text', { array: true })
|
||||
answers: string[];
|
||||
|
||||
@Column('text', { array: true, nullable: true })
|
||||
listItems: string[] | null;
|
||||
|
||||
@Index()
|
||||
@Column({ default: 0 })
|
||||
sortOrder: number;
|
||||
|
||||
@Index()
|
||||
@Column({ default: true })
|
||||
isPublished: boolean;
|
||||
|
||||
@Column({ nullable: true })
|
||||
category: string | null;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz', default: () => 'now()' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'timestamptz', default: () => 'now()' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Faq } from './faq.entity';
|
||||
import { FaqService } from './faq.service';
|
||||
import { FaqController } from './faq.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Faq])],
|
||||
controllers: [FaqController],
|
||||
providers: [FaqService],
|
||||
exports: [FaqService],
|
||||
})
|
||||
export class FaqModule { }
|
||||
@@ -0,0 +1,192 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Faq } from './faq.entity';
|
||||
import { CreateFaqDto, UpdateFaqDto, BulkImportFaqDto, ImportResultDto } from './faq.dto';
|
||||
|
||||
@Injectable()
|
||||
export class FaqService {
|
||||
constructor(
|
||||
@InjectRepository(Faq)
|
||||
private readonly faqRepo: Repository<Faq>
|
||||
) { }
|
||||
|
||||
// ===== PUBLIC ENDPOINTS =====
|
||||
|
||||
/**
|
||||
* Gibt alle veröffentlichten FAQs zurück (für öffentliche Seite)
|
||||
*/
|
||||
async findAllPublished(): Promise<Faq[]> {
|
||||
return this.faqRepo.find({
|
||||
where: { isPublished: true },
|
||||
order: { sortOrder: 'ASC', createdAt: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt ein FAQ per Slug zurück (nur wenn published)
|
||||
*/
|
||||
async findBySlugPublic(slug: string): Promise<Faq> {
|
||||
const faq = await this.faqRepo.findOne({
|
||||
where: { slug, isPublished: true },
|
||||
});
|
||||
|
||||
if (!faq) {
|
||||
throw new NotFoundException(`FAQ with slug "${slug}" not found`);
|
||||
}
|
||||
|
||||
return faq;
|
||||
}
|
||||
|
||||
// ===== ADMIN ENDPOINTS =====
|
||||
|
||||
/**
|
||||
* Gibt ALLE FAQs zurück (auch unpublished) - für Admin
|
||||
*/
|
||||
async findAll(): Promise<Faq[]> {
|
||||
return this.faqRepo.find({
|
||||
order: { sortOrder: 'ASC', createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt ein FAQ per ID zurück - für Admin
|
||||
*/
|
||||
async findOne(id: string): Promise<Faq> {
|
||||
const faq = await this.faqRepo.findOne({ where: { id } });
|
||||
|
||||
if (!faq) {
|
||||
throw new NotFoundException(`FAQ with ID "${id}" not found`);
|
||||
}
|
||||
|
||||
return faq;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt ein neues FAQ
|
||||
*/
|
||||
async create(dto: CreateFaqDto): Promise<Faq> {
|
||||
// Check if slug already exists
|
||||
const existing = await this.faqRepo.findOne({ where: { slug: dto.slug } });
|
||||
if (existing) {
|
||||
throw new ConflictException(`FAQ with slug "${dto.slug}" already exists`);
|
||||
}
|
||||
|
||||
const faq = this.faqRepo.create({
|
||||
...dto,
|
||||
sortOrder: dto.sortOrder ?? await this.getNextSortOrder(),
|
||||
});
|
||||
|
||||
return this.faqRepo.save(faq);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert ein FAQ
|
||||
*/
|
||||
async update(id: string, dto: UpdateFaqDto): Promise<Faq> {
|
||||
const faq = await this.findOne(id);
|
||||
|
||||
// Check if new slug conflicts with existing
|
||||
if (dto.slug && dto.slug !== faq.slug) {
|
||||
const existing = await this.faqRepo.findOne({ where: { slug: dto.slug } });
|
||||
if (existing) {
|
||||
throw new ConflictException(`FAQ with slug "${dto.slug}" already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(faq, dto);
|
||||
return this.faqRepo.save(faq);
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht ein FAQ
|
||||
*/
|
||||
async delete(id: string): Promise<void> {
|
||||
const result = await this.faqRepo.delete(id);
|
||||
if (result.affected === 0) {
|
||||
throw new NotFoundException(`FAQ with ID "${id}" not found`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle publish status
|
||||
*/
|
||||
async togglePublish(id: string): Promise<Faq> {
|
||||
const faq = await this.findOne(id);
|
||||
faq.isPublished = !faq.isPublished;
|
||||
return this.faqRepo.save(faq);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sortierung aktualisieren (Batch)
|
||||
*/
|
||||
async updateSortOrder(items: { id: string; sortOrder: number }[]): Promise<void> {
|
||||
await Promise.all(
|
||||
items.map(item =>
|
||||
this.faqRepo.update(item.id, { sortOrder: item.sortOrder })
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON Import - Bulk-Import von FAQs
|
||||
*/
|
||||
async bulkImport(dto: BulkImportFaqDto): Promise<ImportResultDto> {
|
||||
const result: ImportResultDto = {
|
||||
imported: 0,
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
for (const faqData of dto.faqs) {
|
||||
try {
|
||||
const existing = await this.faqRepo.findOne({ where: { slug: faqData.slug } });
|
||||
|
||||
if (existing) {
|
||||
if (dto.overwriteExisting) {
|
||||
// Update existing
|
||||
Object.assign(existing, faqData);
|
||||
await this.faqRepo.save(existing);
|
||||
result.updated++;
|
||||
} else {
|
||||
result.skipped++;
|
||||
}
|
||||
} else {
|
||||
// Create new
|
||||
const faq = this.faqRepo.create({
|
||||
...faqData,
|
||||
sortOrder: faqData.sortOrder ?? await this.getNextSortOrder(),
|
||||
isPublished: faqData.isPublished ?? true,
|
||||
});
|
||||
await this.faqRepo.save(faq);
|
||||
result.imported++;
|
||||
}
|
||||
} catch (error) {
|
||||
result.errors.push(`Failed to import FAQ "${faqData.slug}": ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export aller FAQs als JSON
|
||||
*/
|
||||
async exportAll(): Promise<Faq[]> {
|
||||
return this.faqRepo.find({
|
||||
order: { sortOrder: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
// ===== HELPER =====
|
||||
|
||||
private async getNextSortOrder(): Promise<number> {
|
||||
const result = await this.faqRepo
|
||||
.createQueryBuilder('faq')
|
||||
.select('MAX(faq.sortOrder)', 'max')
|
||||
.getRawOne();
|
||||
|
||||
return (result?.max ?? 0) + 10;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Injectable, ExecutionContext, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { ThrottlerGuard, ThrottlerLimitDetail } from '@nestjs/throttler';
|
||||
import { Response } from 'express';
|
||||
|
||||
/**
|
||||
* Custom ThrottlerGuard der:
|
||||
* 1. Die echte Client-IP hinter Proxies/Load Balancers erkennt
|
||||
* 2. Bessere Fehlermeldungen liefert
|
||||
* 3. Retry-After Header setzt
|
||||
*/
|
||||
@Injectable()
|
||||
export class ThrottlerBehindProxyGuard extends ThrottlerGuard {
|
||||
/**
|
||||
* Ermittelt die echte Client-IP (auch hinter Reverse Proxies)
|
||||
*/
|
||||
protected async getTracker(req: Record<string, any>): Promise<string> {
|
||||
// X-Forwarded-For Header prüfen (von Reverse Proxies wie nginx)
|
||||
const forwardedFor = req.headers?.['x-forwarded-for'];
|
||||
if (forwardedFor) {
|
||||
// Kann mehrere IPs enthalten, die erste ist die echte Client-IP
|
||||
const ips = forwardedFor.split(',').map((ip: string) => ip.trim());
|
||||
return ips[0];
|
||||
}
|
||||
|
||||
// X-Real-IP Header (alternative Variante)
|
||||
const realIp = req.headers?.['x-real-ip'];
|
||||
if (realIp) {
|
||||
return realIp;
|
||||
}
|
||||
|
||||
// Fallback auf direkte IP
|
||||
return req.ip || req.connection?.remoteAddress || 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Überschreibt throwThrottlingException um Retry-After Header zu setzen
|
||||
*/
|
||||
protected async throwThrottlingException(
|
||||
context: ExecutionContext,
|
||||
throttlerLimitDetail: ThrottlerLimitDetail,
|
||||
): Promise<void> {
|
||||
const response = context.switchToHttp().getResponse<Response>();
|
||||
const retryAfterSeconds = Math.ceil(throttlerLimitDetail.ttl / 1000);
|
||||
|
||||
// Setze Retry-After Header (wichtig für Client!)
|
||||
response.setHeader('Retry-After', retryAfterSeconds.toString());
|
||||
response.setHeader('X-RateLimit-Limit', throttlerLimitDetail.limit.toString());
|
||||
response.setHeader('X-RateLimit-Remaining', '0');
|
||||
response.setHeader('X-RateLimit-Reset', (Date.now() + throttlerLimitDetail.ttl).toString());
|
||||
|
||||
throw new HttpException(
|
||||
{
|
||||
statusCode: HttpStatus.TOO_MANY_REQUESTS,
|
||||
message: `Zu viele Anfragen. Bitte warte ${retryAfterSeconds} Sekunden.`,
|
||||
retryAfter: retryAfterSeconds,
|
||||
},
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Controller, Get, Post, Put, Patch, Delete, Body, Param, UseGuards } from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { InvoicesService } from './invoices.service';
|
||||
import { CreateInvoiceDto, UpdateInvoiceDto, UpdateStatusDto } from './invoices.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { AdminGuard } from '../auth/guards/admin.guard';
|
||||
|
||||
@Controller('invoices')
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Throttle({ default: { limit: 30, ttl: 60000 } }) // 🛡️ Basis: 30 Requests/Minute
|
||||
export class InvoicesController {
|
||||
constructor(private readonly invoicesService: InvoicesService) {}
|
||||
|
||||
@Get()
|
||||
findAll() {
|
||||
return this.invoicesService.findAll();
|
||||
}
|
||||
|
||||
@Get('stats')
|
||||
getStats() {
|
||||
return this.invoicesService.getStats();
|
||||
}
|
||||
|
||||
@Get('generate-number')
|
||||
generateNumber() {
|
||||
return this.invoicesService.generateInvoiceNumber();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.invoicesService.findOne(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() createDto: CreateInvoiceDto) {
|
||||
return this.invoicesService.create(createDto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
update(@Param('id') id: string, @Body() updateDto: UpdateInvoiceDto) {
|
||||
return this.invoicesService.update(id, updateDto);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
updateStatus(@Param('id') id: string, @Body() statusDto: UpdateStatusDto) {
|
||||
return this.invoicesService.updateStatus(id, statusDto.status);
|
||||
}
|
||||
|
||||
@Post(':id/duplicate')
|
||||
duplicate(@Param('id') id: string) {
|
||||
return this.invoicesService.duplicate(id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.invoicesService.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { IsString, IsEmail, IsOptional, IsNumber, IsArray, ValidateNested, IsEnum, IsDateString } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class InvoiceItemDto {
|
||||
@IsString()
|
||||
id: string;
|
||||
|
||||
@IsString()
|
||||
description: string;
|
||||
|
||||
@IsNumber()
|
||||
quantity: number;
|
||||
|
||||
@IsString()
|
||||
unit: string;
|
||||
|
||||
@IsNumber()
|
||||
unitPrice: number;
|
||||
}
|
||||
|
||||
export class CreateInvoiceDto {
|
||||
@IsString()
|
||||
invoiceNumber: string;
|
||||
|
||||
@IsDateString()
|
||||
date: string;
|
||||
|
||||
@IsDateString()
|
||||
dueDate: string;
|
||||
|
||||
@IsEnum(['draft', 'sent', 'paid', 'overdue'])
|
||||
@IsOptional()
|
||||
status?: 'draft' | 'sent' | 'paid' | 'overdue';
|
||||
|
||||
@IsString()
|
||||
customerName: string;
|
||||
|
||||
@IsEmail()
|
||||
@IsOptional()
|
||||
customerEmail?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
customerAddress?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
customerCity?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
customerZip?: string;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => InvoiceItemDto)
|
||||
items: InvoiceItemDto[];
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
taxRate?: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class UpdateInvoiceDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
invoiceNumber?: string;
|
||||
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
date?: string;
|
||||
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
dueDate?: string;
|
||||
|
||||
@IsEnum(['draft', 'sent', 'paid', 'overdue'])
|
||||
@IsOptional()
|
||||
status?: 'draft' | 'sent' | 'paid' | 'overdue';
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
customerName?: string;
|
||||
|
||||
@IsEmail()
|
||||
@IsOptional()
|
||||
customerEmail?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
customerAddress?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
customerCity?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
customerZip?: string;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => InvoiceItemDto)
|
||||
@IsOptional()
|
||||
items?: InvoiceItemDto[];
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
taxRate?: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class UpdateStatusDto {
|
||||
@IsEnum(['draft', 'sent', 'paid', 'overdue'])
|
||||
status: 'draft' | 'sent' | 'paid' | 'overdue';
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity('invoices')
|
||||
export class Invoice {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ unique: true })
|
||||
invoiceNumber: string;
|
||||
|
||||
@Column({ type: 'date' })
|
||||
date: string;
|
||||
|
||||
@Column({ type: 'date' })
|
||||
dueDate: string;
|
||||
|
||||
@Column({ default: 'draft' })
|
||||
status: 'draft' | 'sent' | 'paid' | 'overdue';
|
||||
|
||||
// Kunde
|
||||
@Column()
|
||||
customerName: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
customerEmail: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
customerAddress: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
customerCity: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
customerZip: string;
|
||||
|
||||
// Positionen als JSON
|
||||
@Column({ type: 'json' })
|
||||
items: {
|
||||
id: string;
|
||||
description: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unitPrice: number;
|
||||
}[];
|
||||
|
||||
@Column({ type: 'decimal', precision: 5, scale: 2, default: 19 })
|
||||
taxRate: number;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes: string;
|
||||
|
||||
// Berechnete Werte für einfache Queries
|
||||
@Column({ type: 'decimal', precision: 10, scale: 2, default: 0 })
|
||||
totalNet: number;
|
||||
|
||||
@Column({ type: 'decimal', precision: 10, scale: 2, default: 0 })
|
||||
totalGross: number;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Invoice } from './invoices.entity';
|
||||
import { InvoicesService } from './invoices.service';
|
||||
import { InvoicesController } from './invoices.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Invoice])],
|
||||
controllers: [InvoicesController],
|
||||
providers: [InvoicesService],
|
||||
exports: [InvoicesService],
|
||||
})
|
||||
export class InvoicesModule {}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Invoice } from './invoices.entity';
|
||||
import { CreateInvoiceDto, UpdateInvoiceDto } from './invoices.dto';
|
||||
|
||||
@Injectable()
|
||||
export class InvoicesService {
|
||||
constructor(
|
||||
@InjectRepository(Invoice)
|
||||
private invoicesRepository: Repository<Invoice>,
|
||||
) {}
|
||||
|
||||
async findAll(): Promise<Invoice[]> {
|
||||
return this.invoicesRepository.find({
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<Invoice> {
|
||||
const invoice = await this.invoicesRepository.findOne({ where: { id } });
|
||||
if (!invoice) {
|
||||
throw new NotFoundException(`Invoice with ID ${id} not found`);
|
||||
}
|
||||
return invoice;
|
||||
}
|
||||
|
||||
async create(createDto: CreateInvoiceDto): Promise<Invoice> {
|
||||
const { totalNet, totalGross } = this.calculateTotals(createDto.items, createDto.taxRate || 19);
|
||||
|
||||
const invoice = this.invoicesRepository.create({
|
||||
...createDto,
|
||||
status: createDto.status || 'draft',
|
||||
taxRate: createDto.taxRate || 19,
|
||||
totalNet,
|
||||
totalGross,
|
||||
});
|
||||
|
||||
return this.invoicesRepository.save(invoice);
|
||||
}
|
||||
|
||||
async update(id: string, updateDto: UpdateInvoiceDto): Promise<Invoice> {
|
||||
const invoice = await this.findOne(id);
|
||||
|
||||
// Recalculate totals if items or taxRate changed
|
||||
const items = updateDto.items || invoice.items;
|
||||
const taxRate = updateDto.taxRate !== undefined ? updateDto.taxRate : invoice.taxRate;
|
||||
const { totalNet, totalGross } = this.calculateTotals(items, taxRate);
|
||||
|
||||
Object.assign(invoice, {
|
||||
...updateDto,
|
||||
totalNet,
|
||||
totalGross,
|
||||
});
|
||||
|
||||
return this.invoicesRepository.save(invoice);
|
||||
}
|
||||
|
||||
async updateStatus(id: string, status: Invoice['status']): Promise<Invoice> {
|
||||
const invoice = await this.findOne(id);
|
||||
invoice.status = status;
|
||||
return this.invoicesRepository.save(invoice);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const invoice = await this.findOne(id);
|
||||
await this.invoicesRepository.remove(invoice);
|
||||
}
|
||||
|
||||
async duplicate(id: string): Promise<Invoice> {
|
||||
const original = await this.findOne(id);
|
||||
|
||||
// Generate new invoice number
|
||||
const newNumber = await this.generateInvoiceNumber();
|
||||
|
||||
const duplicate = this.invoicesRepository.create({
|
||||
...original,
|
||||
id: undefined,
|
||||
invoiceNumber: newNumber,
|
||||
status: 'draft',
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
dueDate: this.getDefaultDueDate(),
|
||||
createdAt: undefined,
|
||||
updatedAt: undefined,
|
||||
});
|
||||
|
||||
return this.invoicesRepository.save(duplicate);
|
||||
}
|
||||
|
||||
async generateInvoiceNumber(): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
const prefix = `RE-${year}-`;
|
||||
|
||||
const lastInvoice = await this.invoicesRepository
|
||||
.createQueryBuilder('invoice')
|
||||
.where('invoice.invoiceNumber LIKE :prefix', { prefix: `${prefix}%` })
|
||||
.orderBy('invoice.invoiceNumber', 'DESC')
|
||||
.getOne();
|
||||
|
||||
let nextNumber = 1;
|
||||
if (lastInvoice) {
|
||||
const lastNum = parseInt(lastInvoice.invoiceNumber.replace(prefix, ''), 10);
|
||||
nextNumber = lastNum + 1;
|
||||
}
|
||||
|
||||
return `${prefix}${nextNumber.toString().padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
async getStats() {
|
||||
const invoices = await this.findAll();
|
||||
|
||||
return {
|
||||
total: invoices.length,
|
||||
draft: invoices.filter(i => i.status === 'draft').length,
|
||||
sent: invoices.filter(i => i.status === 'sent').length,
|
||||
paid: invoices.filter(i => i.status === 'paid').length,
|
||||
overdue: invoices.filter(i => i.status === 'overdue').length,
|
||||
totalRevenue: invoices
|
||||
.filter(i => i.status === 'paid')
|
||||
.reduce((sum, i) => sum + Number(i.totalGross), 0),
|
||||
};
|
||||
}
|
||||
|
||||
private calculateTotals(items: any[], taxRate: number) {
|
||||
const totalNet = items.reduce((sum, item) => sum + (item.quantity * item.unitPrice), 0);
|
||||
const totalGross = totalNet * (1 + taxRate / 100);
|
||||
return { totalNet, totalGross };
|
||||
}
|
||||
|
||||
private getDefaultDueDate(): string {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + 14);
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
import helmet from 'helmet';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// 🛡️ Security Headers mit Helmet
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
scriptSrc: ["'self'"],
|
||||
imgSrc: ["'self'", "data:", "https:"],
|
||||
},
|
||||
},
|
||||
crossOriginEmbedderPolicy: false, // Für iframes falls benötigt
|
||||
}));
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
app.enableCors({
|
||||
origin: isProduction ? true : (origin, cb) => {
|
||||
const allowList = [
|
||||
'http://localhost:4200', // Dev lokal
|
||||
'http://localhost', // Docker Frontend (Port 80)
|
||||
'http://localhost:80', // Docker Frontend explizit
|
||||
'http://192.168.178.111:4200', // Dein lokales Netzwerk
|
||||
'https://leonardsmedia.de',
|
||||
'https://www.leonardsmedia.de',
|
||||
];
|
||||
// Kein Origin = Postman, curl, server-to-server
|
||||
if (!origin) return cb(null, true);
|
||||
return allowList.includes(origin) ? cb(null, true) : cb(new Error('CORS'), false);
|
||||
},
|
||||
credentials: true,
|
||||
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS',
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'X-Consent-Analytics'],
|
||||
// 🛡️ Diese Header müssen exposed werden damit das Frontend sie lesen kann
|
||||
exposedHeaders: ['Retry-After', 'X-RateLimit-Limit', 'X-RateLimit-Remaining', 'X-RateLimit-Reset'],
|
||||
optionsSuccessStatus: 204,
|
||||
});
|
||||
|
||||
app.useGlobalPipes(new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}));
|
||||
|
||||
await app.listen(process.env.PORT || 3000);
|
||||
console.log('🚀 Backend läuft auf http://localhost:' + (process.env.PORT || 3000));
|
||||
}
|
||||
bootstrap();
|
||||
@@ -0,0 +1,92 @@
|
||||
// src/newsletter/newsletter.controller.ts
|
||||
import { Controller, Post, Body, Get, UseGuards, Delete, Query, Patch, Param, NotFoundException } from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';
|
||||
import { AdminGuard } from 'src/auth/guards/admin.guard';
|
||||
import { NewsletterService } from './newsletter.service';
|
||||
import { SubscribeNewsletterDto } from './newsletter.dto';
|
||||
|
||||
@Controller('newsletter')
|
||||
@Throttle({ default: { limit: 30, ttl: 60000 } }) // 🛡️ Basis: 30 Requests/Minute
|
||||
export class NewsletterController {
|
||||
constructor(private readonly newsletterService: NewsletterService) { }
|
||||
|
||||
// 🛡️ STRENG: 3 Anmeldungen pro Stunde pro IP
|
||||
@Throttle({ default: { limit: 3, ttl: 3600000 } })
|
||||
@Post('subscribe')
|
||||
async subscribe(@Body() dto: SubscribeNewsletterDto) {
|
||||
const subscriber = await this.newsletterService.subscribe(dto);
|
||||
return {
|
||||
success: true,
|
||||
message: 'Erfolgreich für den Newsletter angemeldet! Check deine E-Mails.',
|
||||
email: subscriber.email,
|
||||
};
|
||||
}
|
||||
|
||||
// PUBLIC: Newsletter abbestellen
|
||||
@Delete('unsubscribe')
|
||||
async unsubscribe(@Query('email') email: string) {
|
||||
if (!email) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'E-Mail-Adresse fehlt',
|
||||
};
|
||||
}
|
||||
|
||||
await this.newsletterService.unsubscribe(email);
|
||||
return {
|
||||
success: true,
|
||||
message: 'Erfolgreich vom Newsletter abgemeldet',
|
||||
};
|
||||
}
|
||||
|
||||
// ADMIN: Alle Subscriber abrufen (inkl. inaktive)
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Get('subscribers')
|
||||
async getAllSubscribers() {
|
||||
const subscribers = await this.newsletterService.getAllSubscribersAdmin();
|
||||
const stats = await this.newsletterService.getStats();
|
||||
return {
|
||||
...stats,
|
||||
subscribers,
|
||||
};
|
||||
}
|
||||
|
||||
// ADMIN: Statistiken
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Get('stats')
|
||||
async getStats() {
|
||||
return this.newsletterService.getStats();
|
||||
}
|
||||
|
||||
// ADMIN: Subscriber Status umschalten
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Patch('subscribers/:id/toggle')
|
||||
async toggleStatus(@Param('id') id: string) {
|
||||
try {
|
||||
const subscriber = await this.newsletterService.toggleSubscriberStatus(id);
|
||||
return {
|
||||
success: true,
|
||||
message: `Status geändert: ${subscriber.isActive ? 'Aktiv' : 'Inaktiv'}`,
|
||||
subscriber,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new NotFoundException('Subscriber nicht gefunden');
|
||||
}
|
||||
}
|
||||
|
||||
// ADMIN: Subscriber löschen
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Delete('subscribers/:id')
|
||||
async deleteSubscriber(@Param('id') id: string) {
|
||||
try {
|
||||
await this.newsletterService.deleteSubscriber(id);
|
||||
return {
|
||||
success: true,
|
||||
message: 'Subscriber wurde gelöscht',
|
||||
};
|
||||
} catch (error) {
|
||||
throw new NotFoundException('Subscriber nicht gefunden');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// src/newsletter/newsletter.dto.ts
|
||||
import { IsEmail, IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class SubscribeNewsletterDto {
|
||||
@IsEmail({}, { message: 'Bitte gib eine gültige E-Mail-Adresse ein' })
|
||||
@IsNotEmpty({ message: 'E-Mail-Adresse ist erforderlich' })
|
||||
email: string;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// src/newsletter/newsletter.entity.ts
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity('newsletter_subscribers')
|
||||
export class NewsletterSubscriber {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ unique: true })
|
||||
email: string;
|
||||
|
||||
@Column({ default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@CreateDateColumn()
|
||||
subscribedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// src/newsletter/newsletter.module.ts
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { NewsletterController } from './newsletter.controller';
|
||||
import { NewsletterService } from './newsletter.service';
|
||||
import { NewsletterSubscriber } from './newsletter.entity';
|
||||
import { EmailModule } from 'src/email/email.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([NewsletterSubscriber]),
|
||||
EmailModule,
|
||||
],
|
||||
controllers: [NewsletterController],
|
||||
providers: [NewsletterService],
|
||||
exports: [NewsletterService],
|
||||
})
|
||||
export class NewsletterModule {}
|
||||
@@ -0,0 +1,152 @@
|
||||
// src/newsletter/newsletter.service.ts
|
||||
import { Injectable, ConflictException, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { EmailService } from 'src/email/email.service';
|
||||
import { NewsletterSubscriber } from './newsletter.entity';
|
||||
import { SubscribeNewsletterDto } from './newsletter.dto';
|
||||
|
||||
@Injectable()
|
||||
export class NewsletterService {
|
||||
private readonly logger = new Logger(NewsletterService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(NewsletterSubscriber)
|
||||
private readonly subscriberRepo: Repository<NewsletterSubscriber>,
|
||||
private readonly emailService: EmailService,
|
||||
) { }
|
||||
|
||||
async subscribe(dto: SubscribeNewsletterDto): Promise<NewsletterSubscriber> {
|
||||
const existing = await this.subscriberRepo.findOne({
|
||||
where: { email: dto.email.toLowerCase() },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
if (existing.isActive) {
|
||||
throw new ConflictException('Diese E-Mail-Adresse ist bereits für den Newsletter angemeldet');
|
||||
}
|
||||
existing.isActive = true;
|
||||
await this.subscriberRepo.save(existing);
|
||||
await this.sendWelcomeEmail(existing.email);
|
||||
return existing;
|
||||
}
|
||||
|
||||
const subscriber = this.subscriberRepo.create({
|
||||
email: dto.email.toLowerCase(),
|
||||
});
|
||||
|
||||
const saved = await this.subscriberRepo.save(subscriber);
|
||||
|
||||
await this.sendWelcomeEmail(saved.email);
|
||||
|
||||
this.logger.log(`✅ Neue Newsletter-Anmeldung: ${saved.email}`);
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
private async sendWelcomeEmail(email: string): Promise<void> {
|
||||
try {
|
||||
await this.emailService.sendNewsletterWelcome({
|
||||
to: email,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`❌ Fehler beim Senden der Willkommens-Email an ${email}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
async getAllSubscribers(): Promise<NewsletterSubscriber[]> {
|
||||
return this.subscriberRepo.find({
|
||||
where: { isActive: true },
|
||||
order: { subscribedAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async unsubscribe(email: string): Promise<void> {
|
||||
const subscriber = await this.subscriberRepo.findOne({
|
||||
where: { email: email.toLowerCase() },
|
||||
});
|
||||
|
||||
if (!subscriber) {
|
||||
this.logger.warn(`⚠️ Abmelde-Versuch für nicht existierende Email: ${email}`);
|
||||
// Nicht werfen, damit Angreifer nicht testen können welche Emails existieren
|
||||
return;
|
||||
}
|
||||
|
||||
if (!subscriber.isActive) {
|
||||
this.logger.warn(`⚠️ Email bereits abgemeldet: ${email}`);
|
||||
return;
|
||||
}
|
||||
|
||||
subscriber.isActive = false;
|
||||
await this.subscriberRepo.save(subscriber);
|
||||
|
||||
this.logger.log(`📭 Newsletter-Abmeldung: ${email}`);
|
||||
|
||||
// Optional: Bestätigungs-Email senden
|
||||
await this.sendUnsubscribeConfirmation(email);
|
||||
}
|
||||
|
||||
private async sendUnsubscribeConfirmation(email: string): Promise<void> {
|
||||
try {
|
||||
await this.emailService.sendNewsletterUnsubscribe({
|
||||
to: email,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`❌ Fehler beim Senden der Abmelde-Bestätigung an ${email}:`, error);
|
||||
// Fehler nicht werfen, Abmeldung ist trotzdem erfolgt
|
||||
}
|
||||
}
|
||||
|
||||
async getSubscriberCount(): Promise<number> {
|
||||
return this.subscriberRepo.count({
|
||||
where: { isActive: true },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Subscriber holen (inkl. inaktive) - Admin
|
||||
*/
|
||||
async getAllSubscribersAdmin(): Promise<NewsletterSubscriber[]> {
|
||||
return this.subscriberRepo.find({
|
||||
order: { subscribedAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscriber Status umschalten - Admin
|
||||
*/
|
||||
async toggleSubscriberStatus(id: string): Promise<NewsletterSubscriber> {
|
||||
const subscriber = await this.subscriberRepo.findOne({ where: { id } });
|
||||
if (!subscriber) {
|
||||
throw new Error('Subscriber nicht gefunden');
|
||||
}
|
||||
subscriber.isActive = !subscriber.isActive;
|
||||
return this.subscriberRepo.save(subscriber);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscriber löschen (permanent) - Admin
|
||||
*/
|
||||
async deleteSubscriber(id: string): Promise<void> {
|
||||
const subscriber = await this.subscriberRepo.findOne({ where: { id } });
|
||||
if (!subscriber) {
|
||||
throw new Error('Subscriber nicht gefunden');
|
||||
}
|
||||
await this.subscriberRepo.remove(subscriber);
|
||||
this.logger.log(`🗑️ Subscriber gelöscht: ${subscriber.email}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Statistiken - Admin
|
||||
*/
|
||||
async getStats(): Promise<{ total: number; active: number; inactive: number }> {
|
||||
const total = await this.subscriberRepo.count();
|
||||
const active = await this.subscriberRepo.count({ where: { isActive: true } });
|
||||
return {
|
||||
total,
|
||||
active,
|
||||
inactive: total - active,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import {
|
||||
Controller, Get, Post, Body, Param, Patch, Delete,
|
||||
UseGuards, HttpCode, HttpStatus
|
||||
} from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { ServicesCatalogService } from './services-catalog.service';
|
||||
import {
|
||||
CreateServiceCategoryDto, UpdateServiceCategoryDto,
|
||||
CreateServiceDto, UpdateServiceDto,
|
||||
BulkImportServicesCatalogDto
|
||||
} from './services-catalog.dto';
|
||||
import { AdminGuard } from 'src/auth/guards/admin.guard';
|
||||
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';
|
||||
|
||||
@Controller('services-catalog')
|
||||
@Throttle({ default: { limit: 60, ttl: 60000 } }) // 🛡️ Basis: 60 Requests/Minute (Read-heavy)
|
||||
export class ServicesCatalogController {
|
||||
constructor(private readonly catalogService: ServicesCatalogService) { }
|
||||
|
||||
// ===== PUBLIC ENDPOINTS =====
|
||||
|
||||
/**
|
||||
* Alle veröffentlichten Kategorien mit Services (für öffentliche Seite)
|
||||
*/
|
||||
@Get()
|
||||
async findAllPublished() {
|
||||
return this.catalogService.findAllPublished();
|
||||
}
|
||||
|
||||
/**
|
||||
* Kategorie per Slug abrufen (öffentlich)
|
||||
*/
|
||||
@Get('category/:slug')
|
||||
async findCategoryBySlug(@Param('slug') slug: string) {
|
||||
return this.catalogService.findCategoryBySlug(slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Service per Slug abrufen (öffentlich)
|
||||
*/
|
||||
@Get('service/:slug')
|
||||
async findServiceBySlug(@Param('slug') slug: string) {
|
||||
return this.catalogService.findServiceBySlug(slug);
|
||||
}
|
||||
|
||||
// ===== ADMIN ENDPOINTS - EXPORT/IMPORT (vor :id Routen!) =====
|
||||
|
||||
/**
|
||||
* Export aller Kategorien und Services als JSON - Admin only
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Get('admin/export')
|
||||
async exportAll() {
|
||||
return this.catalogService.exportAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-Import von Kategorien und Services (JSON) - Admin only
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Post('admin/import')
|
||||
async bulkImport(@Body() dto: BulkImportServicesCatalogDto) {
|
||||
return this.catalogService.bulkImport(dto);
|
||||
}
|
||||
|
||||
// ===== ADMIN ENDPOINTS - CATEGORIES =====
|
||||
|
||||
/**
|
||||
* Alle Kategorien abrufen (inkl. unpublished) - Admin only
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Get('admin/categories')
|
||||
async findAllCategoriesAdmin() {
|
||||
return this.catalogService.findAllCategories();
|
||||
}
|
||||
|
||||
/**
|
||||
* Kategorie per ID abrufen - Admin only
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Get('admin/categories/:id')
|
||||
async findCategoryByIdAdmin(@Param('id') id: string) {
|
||||
return this.catalogService.findCategoryById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Neue Kategorie erstellen - Admin only
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Post('admin/categories')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async createCategory(@Body() dto: CreateServiceCategoryDto) {
|
||||
return this.catalogService.createCategory(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kategorie aktualisieren - Admin only
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Patch('admin/categories/:id')
|
||||
async updateCategory(@Param('id') id: string, @Body() dto: UpdateServiceCategoryDto) {
|
||||
return this.catalogService.updateCategory(id, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kategorie löschen - Admin only
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Delete('admin/categories/:id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async deleteCategory(@Param('id') id: string) {
|
||||
return this.catalogService.deleteCategory(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kategorie Publish-Status togglen - Admin only
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Patch('admin/categories/:id/toggle-publish')
|
||||
async toggleCategoryPublish(@Param('id') id: string) {
|
||||
return this.catalogService.toggleCategoryPublish(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kategorien-Sortierung aktualisieren - Admin only
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Patch('admin/categories/sort-order')
|
||||
async updateCategorySortOrder(@Body() items: { id: string; sortOrder: number }[]) {
|
||||
await this.catalogService.updateCategorySortOrder(items);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ===== ADMIN ENDPOINTS - SERVICES =====
|
||||
|
||||
/**
|
||||
* Alle Services abrufen - Admin only
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Get('admin/services')
|
||||
async findAllServicesAdmin() {
|
||||
return this.catalogService.findAllServices();
|
||||
}
|
||||
|
||||
/**
|
||||
* Service per ID abrufen - Admin only
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Get('admin/services/:id')
|
||||
async findServiceByIdAdmin(@Param('id') id: string) {
|
||||
return this.catalogService.findServiceById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Neuen Service erstellen - Admin only
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Post('admin/services')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async createService(@Body() dto: CreateServiceDto) {
|
||||
return this.catalogService.createService(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Service aktualisieren - Admin only
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Patch('admin/services/:id')
|
||||
async updateService(@Param('id') id: string, @Body() dto: UpdateServiceDto) {
|
||||
return this.catalogService.updateService(id, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Service löschen - Admin only
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Delete('admin/services/:id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async deleteService(@Param('id') id: string) {
|
||||
return this.catalogService.deleteService(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Service Publish-Status togglen - Admin only
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Patch('admin/services/:id/toggle-publish')
|
||||
async toggleServicePublish(@Param('id') id: string) {
|
||||
return this.catalogService.toggleServicePublish(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Services-Sortierung aktualisieren - Admin only
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Patch('admin/services/sort-order')
|
||||
async updateServiceSortOrder(@Body() items: { id: string; sortOrder: number }[]) {
|
||||
await this.catalogService.updateServiceSortOrder(items);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { IsString, IsOptional, IsBoolean, IsArray, IsNumber, IsUUID, ValidateNested, IsNotEmpty } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
// ===== SERVICE CATEGORY DTOs =====
|
||||
|
||||
export class CreateServiceCategoryDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
slug: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
subtitle: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
materialIcon: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
sortOrder?: number;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isPublished?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateServiceCategoryDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
slug?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
subtitle?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
materialIcon?: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
sortOrder?: number;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isPublished?: boolean;
|
||||
}
|
||||
|
||||
// ===== SERVICE DTOs =====
|
||||
|
||||
export class CreateServiceDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
slug: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
icon: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
title: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
description: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
longDescription: string;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags: string[];
|
||||
|
||||
@IsString()
|
||||
keywords: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
categoryId: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
sortOrder?: number;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isPublished?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateServiceDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
slug?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
icon?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
title?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
longDescription?: string;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
tags?: string[];
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
keywords?: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
categoryId?: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
sortOrder?: number;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isPublished?: boolean;
|
||||
}
|
||||
|
||||
// ===== BULK IMPORT DTOs =====
|
||||
|
||||
export class ImportServiceDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
slug: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
icon: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
title: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
description: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
longDescription: string;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags: string[];
|
||||
|
||||
@IsString()
|
||||
keywords: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export class ImportCategoryDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
slug: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
subtitle: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
materialIcon: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
sortOrder?: number;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ImportServiceDto)
|
||||
services: ImportServiceDto[];
|
||||
}
|
||||
|
||||
export class BulkImportServicesCatalogDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ImportCategoryDto)
|
||||
categories: ImportCategoryDto[];
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
overwriteExisting?: boolean;
|
||||
}
|
||||
|
||||
export class ImportResultDto {
|
||||
success: boolean;
|
||||
categoriesCreated: number;
|
||||
categoriesUpdated: number;
|
||||
servicesCreated: number;
|
||||
servicesUpdated: number;
|
||||
errors: string[];
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index,
|
||||
OneToMany, ManyToOne, JoinColumn
|
||||
} from 'typeorm';
|
||||
|
||||
// ===== SERVICE CATEGORY ENTITY =====
|
||||
@Entity('service_categories')
|
||||
export class ServiceCategoryEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Index()
|
||||
@Column({ unique: true })
|
||||
slug: string;
|
||||
|
||||
@Column()
|
||||
name: string;
|
||||
|
||||
@Column()
|
||||
subtitle: string;
|
||||
|
||||
@Column()
|
||||
materialIcon: string;
|
||||
|
||||
@Index()
|
||||
@Column({ default: 0 })
|
||||
sortOrder: number;
|
||||
|
||||
@Index()
|
||||
@Column({ default: true })
|
||||
isPublished: boolean;
|
||||
|
||||
@OneToMany(() => ServiceEntity, service => service.category)
|
||||
services: ServiceEntity[];
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz', default: () => 'now()' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'timestamptz', default: () => 'now()' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// ===== SERVICE ENTITY =====
|
||||
@Entity('services')
|
||||
export class ServiceEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Index()
|
||||
@Column({ unique: true })
|
||||
slug: string;
|
||||
|
||||
@Column()
|
||||
icon: string;
|
||||
|
||||
@Column()
|
||||
title: string;
|
||||
|
||||
@Column('text')
|
||||
description: string;
|
||||
|
||||
@Column('text')
|
||||
longDescription: string;
|
||||
|
||||
@Column('text', { array: true, default: [] })
|
||||
tags: string[];
|
||||
|
||||
@Column('text')
|
||||
keywords: string;
|
||||
|
||||
@Index()
|
||||
@Column({ default: 0 })
|
||||
sortOrder: number;
|
||||
|
||||
@Index()
|
||||
@Column({ default: true })
|
||||
isPublished: boolean;
|
||||
|
||||
@Index()
|
||||
@Column('uuid')
|
||||
categoryId: string;
|
||||
|
||||
@ManyToOne(() => ServiceCategoryEntity, category => category.services, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'categoryId' })
|
||||
category: ServiceCategoryEntity;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz', default: () => 'now()' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'timestamptz', default: () => 'now()' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ServicesCatalogController } from './services-catalog.controller';
|
||||
import { ServicesCatalogService } from './services-catalog.service';
|
||||
import { ServiceCategoryEntity, ServiceEntity } from './services-catalog.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ServiceCategoryEntity, ServiceEntity])
|
||||
],
|
||||
controllers: [ServicesCatalogController],
|
||||
providers: [ServicesCatalogService],
|
||||
exports: [ServicesCatalogService]
|
||||
})
|
||||
export class ServicesCatalogModule { }
|
||||
@@ -0,0 +1,390 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ServiceCategoryEntity, ServiceEntity } from './services-catalog.entity';
|
||||
import {
|
||||
CreateServiceCategoryDto, UpdateServiceCategoryDto,
|
||||
CreateServiceDto, UpdateServiceDto,
|
||||
BulkImportServicesCatalogDto, ImportResultDto
|
||||
} from './services-catalog.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ServicesCatalogService {
|
||||
constructor(
|
||||
@InjectRepository(ServiceCategoryEntity)
|
||||
private readonly categoryRepo: Repository<ServiceCategoryEntity>,
|
||||
@InjectRepository(ServiceEntity)
|
||||
private readonly serviceRepo: Repository<ServiceEntity>,
|
||||
) { }
|
||||
|
||||
// ===== CATEGORIES =====
|
||||
|
||||
/**
|
||||
* Alle veröffentlichten Kategorien mit Services (öffentlich)
|
||||
*/
|
||||
async findAllPublished(): Promise<ServiceCategoryEntity[]> {
|
||||
return this.categoryRepo.find({
|
||||
where: { isPublished: true },
|
||||
relations: ['services'],
|
||||
order: { sortOrder: 'ASC' }
|
||||
}).then(categories => {
|
||||
// Filter nur published services und sortieren
|
||||
return categories.map(cat => ({
|
||||
...cat,
|
||||
services: cat.services
|
||||
.filter(s => s.isPublished)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Kategorien (Admin)
|
||||
*/
|
||||
async findAllCategories(): Promise<ServiceCategoryEntity[]> {
|
||||
return this.categoryRepo.find({
|
||||
relations: ['services'],
|
||||
order: { sortOrder: 'ASC' }
|
||||
}).then(categories => {
|
||||
return categories.map(cat => ({
|
||||
...cat,
|
||||
services: cat.services.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Kategorie per ID finden
|
||||
*/
|
||||
async findCategoryById(id: string): Promise<ServiceCategoryEntity> {
|
||||
const category = await this.categoryRepo.findOne({
|
||||
where: { id },
|
||||
relations: ['services']
|
||||
});
|
||||
if (!category) {
|
||||
throw new NotFoundException(`Kategorie mit ID ${id} nicht gefunden`);
|
||||
}
|
||||
return category;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kategorie per Slug finden (öffentlich)
|
||||
*/
|
||||
async findCategoryBySlug(slug: string): Promise<ServiceCategoryEntity> {
|
||||
const category = await this.categoryRepo.findOne({
|
||||
where: { slug, isPublished: true },
|
||||
relations: ['services']
|
||||
});
|
||||
if (!category) {
|
||||
throw new NotFoundException(`Kategorie "${slug}" nicht gefunden`);
|
||||
}
|
||||
category.services = category.services
|
||||
.filter(s => s.isPublished)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
return category;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kategorie erstellen
|
||||
*/
|
||||
async createCategory(dto: CreateServiceCategoryDto): Promise<ServiceCategoryEntity> {
|
||||
const existing = await this.categoryRepo.findOne({ where: { slug: dto.slug } });
|
||||
if (existing) {
|
||||
throw new ConflictException(`Kategorie mit Slug "${dto.slug}" existiert bereits`);
|
||||
}
|
||||
|
||||
const category = this.categoryRepo.create({
|
||||
...dto,
|
||||
sortOrder: dto.sortOrder ?? await this.getNextCategorySortOrder()
|
||||
});
|
||||
return this.categoryRepo.save(category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kategorie aktualisieren
|
||||
*/
|
||||
async updateCategory(id: string, dto: UpdateServiceCategoryDto): Promise<ServiceCategoryEntity> {
|
||||
const category = await this.findCategoryById(id);
|
||||
|
||||
if (dto.slug && dto.slug !== category.slug) {
|
||||
const existing = await this.categoryRepo.findOne({ where: { slug: dto.slug } });
|
||||
if (existing) {
|
||||
throw new ConflictException(`Kategorie mit Slug "${dto.slug}" existiert bereits`);
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(category, dto);
|
||||
return this.categoryRepo.save(category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kategorie löschen (löscht auch alle Services)
|
||||
*/
|
||||
async deleteCategory(id: string): Promise<void> {
|
||||
const result = await this.categoryRepo.delete(id);
|
||||
if (result.affected === 0) {
|
||||
throw new NotFoundException(`Kategorie mit ID ${id} nicht gefunden`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kategorie Publish-Status togglen
|
||||
*/
|
||||
async toggleCategoryPublish(id: string): Promise<ServiceCategoryEntity> {
|
||||
const category = await this.findCategoryById(id);
|
||||
category.isPublished = !category.isPublished;
|
||||
return this.categoryRepo.save(category);
|
||||
}
|
||||
|
||||
// ===== SERVICES =====
|
||||
|
||||
/**
|
||||
* Alle Services (Admin)
|
||||
*/
|
||||
async findAllServices(): Promise<ServiceEntity[]> {
|
||||
return this.serviceRepo.find({
|
||||
relations: ['category'],
|
||||
order: { sortOrder: 'ASC' }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Service per ID finden
|
||||
*/
|
||||
async findServiceById(id: string): Promise<ServiceEntity> {
|
||||
const service = await this.serviceRepo.findOne({
|
||||
where: { id },
|
||||
relations: ['category']
|
||||
});
|
||||
if (!service) {
|
||||
throw new NotFoundException(`Service mit ID ${id} nicht gefunden`);
|
||||
}
|
||||
return service;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service per Slug finden (öffentlich)
|
||||
*/
|
||||
async findServiceBySlug(slug: string): Promise<ServiceEntity> {
|
||||
const service = await this.serviceRepo.findOne({
|
||||
where: { slug, isPublished: true },
|
||||
relations: ['category']
|
||||
});
|
||||
if (!service) {
|
||||
throw new NotFoundException(`Service "${slug}" nicht gefunden`);
|
||||
}
|
||||
return service;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service erstellen
|
||||
*/
|
||||
async createService(dto: CreateServiceDto): Promise<ServiceEntity> {
|
||||
const existing = await this.serviceRepo.findOne({ where: { slug: dto.slug } });
|
||||
if (existing) {
|
||||
throw new ConflictException(`Service mit Slug "${dto.slug}" existiert bereits`);
|
||||
}
|
||||
|
||||
// Prüfen ob Kategorie existiert
|
||||
await this.findCategoryById(dto.categoryId);
|
||||
|
||||
const service = this.serviceRepo.create({
|
||||
...dto,
|
||||
sortOrder: dto.sortOrder ?? await this.getNextServiceSortOrder(dto.categoryId)
|
||||
});
|
||||
return this.serviceRepo.save(service);
|
||||
}
|
||||
|
||||
/**
|
||||
* Service aktualisieren
|
||||
*/
|
||||
async updateService(id: string, dto: UpdateServiceDto): Promise<ServiceEntity> {
|
||||
const service = await this.findServiceById(id);
|
||||
|
||||
if (dto.slug && dto.slug !== service.slug) {
|
||||
const existing = await this.serviceRepo.findOne({ where: { slug: dto.slug } });
|
||||
if (existing) {
|
||||
throw new ConflictException(`Service mit Slug "${dto.slug}" existiert bereits`);
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.categoryId) {
|
||||
await this.findCategoryById(dto.categoryId);
|
||||
}
|
||||
|
||||
Object.assign(service, dto);
|
||||
return this.serviceRepo.save(service);
|
||||
}
|
||||
|
||||
/**
|
||||
* Service löschen
|
||||
*/
|
||||
async deleteService(id: string): Promise<void> {
|
||||
const result = await this.serviceRepo.delete(id);
|
||||
if (result.affected === 0) {
|
||||
throw new NotFoundException(`Service mit ID ${id} nicht gefunden`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Service Publish-Status togglen
|
||||
*/
|
||||
async toggleServicePublish(id: string): Promise<ServiceEntity> {
|
||||
const service = await this.findServiceById(id);
|
||||
service.isPublished = !service.isPublished;
|
||||
return this.serviceRepo.save(service);
|
||||
}
|
||||
|
||||
// ===== SORT ORDER =====
|
||||
|
||||
async updateCategorySortOrder(items: { id: string; sortOrder: number }[]): Promise<void> {
|
||||
for (const item of items) {
|
||||
await this.categoryRepo.update(item.id, { sortOrder: item.sortOrder });
|
||||
}
|
||||
}
|
||||
|
||||
async updateServiceSortOrder(items: { id: string; sortOrder: number }[]): Promise<void> {
|
||||
for (const item of items) {
|
||||
await this.serviceRepo.update(item.id, { sortOrder: item.sortOrder });
|
||||
}
|
||||
}
|
||||
|
||||
// ===== IMPORT / EXPORT =====
|
||||
|
||||
/**
|
||||
* Bulk-Import von Kategorien und Services
|
||||
*/
|
||||
async bulkImport(dto: BulkImportServicesCatalogDto): Promise<ImportResultDto> {
|
||||
const result: ImportResultDto = {
|
||||
success: true,
|
||||
categoriesCreated: 0,
|
||||
categoriesUpdated: 0,
|
||||
servicesCreated: 0,
|
||||
servicesUpdated: 0,
|
||||
errors: []
|
||||
};
|
||||
|
||||
for (const catData of dto.categories) {
|
||||
try {
|
||||
let category = await this.categoryRepo.findOne({ where: { slug: catData.slug } });
|
||||
|
||||
if (category && dto.overwriteExisting) {
|
||||
// Update existing category
|
||||
category.name = catData.name;
|
||||
category.subtitle = catData.subtitle;
|
||||
category.materialIcon = catData.materialIcon;
|
||||
category.sortOrder = catData.sortOrder ?? category.sortOrder;
|
||||
category.isPublished = true;
|
||||
await this.categoryRepo.save(category);
|
||||
result.categoriesUpdated++;
|
||||
} else if (!category) {
|
||||
// Create new category
|
||||
category = this.categoryRepo.create({
|
||||
slug: catData.slug,
|
||||
name: catData.name,
|
||||
subtitle: catData.subtitle,
|
||||
materialIcon: catData.materialIcon,
|
||||
sortOrder: catData.sortOrder ?? await this.getNextCategorySortOrder(),
|
||||
isPublished: true
|
||||
});
|
||||
category = await this.categoryRepo.save(category);
|
||||
result.categoriesCreated++;
|
||||
}
|
||||
|
||||
// Import services for this category
|
||||
for (const svcData of catData.services) {
|
||||
try {
|
||||
let service = await this.serviceRepo.findOne({ where: { slug: svcData.slug } });
|
||||
|
||||
if (service && dto.overwriteExisting) {
|
||||
// Update existing service
|
||||
service.icon = svcData.icon;
|
||||
service.title = svcData.title;
|
||||
service.description = svcData.description;
|
||||
service.longDescription = svcData.longDescription;
|
||||
service.tags = svcData.tags;
|
||||
service.keywords = svcData.keywords;
|
||||
service.sortOrder = svcData.sortOrder ?? service.sortOrder;
|
||||
service.categoryId = category.id;
|
||||
service.isPublished = true;
|
||||
await this.serviceRepo.save(service);
|
||||
result.servicesUpdated++;
|
||||
} else if (!service) {
|
||||
// Create new service
|
||||
service = this.serviceRepo.create({
|
||||
slug: svcData.slug,
|
||||
icon: svcData.icon,
|
||||
title: svcData.title,
|
||||
description: svcData.description,
|
||||
longDescription: svcData.longDescription,
|
||||
tags: svcData.tags,
|
||||
keywords: svcData.keywords,
|
||||
sortOrder: svcData.sortOrder ?? await this.getNextServiceSortOrder(category.id),
|
||||
categoryId: category.id,
|
||||
isPublished: true
|
||||
});
|
||||
await this.serviceRepo.save(service);
|
||||
result.servicesCreated++;
|
||||
}
|
||||
} catch (err) {
|
||||
result.errors.push(`Service "${svcData.slug}": ${err.message}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
result.errors.push(`Kategorie "${catData.slug}": ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
result.success = result.errors.length === 0;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export aller Kategorien und Services
|
||||
*/
|
||||
async exportAll(): Promise<{ categories: any[] }> {
|
||||
const categories = await this.categoryRepo.find({
|
||||
relations: ['services'],
|
||||
order: { sortOrder: 'ASC' }
|
||||
});
|
||||
|
||||
return {
|
||||
categories: categories.map(cat => ({
|
||||
slug: cat.slug,
|
||||
name: cat.name,
|
||||
subtitle: cat.subtitle,
|
||||
materialIcon: cat.materialIcon,
|
||||
sortOrder: cat.sortOrder,
|
||||
services: cat.services
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map(svc => ({
|
||||
slug: svc.slug,
|
||||
icon: svc.icon,
|
||||
title: svc.title,
|
||||
description: svc.description,
|
||||
longDescription: svc.longDescription,
|
||||
tags: svc.tags,
|
||||
keywords: svc.keywords,
|
||||
sortOrder: svc.sortOrder
|
||||
}))
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
// ===== HELPERS =====
|
||||
|
||||
private async getNextCategorySortOrder(): Promise<number> {
|
||||
const max = await this.categoryRepo.createQueryBuilder('cat')
|
||||
.select('MAX(cat.sortOrder)', 'max')
|
||||
.getRawOne();
|
||||
return (max?.max ?? -1) + 1;
|
||||
}
|
||||
|
||||
private async getNextServiceSortOrder(categoryId: string): Promise<number> {
|
||||
const max = await this.serviceRepo.createQueryBuilder('svc')
|
||||
.where('svc.categoryId = :categoryId', { categoryId })
|
||||
.select('MAX(svc.sortOrder)', 'max')
|
||||
.getRawOne();
|
||||
return (max?.max ?? -1) + 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Patch,
|
||||
Body,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
Post
|
||||
} from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { SettingsService } from './settings.service';
|
||||
import { UpdateSettingsDto } from './settings.dto';
|
||||
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';
|
||||
import { AdminGuard } from 'src/auth/guards/admin.guard';
|
||||
|
||||
@Controller('settings')
|
||||
@Throttle({ default: { limit: 30, ttl: 60000 } }) // 🛡️ Basis: 30 Requests/Minute
|
||||
export class SettingsController {
|
||||
constructor(private readonly settingsService: SettingsService) {}
|
||||
|
||||
// Öffentliche Settings (für Frontend Check)
|
||||
@Get('public')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getPublicSettings() {
|
||||
return this.settingsService.getPublicSettings();
|
||||
}
|
||||
|
||||
// 🛡️ STRENG: 5 Versuche pro Minute (Brute-Force Schutz)
|
||||
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||
@Post('check-maintenance-password')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async checkMaintenancePassword(@Body('password') password: string) {
|
||||
const isValid = await this.settingsService.checkMaintenancePassword(password);
|
||||
return { valid: isValid };
|
||||
}
|
||||
|
||||
// Admin Routen
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Get()
|
||||
async getSettings() {
|
||||
return this.settingsService.getSettings();
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Patch()
|
||||
async updateSettings(@Body() dto: UpdateSettingsDto) {
|
||||
return this.settingsService.updateSettings(dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { IsBoolean, IsEmail, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class UpdateSettingsDto {
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isUnderConstruction?: boolean;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
@IsOptional()
|
||||
maintenanceMessage?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
@IsOptional()
|
||||
maintenancePassword?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
allowRegistration?: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
allowNewsletter?: boolean;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
@IsOptional()
|
||||
siteTitle?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
siteDescription?: string;
|
||||
|
||||
@IsEmail()
|
||||
@IsOptional()
|
||||
contactEmail?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
@IsOptional()
|
||||
contactPhone?: string;
|
||||
}
|
||||
|
||||
export class PublicSettingsDto {
|
||||
isUnderConstruction: boolean;
|
||||
maintenanceMessage?: string;
|
||||
siteTitle?: string;
|
||||
siteDescription?: string;
|
||||
allowRegistration?: boolean;
|
||||
allowNewsletter?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity('settings')
|
||||
export class Settings {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isUnderConstruction: boolean;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
maintenanceMessage: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||
maintenancePassword: string;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
allowRegistration: boolean;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
allowNewsletter: boolean;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
siteTitle: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
siteDescription: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
contactEmail: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||
contactPhone: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Settings } from './settings.entity';
|
||||
import { SettingsService } from './settings.service';
|
||||
import { SettingsController } from './settings.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Settings])],
|
||||
controllers: [SettingsController],
|
||||
providers: [SettingsService],
|
||||
exports: [SettingsService],
|
||||
})
|
||||
export class SettingsModule {}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Settings } from './settings.entity';
|
||||
import { UpdateSettingsDto, PublicSettingsDto } from './settings.dto';
|
||||
|
||||
@Injectable()
|
||||
export class SettingsService implements OnModuleInit {
|
||||
private settingsId: string | null = null;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Settings)
|
||||
private readonly settingsRepo: Repository<Settings>,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
// Initialisiere Settings falls noch nicht vorhanden
|
||||
await this.initializeSettings();
|
||||
}
|
||||
|
||||
private async initializeSettings(): Promise<void> {
|
||||
// Prüfe ob Settings existieren
|
||||
const settings = await this.settingsRepo.find({
|
||||
take: 1,
|
||||
order: { createdAt: 'ASC' }
|
||||
});
|
||||
|
||||
if (settings.length === 0) {
|
||||
// Erstelle Default Settings
|
||||
const newSettings = await this.createDefaultSettings();
|
||||
this.settingsId = newSettings.id;
|
||||
} else {
|
||||
// Speichere die ID für schnelleren Zugriff
|
||||
this.settingsId = settings[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
private async createDefaultSettings(): Promise<Settings> {
|
||||
const settings = this.settingsRepo.create({
|
||||
isUnderConstruction: false,
|
||||
maintenanceMessage: 'Die Seite wird gerade gewartet. Bitte versuchen Sie es später erneut.',
|
||||
maintenancePassword: 'lm',
|
||||
allowRegistration: true,
|
||||
allowNewsletter: true,
|
||||
siteTitle: 'LeonardsMedia',
|
||||
siteDescription: 'Webentwicklung und digitale Lösungen',
|
||||
contactEmail: 'info@leonardsmedia.de',
|
||||
contactPhone: '+49 123 456789',
|
||||
});
|
||||
|
||||
return this.settingsRepo.save(settings);
|
||||
}
|
||||
|
||||
async getSettings(): Promise<Settings> {
|
||||
// Wenn wir die ID haben, nutze sie
|
||||
if (this.settingsId) {
|
||||
const settings = await this.settingsRepo.findOne({
|
||||
where: { id: this.settingsId }
|
||||
});
|
||||
|
||||
if (settings) {
|
||||
return settings;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Finde die ersten Settings
|
||||
const allSettings = await this.settingsRepo.find({
|
||||
take: 1,
|
||||
order: { createdAt: 'ASC' }
|
||||
});
|
||||
|
||||
if (allSettings.length === 0) {
|
||||
// Keine Settings gefunden, erstelle neue
|
||||
const newSettings = await this.createDefaultSettings();
|
||||
this.settingsId = newSettings.id;
|
||||
return newSettings;
|
||||
}
|
||||
|
||||
// Cache die ID für zukünftige Anfragen
|
||||
this.settingsId = allSettings[0].id;
|
||||
return allSettings[0];
|
||||
}
|
||||
|
||||
async getPublicSettings(): Promise<PublicSettingsDto> {
|
||||
const settings = await this.getSettings();
|
||||
|
||||
return {
|
||||
isUnderConstruction: settings.isUnderConstruction,
|
||||
maintenanceMessage: settings.maintenanceMessage,
|
||||
siteTitle: settings.siteTitle,
|
||||
siteDescription: settings.siteDescription,
|
||||
allowRegistration: settings.allowRegistration,
|
||||
allowNewsletter: settings.allowNewsletter,
|
||||
};
|
||||
}
|
||||
|
||||
async updateSettings(dto: UpdateSettingsDto): Promise<Settings> {
|
||||
const settings = await this.getSettings();
|
||||
Object.assign(settings, dto);
|
||||
const updated = await this.settingsRepo.save(settings);
|
||||
|
||||
// Update cached ID
|
||||
this.settingsId = updated.id;
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async checkMaintenancePassword(password: string): Promise<boolean> {
|
||||
const settings = await this.getSettings();
|
||||
return settings.maintenancePassword === password;
|
||||
}
|
||||
|
||||
async isUnderConstruction(): Promise<boolean> {
|
||||
const settings = await this.getSettings();
|
||||
return settings.isUnderConstruction;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Param,
|
||||
Patch,
|
||||
Delete,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { UsersService } from './users.service';
|
||||
import { CreateUserDto, LoginDto, NewsletterSubscribeDto, UpdateUserDto } from './users.dto';
|
||||
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';
|
||||
import { AdminGuard } from 'src/auth/guards/admin.guard';
|
||||
|
||||
@Controller('users')
|
||||
@Throttle({ default: { limit: 30, ttl: 60000 } }) // 🛡️ Basis: 30 Requests/Minute
|
||||
export class UsersController {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
// 🛡️ STRENG: 3 Registrierungen pro Stunde
|
||||
@Throttle({ default: { limit: 3, ttl: 3600000 } })
|
||||
@Post('register')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async register(@Body() dto: CreateUserDto) {
|
||||
return this.usersService.create(dto);
|
||||
}
|
||||
|
||||
// 🛡️ STRENG: 5 Login-Versuche pro Minute
|
||||
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async login(@Body() dto: LoginDto) {
|
||||
const user = await this.usersService.login(dto);
|
||||
return {
|
||||
message: 'Login successful',
|
||||
user,
|
||||
};
|
||||
}
|
||||
|
||||
// 🛡️ STRENG: 3 Newsletter-Anmeldungen pro Stunde
|
||||
@Throttle({ default: { limit: 3, ttl: 3600000 } })
|
||||
@Post('newsletter/subscribe')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async subscribeNewsletter(@Body() dto: NewsletterSubscribeDto) {
|
||||
return this.usersService.subscribeNewsletter(dto);
|
||||
}
|
||||
|
||||
@Post('newsletter/unsubscribe')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async unsubscribeNewsletter(@Body() body: { email: string }) {
|
||||
return this.usersService.unsubscribeNewsletter(body.email);
|
||||
}
|
||||
|
||||
// Admin Routen
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Get('newsletter/subscribers')
|
||||
async getNewsletterSubscribers() {
|
||||
return this.usersService.getNewsletterSubscribers();
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Get('stats')
|
||||
async getStats() {
|
||||
const totalUsers = await this.usersService.count();
|
||||
const newsletterSubscribers = await this.usersService.countNewsletterSubscribers();
|
||||
|
||||
return {
|
||||
totalUsers,
|
||||
newsletterSubscribers,
|
||||
subscriberRate: totalUsers > 0
|
||||
? Math.round((newsletterSubscribers / totalUsers) * 100)
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// User Management Routen
|
||||
// Später: nur Admin oder der User selbst darf zugreifen
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Get()
|
||||
async findAll() {
|
||||
return this.usersService.findAll();
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string) {
|
||||
return this.usersService.findOne(id);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Patch(':id')
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateUserDto
|
||||
) {
|
||||
return this.usersService.update(id, dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async delete(@Param('id') id: string) {
|
||||
return this.usersService.delete(id);
|
||||
}
|
||||
|
||||
// Eigenes Profil abrufen (wenn eingeloggt)
|
||||
// @UseGuards(JwtAuthGuard)
|
||||
// @Get('me')
|
||||
// async getProfile(@CurrentUser() user: User) {
|
||||
// return this.usersService.findOne(user.id);
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { IsEmail, IsString, IsBoolean, IsOptional, MinLength, MaxLength, IsEnum } from 'class-validator';
|
||||
import { UserRole } from './users.entity';
|
||||
|
||||
export class CreateUserDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(100)
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8, { message: 'Passwort muss mindestens 8 Zeichen lang sein' })
|
||||
password: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
wantsNewsletter?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateUserDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
wantsNewsletter?: boolean;
|
||||
|
||||
@IsEnum(UserRole)
|
||||
@IsOptional()
|
||||
role?: UserRole;
|
||||
}
|
||||
|
||||
export class LoginDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
password: string;
|
||||
}
|
||||
|
||||
export class NewsletterSubscribeDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ContactRequest } from 'src/contact-requests/contact-requests.entity';
|
||||
import {
|
||||
Entity, PrimaryGeneratedColumn, Column,
|
||||
CreateDateColumn, UpdateDateColumn, OneToMany, Index
|
||||
} from 'typeorm';
|
||||
|
||||
export enum UserRole {
|
||||
USER = 'user',
|
||||
ADMIN = 'admin',
|
||||
}
|
||||
|
||||
@Entity('users')
|
||||
export class User {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ unique: true }) // in PG per Migration auf CITEXT umstellen
|
||||
email: string;
|
||||
|
||||
@Column()
|
||||
name: string;
|
||||
|
||||
@Column({ select: false }) // optional: schützt vor versehentlichem Auslesen
|
||||
password: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: UserRole,
|
||||
enumName: 'user_role',
|
||||
default: UserRole.USER,
|
||||
})
|
||||
role: UserRole;
|
||||
|
||||
@Index()
|
||||
@Column({ default: false })
|
||||
wantsNewsletter: boolean;
|
||||
|
||||
@Index()
|
||||
@Column({ default: false })
|
||||
isVerified: boolean;
|
||||
|
||||
@Column({ nullable: true })
|
||||
verificationToken: string | null;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz', default: () => 'now()' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'timestamptz', default: () => 'now()' })
|
||||
updatedAt: Date;
|
||||
|
||||
@OneToMany(() => ContactRequest, request => request.user)
|
||||
contactRequests: ContactRequest[];
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { UsersController } from './users.controller';
|
||||
import { UsersService } from './users.service';
|
||||
import { User } from './users.entity';
|
||||
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([User])
|
||||
],
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule { }
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Injectable, NotFoundException, ConflictException, UnauthorizedException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { User } from './users.entity';
|
||||
import { CreateUserDto, LoginDto, NewsletterSubscribeDto, UpdateUserDto } from './users.dto';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private readonly userRepo: Repository<User>,
|
||||
) { }
|
||||
|
||||
async create(dto: CreateUserDto): Promise<User> {
|
||||
// Prüfe ob Email bereits existiert
|
||||
const existingUser = await this.userRepo.findOne({
|
||||
where: { email: dto.email }
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
throw new ConflictException('Email already exists');
|
||||
}
|
||||
|
||||
// Passwort hashen
|
||||
const hashedPassword = await bcrypt.hash(dto.password, 10);
|
||||
|
||||
const user = this.userRepo.create({
|
||||
...dto,
|
||||
password: hashedPassword,
|
||||
});
|
||||
|
||||
const savedUser = await this.userRepo.save(user);
|
||||
|
||||
// Passwort aus Response entfernen
|
||||
delete savedUser.password;
|
||||
return savedUser;
|
||||
}
|
||||
|
||||
async findAll(): Promise<User[]> {
|
||||
const users = await this.userRepo.find({
|
||||
order: { createdAt: 'DESC' },
|
||||
select: ['id', 'email', 'name', 'wantsNewsletter', 'isVerified', 'createdAt', 'updatedAt', 'role',],
|
||||
});
|
||||
return users;
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<User> {
|
||||
const user = await this.userRepo.findOne({
|
||||
where: { id },
|
||||
select: ['id', 'email', 'name', 'wantsNewsletter', 'isVerified', 'createdAt', 'updatedAt', 'role',],
|
||||
relations: ['generatedPages', 'contactRequests'],
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException(`User with ID ${id} not found`);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
return this.userRepo.findOne({ where: { email } });
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateUserDto): Promise<User> {
|
||||
const user = await this.userRepo.findOne({ where: { id } });
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException(`User with ID ${id} not found`);
|
||||
}
|
||||
|
||||
Object.assign(user, dto);
|
||||
const updated = await this.userRepo.save(user);
|
||||
delete updated.password;
|
||||
return updated;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const result = await this.userRepo.delete(id);
|
||||
if (result.affected === 0) {
|
||||
throw new NotFoundException(`User with ID ${id} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
async validateUser(email: string, password: string): Promise<User> {
|
||||
const user = await this.userRepo.findOne({ where: { email } });
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
const isPasswordValid = await bcrypt.compare(password, user.password);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
delete user.password;
|
||||
return user;
|
||||
}
|
||||
|
||||
async login(dto: LoginDto): Promise<User> {
|
||||
return this.validateUser(dto.email, dto.password);
|
||||
}
|
||||
|
||||
async subscribeNewsletter(dto: NewsletterSubscribeDto): Promise<{ message: string }> {
|
||||
let user = await this.findByEmail(dto.email);
|
||||
|
||||
if (user) {
|
||||
// User existiert bereits, Newsletter-Flag updaten
|
||||
if (user.wantsNewsletter) {
|
||||
return { message: 'Already subscribed to newsletter' };
|
||||
}
|
||||
|
||||
user.wantsNewsletter = true;
|
||||
await this.userRepo.save(user);
|
||||
return { message: 'Successfully subscribed to newsletter' };
|
||||
}
|
||||
|
||||
// Neuer User nur für Newsletter
|
||||
user = this.userRepo.create({
|
||||
email: dto.email,
|
||||
name: dto.name || dto.email.split('@')[0],
|
||||
password: await bcrypt.hash(Math.random().toString(36), 10), // Dummy Password
|
||||
wantsNewsletter: true,
|
||||
});
|
||||
|
||||
await this.userRepo.save(user);
|
||||
return { message: 'Successfully subscribed to newsletter' };
|
||||
}
|
||||
|
||||
async unsubscribeNewsletter(email: string): Promise<{ message: string }> {
|
||||
const user = await this.findByEmail(email);
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('Email not found');
|
||||
}
|
||||
|
||||
user.wantsNewsletter = false;
|
||||
await this.userRepo.save(user);
|
||||
|
||||
return { message: 'Successfully unsubscribed from newsletter' };
|
||||
}
|
||||
|
||||
async getNewsletterSubscribers(): Promise<User[]> {
|
||||
return this.userRepo.find({
|
||||
where: { wantsNewsletter: true },
|
||||
select: ['id', 'email', 'name', 'createdAt'],
|
||||
});
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
return this.userRepo.count();
|
||||
}
|
||||
|
||||
async countNewsletterSubscribers(): Promise<number> {
|
||||
return this.userRepo.count({ where: { wantsNewsletter: true } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import * as request from 'supertest';
|
||||
import { AppModule } from './../src/app.module';
|
||||
|
||||
describe('AppController (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeEach(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it('/ (GET)', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/')
|
||||
.expect(200)
|
||||
.expect('Hello World!');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": false,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.ts]
|
||||
quote_type = single
|
||||
ij_typescript_use_double_quotes = false
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
File diff suppressed because one or more lines are too long
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||
"recommendations": ["angular.ng-template"]
|
||||
}
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "ng serve",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: start",
|
||||
"url": "http://localhost:4200/"
|
||||
},
|
||||
{
|
||||
"name": "ng test",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: test",
|
||||
"url": "http://localhost:9876/debug.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+42
@@ -0,0 +1,42 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "start",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "(.*?)"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation complete"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "test",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "(.*?)"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation complete"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
# WebsiteBaseV2
|
||||
|
||||
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 18.2.8.
|
||||
|
||||
## Development server
|
||||
|
||||
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files.
|
||||
|
||||
## Code scaffolding
|
||||
|
||||
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
|
||||
|
||||
## Build
|
||||
|
||||
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory.
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
|
||||
|
||||
## Running end-to-end tests
|
||||
|
||||
Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities.
|
||||
|
||||
## Further help
|
||||
|
||||
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
||||
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"WebsiteBaseV2": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:application",
|
||||
"options": {
|
||||
"outputPath": "dist/website-base-v2",
|
||||
"index": "src/index.html",
|
||||
"browser": "src/main.ts",
|
||||
"polyfills": [
|
||||
"zone.js"
|
||||
],
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
},
|
||||
{
|
||||
"glob": "sitemap.xml",
|
||||
"input": "src",
|
||||
"output": "/"
|
||||
},
|
||||
{
|
||||
"glob": "robots.txt",
|
||||
"input": "src",
|
||||
"output": "/"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "2MB",
|
||||
"maximumError": "4MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "50kB",
|
||||
"maximumError": "100kB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "WebsiteBaseV2:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "WebsiteBaseV2:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"options": {
|
||||
"polyfills": [
|
||||
"zone.js",
|
||||
"zone.js/testing"
|
||||
],
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cli": {
|
||||
"analytics": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export const environment = {
|
||||
production: true,
|
||||
apiUrl: 'https://api.leonardsmedia.de' // Später deine Production URL
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export const environment = {
|
||||
production: false,
|
||||
apiUrl: 'http://192.168.178.111:3000'
|
||||
// apiUrl: 'https://api.leonardsmedia.de'
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Leonards & Brandenburger IT</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
|
||||
<!-- Preconnect für schnelleres Font-Loading -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<!-- Inter Font (fehlte!) + Material Symbols mit font-display=swap -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0&display=swap" rel="stylesheet">
|
||||
<meta name="description" content="IT-Dienstleistungen, Webentwicklung und SEO aus einer Hand. Leonards & Brandenburger IT – pragmatisch, transparent und zuverlässig.">
|
||||
<meta name="robots" content="index,follow">
|
||||
|
||||
<!-- PWA Theme Color -->
|
||||
<meta name="theme-color" content="#C2410C">
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#1e293b">
|
||||
|
||||
<!-- iOS PWA Support -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<meta name="apple-mobile-web-app-title" content="L&B IT">
|
||||
<link rel="apple-touch-icon" href="/assets/icons/icon-152x152.svg">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/icon-192x192.svg">
|
||||
|
||||
<!-- Windows/MS Tiles -->
|
||||
<meta name="msapplication-TileColor" content="#C2410C">
|
||||
<meta name="msapplication-TileImage" content="/assets/icons/icon-144x144.svg">
|
||||
<meta name="msapplication-config" content="none">
|
||||
|
||||
<!-- Disable phone number detection -->
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
|
||||
<!-- Open Graph defaults (werden zur Laufzeit aktualisiert) -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="Leonards & Brandenburger IT">
|
||||
<meta property="og:locale" content="de_DE">
|
||||
<meta property="og:title" content="Leonards & Brandenburger IT">
|
||||
<meta property="og:description" content="IT-Dienstleistungen, Webentwicklung und SEO aus einer Hand. Leonards & Brandenburger IT – pragmatisch, transparent und zuverlässig.">
|
||||
<meta property="og:image" content="/assets/LM_Logos/Logo1.png">
|
||||
<meta property="og:url" content="/">
|
||||
|
||||
<!-- Twitter Card defaults -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="Leonards & Brandenburger IT">
|
||||
<meta name="twitter:description" content="IT-Dienstleistungen, Webentwicklung und SEO aus einer Hand. Leonards & Brandenburger IT – pragmatisch, transparent und zuverlässig.">
|
||||
<meta name="twitter:image" content="/assets/LM_Logos/Logo1.png">
|
||||
|
||||
<!-- Canonical (wird dynamisch aktualisiert) -->
|
||||
<link rel="canonical" href="/" />
|
||||
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
|
||||
<!-- JSON-LD strukturierte Daten werden dynamisch von SeoService injiziert -->
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,6 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { AppComponent } from './app/app.component';
|
||||
|
||||
bootstrapApplication(AppComponent, appConfig)
|
||||
.catch((err) => console.error(err));
|
||||
@@ -0,0 +1,23 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Angular SPA Routing - alle Routen auf index.html umleiten
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Caching für statische Assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Gzip Kompression
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
gzip_min_length 1000;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "website-base-v2",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test",
|
||||
"prebuild": "node tools/generate-seo-assets.mjs"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^18.2.0",
|
||||
"@angular/common": "^18.2.0",
|
||||
"@angular/compiler": "^18.2.0",
|
||||
"@angular/core": "^18.2.0",
|
||||
"@angular/forms": "^18.2.0",
|
||||
"@angular/platform-browser": "^18.2.0",
|
||||
"@angular/platform-browser-dynamic": "^18.2.0",
|
||||
"@angular/router": "^18.2.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"jspdf": "^4.0.0",
|
||||
"tslib": "^2.3.0",
|
||||
"zone.js": "~0.14.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^18.2.8",
|
||||
"@angular/cli": "^18.2.8",
|
||||
"@angular/compiler-cli": "^18.2.0",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"@types/jspdf": "^1.3.3",
|
||||
"jasmine-core": "~5.2.0",
|
||||
"karma": "~6.4.0",
|
||||
"karma-chrome-launcher": "~3.2.0",
|
||||
"karma-coverage": "~2.2.0",
|
||||
"karma-jasmine": "~5.1.0",
|
||||
"karma-jasmine-html-reporter": "~2.1.0",
|
||||
"typescript": "~5.5.2"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 889 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 799 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user