initial commit
Deploy to Dev Server / deploy (push) Has been cancelled

This commit is contained in:
2026-03-26 16:10:45 +01:00
commit ae33874ae0
406 changed files with 72867 additions and 0 deletions
+39
View File
@@ -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
+25
View File
@@ -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',
},
};
+4
View File
@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}
+23
View File
@@ -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
+85
View File
@@ -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>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](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).
+790
View File
@@ -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"
}
]
+549
View File
@@ -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
}
]
}
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+90
View File
@@ -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 || '';
}
}
+109
View File
@@ -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;
}
}
+12
View File
@@ -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' };
}
}
+90
View File
@@ -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 { }
+8
View File
@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}
+52
View File
@@ -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;
}
}
+37
View File
@@ -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 { }
+122
View File
@@ -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);
}
}
+80
View File
@@ -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 {}
+282
View File
@@ -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`);
}
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { EmailService } from './email.service';
@Module({
providers: [EmailService],
exports: [EmailService],
})
export class EmailModule {}
+452
View File
@@ -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);
}
}
+120
View File
@@ -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);
}
}
+119
View File
@@ -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[];
}
+39
View File
@@ -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;
}
+13
View File
@@ -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 { }
+192
View File
@@ -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);
}
}
+123
View File
@@ -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];
}
}
+55
View File
@@ -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);
}
}
+52
View File
@@ -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;
}
}
+116
View File
@@ -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);
// }
}
+51
View File
@@ -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;
}
+53
View File
@@ -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[];
}
+16
View File
@@ -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 { }
+160
View File
@@ -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 } });
}
}
+24
View File
@@ -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!');
});
});
+9
View File
@@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
+21
View File
@@ -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
}
}
+17
View File
@@ -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
View File
@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}
+20
View File
@@ -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"
}
]
}
+42
View File
@@ -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"
}
}
}
}
]
}
+27
View File
@@ -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.
+115
View File
@@ -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'
};
+64
View File
@@ -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>
+6
View File
@@ -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));
+23
View File
@@ -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;
}
+41
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 999 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 604 KiB

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