Compare commits

...

15 Commits

Author SHA1 Message Date
e0803715a7 chore: avb transition and automated testing setup
Some checks failed
🚀 Build & Deploy / 🔍 Prepare (push) Successful in 6s
🚀 Build & Deploy / 🧪 QA (push) Failing after 1m0s
🚀 Build & Deploy / 🏗️ Build (push) Successful in 13m28s
🚀 Build & Deploy / 🚀 Deploy (push) Has been skipped
🚀 Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
🚀 Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-04-21 18:47:43 +02:00
8427d348dd fix: ensure smtp metadata logger doesn't crash in tests
Some checks failed
🚀 Build & Deploy / 🔍 Prepare (push) Successful in 4s
🚀 Build & Deploy / 🧪 QA (push) Successful in 2m13s
🚀 Build & Deploy / 🏗️ Build (push) Successful in 14m2s
🚀 Build & Deploy / 🚀 Deploy (push) Successful in 11s
🚀 Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 2m51s
🚀 Build & Deploy / 🔔 Notify (push) Successful in 2s
Nightly QA / call-qa-workflow (push) Failing after 40s
2026-04-15 00:05:45 +02:00
7109b3c32e fix: add detailed smtp logging to debug confirmation email
Some checks failed
🚀 Build & Deploy / 🔍 Prepare (push) Successful in 4s
🚀 Build & Deploy / 🧪 QA (push) Failing after 49s
🚀 Build & Deploy / 🚀 Deploy (push) Has been skipped
🚀 Build & Deploy / 🏗️ Build (push) Successful in 13m44s
🚀 Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
🚀 Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-04-14 22:28:45 +02:00
18717e7faf fix: resolve vitest hoisting issue in contact api test
Some checks failed
🚀 Build & Deploy / 🔍 Prepare (push) Successful in 7s
🚀 Build & Deploy / 🧪 QA (push) Successful in 2m9s
🚀 Build & Deploy / 🏗️ Build (push) Successful in 13m8s
🚀 Build & Deploy / 🚀 Deploy (push) Successful in 13s
🚀 Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 3m26s
🚀 Build & Deploy / 🔔 Notify (push) Successful in 2s
Nightly QA / call-qa-workflow (push) Failing after 34s
2026-04-13 22:32:16 +02:00
3478f0f42f fix: remove problematic docker maintenance step from prepare job
Some checks failed
🚀 Build & Deploy / 🔍 Prepare (push) Successful in 5s
🚀 Build & Deploy / 🧪 QA (push) Failing after 39s
🚀 Build & Deploy / 🏗️ Build (push) Successful in 12m34s
🚀 Build & Deploy / 🚀 Deploy (push) Has been skipped
🚀 Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
🚀 Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-04-13 21:45:42 +02:00
836cc66334 chore: re-trigger pipeline after network failure in #2226
Some checks failed
🚀 Build & Deploy / 🔍 Prepare (push) Failing after 13s
🚀 Build & Deploy / 🧪 QA (push) Has been skipped
🚀 Build & Deploy / 🏗️ Build (push) Has been skipped
🚀 Build & Deploy / 🚀 Deploy (push) Has been skipped
🚀 Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
🚀 Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-04-13 19:11:33 +02:00
9bb2186f7a test: update contact api test to mock nodemailer
Some checks failed
🚀 Build & Deploy / 🔍 Prepare (push) Failing after 6s
🚀 Build & Deploy / 🧪 QA (push) Has been skipped
🚀 Build & Deploy / 🏗️ Build (push) Has been skipped
🚀 Build & Deploy / 🚀 Deploy (push) Has been skipped
🚀 Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
🚀 Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-04-13 19:11:04 +02:00
55123132f4 fix(mail): bypass payload mail adapter and use standalone nodemailer
Some checks failed
🚀 Build & Deploy / 🔍 Prepare (push) Successful in 6s
🚀 Build & Deploy / 🧪 QA (push) Failing after 40s
🚀 Build & Deploy / 🏗️ Build (push) Successful in 12m4s
🚀 Build & Deploy / 🚀 Deploy (push) Has been skipped
🚀 Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
🚀 Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-04-13 17:28:19 +02:00
bf1dfe4015 fix(mail): robust recipient logic and sentry dsn verification
All checks were successful
🚀 Build & Deploy / 🔍 Prepare (push) Successful in 5s
🚀 Build & Deploy / 🧪 QA (push) Successful in 2m18s
🚀 Build & Deploy / 🏗️ Build (push) Successful in 12m3s
🚀 Build & Deploy / 🚀 Deploy (push) Successful in 14s
🚀 Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 3m58s
🚀 Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-04-13 11:49:09 +02:00
f03e417eb7 fix(prod): stabilize production environment and fix email adapter initialization
Some checks failed
🚀 Build & Deploy / 🔍 Prepare (push) Successful in 7s
🚀 Build & Deploy / 🧪 QA (push) Successful in 1m41s
🚀 Build & Deploy / 🏗️ Build (push) Successful in 12m22s
🚀 Build & Deploy / 🚀 Deploy (push) Successful in 13s
🚀 Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 3m26s
🚀 Build & Deploy / 🔔 Notify (push) Successful in 2s
Nightly QA / call-qa-workflow (push) Failing after 34s
2026-04-12 15:09:39 +02:00
e0312aeba9 chore: trigger deployment after infra disk cleanup
All checks were successful
🚀 Build & Deploy / 🔍 Prepare (push) Successful in 6s
🚀 Build & Deploy / 🧪 QA (push) Successful in 2m17s
🚀 Build & Deploy / 🏗️ Build (push) Successful in 17m23s
🚀 Build & Deploy / 🚀 Deploy (push) Successful in 13s
🚀 Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 3m17s
🚀 Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-04-12 11:29:44 +02:00
d5c0b91c97 fix(analytics): restore Umami tracking by fixing build-args and bypassing gatekeeper
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 14s
Build & Deploy / 🏗️ Build (push) Failing after 5m6s
Build & Deploy / 🧪 QA (push) Failing after 5m22s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 4s
Nightly QA / call-qa-workflow (push) Failing after 33s
2026-04-12 01:55:48 +02:00
155918e265 chore: trigger deployment after infrastructure and db fixes
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 14s
Build & Deploy / 🧪 QA (push) Successful in 8m56s
Build & Deploy / 🏗️ Build (push) Successful in 15m30s
Build & Deploy / 🚀 Deploy (push) Successful in 15s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 4m18s
Build & Deploy / 🔔 Notify (push) Successful in 2s
Nightly QA / call-qa-workflow (push) Failing after 33s
2026-04-10 14:13:24 +02:00
fb5b55f1dd fix(ci): add QEMU support for multi-arch builds (linux/amd64)
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Successful in 2m2s
Build & Deploy / 🏗️ Build (push) Successful in 15m12s
Build & Deploy / 🚀 Deploy (push) Failing after 1m36s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-04-10 12:44:00 +02:00
9f00828b80 fix: restore glitchtip nesting and fix type errors
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 2m32s
Build & Deploy / 🏗️ Build (push) Successful in 5m10s
Build & Deploy / 🚀 Deploy (push) Successful in 42s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 4m17s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-04-09 12:24:33 +02:00
18 changed files with 639 additions and 58 deletions

View File

@@ -1,4 +1,5 @@
name: Build & Deploy
# Infrastructure Maintenance: Production stabilization and email adapter fix triggered on 2026-04-12.
name: 🚀 Build & Deploy
on:
push:
@@ -36,13 +37,6 @@ jobs:
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: 🧹 Maintenance (High Density Cleanup)
shell: bash
run: |
echo "Purging old build layers and dangling images..."
docker image prune -f
docker builder prune -f --filter "until=6h"
- name: Checkout repository
uses: actions/checkout@v4
with:
@@ -187,6 +181,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: 🐳 Set up QEMU
uses: docker/setup-qemu-action@v3
- name: 🐳 Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: 🔐 Registry Login
@@ -200,6 +196,8 @@ jobs:
build-args: |
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }}
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
NEXT_PUBLIC_UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID || vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
NPM_TOKEN=${{ secrets.REGISTRY_PASS }}
tags: registry.infra.mintel.me/mintel/mb-grid-solutions:${{ needs.prepare.outputs.image_tag }}
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/mb-grid-solutions:buildcache
@@ -223,7 +221,7 @@ jobs:
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
# Secrets mapping (Database & CMS)
PAYLOAD_SECRET: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_PAYLOAD_SECRET) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_PAYLOAD_SECRET) || secrets.PAYLOAD_SECRET || secrets.DIRECTUS_SECRET || vars.PAYLOAD_SECRET || 'you-need-to-set-a-payload-secret' }}
PAYLOAD_SECRET: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_PAYLOAD_SECRET) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_PAYLOAD_SECRET) || secrets.PAYLOAD_SECRET || secrets.MINTEL_PRIVATE_TOKEN || secrets.DIRECTUS_SECRET || vars.PAYLOAD_SECRET || 'you-need-to-set-a-payload-secret' }}
DATABASE_URI: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DATABASE_URI) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DATABASE_URI) || secrets.DATABASE_URI || vars.DATABASE_URI }}
POSTGRES_DB: ${{ secrets.POSTGRES_DB || vars.POSTGRES_DB || 'payload' }}
POSTGRES_USER: ${{ secrets.POSTGRES_USER || vars.POSTGRES_USER || 'directus' }}
@@ -275,6 +273,7 @@ jobs:
GATEKEEPER_ORIGIN="$NEXT_PUBLIC_BASE_URL/gatekeeper"
# Generate Environment File
echo "🔍 Verifying Sentry DSN presence..."
cat > .env.deploy << EOF
# Generated by CI - $TARGET
IMAGE_TAG=$IMAGE_TAG

26
.htmlvalidate.json Normal file
View File

@@ -0,0 +1,26 @@
{
"extends": ["html-validate:recommended", "html-validate:document"],
"rules": {
"require-sri": "off",
"meta-refresh": "off",
"heading-level": "warn",
"no-trailing-whitespace": "off",
"wcag/h37": "warn",
"no-inline-style": "off",
"svg-focusable": "off",
"attribute-boolean-style": "off",
"attr-case": "off",
"void-style": "off",
"no-implicit-button-type": "off",
"unique-landmark": "off",
"long-title": "off",
"valid-id": "off",
"element-required-attributes": "off",
"attribute-empty-style": "off",
"element-permitted-content": "off",
"element-required-content": "off",
"element-permitted-parent": "off",
"no-implicit-close": "off",
"close-order": "off"
}
}

21
.pa11yci.json Normal file
View File

@@ -0,0 +1,21 @@
{
"defaults": {
"standard": "WCAG2AA",
"runners": ["axe", "htmlcs"],
"ignore": [],
"timeout": 50000,
"wait": 1000,
"chromeLaunchConfig": {
"args": ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"]
},
"threshold": 25
},
"urls": [
"http://localhost:3000/",
"http://localhost:3000/ueber-uns",
"http://localhost:3000/kontakt",
"http://localhost:3000/impressum",
"http://localhost:3000/datenschutz",
"http://localhost:3000/agb"
]
}

View File

@@ -3,7 +3,7 @@ import fs from "fs";
import path from "path";
export default function AGB() {
const filePath = path.join(process.cwd(), "context/agbs.md");
const filePath = path.join(process.cwd(), "context/avbs.md");
const fileContent = fs.readFileSync(filePath, "utf8");
// Split by double newlines to get major blocks (headers + their first paragraphs, or subsequent paragraphs)
@@ -74,7 +74,7 @@ export default function AGB() {
<p className="text-slate-500 font-medium">{stand}</p>
</div>
<a
href="/assets/AGB MB Grid 3-2026.pdf"
href="/assets/AVB MB Grid 4-2026.pdf"
download
className="btn-primary !py-3 !px-6 flex items-center gap-2"
>

View File

@@ -8,6 +8,7 @@ import {
ConfirmationMessage,
} from "@mintel/mail";
import React from "react";
import nodemailer from "nodemailer";
export async function POST(req: Request) {
const services = getServerAppServices();
@@ -77,11 +78,33 @@ export async function POST(req: Request) {
services.errors.captureException(payloadError, { phase: "payload_save" });
}
// 2. Email sending via Payload (which uses configured nodemailer)
// 2. Email sending via standalone Nodemailer (bypassing Payload adapter)
try {
const { config } = await import("@/lib/config");
const clientName = "MB Grid Solutions";
// Robust recipient resolution
const recipients = Array.isArray(config.mail.recipients)
? config.mail.recipients.filter(Boolean)
: [];
const to =
recipients.length > 0
? recipients.join(",")
: process.env.CONTACT_RECIPIENT || "info@mb-grid-solutions.com";
logger.info("Instantiating standalone nodemailer transport");
const transporter = nodemailer.createTransport({
host: config.mail.host,
port: config.mail.port,
auth: {
user: config.mail.user,
pass: config.mail.pass,
},
});
logger.info("Preparing to send notification email", { to, host: config.mail.host });
// 2a. Notification to MB Grid
const notificationHtml = await render(
React.createElement(ContactFormNotification, {
@@ -92,19 +115,23 @@ export async function POST(req: Request) {
}),
);
await payload.sendEmail({
from: config.mail.from,
to:
config.mail.recipients.join(",") ||
process.env.CONTACT_RECIPIENT ||
"info@mb-grid-solutions.com",
replyTo: email,
subject: `Kontaktanfrage von ${name}`,
html: notificationHtml,
});
try {
const info = await transporter.sendMail({
from: config.mail.from,
to,
replyTo: email,
subject: `Kontaktanfrage von ${name}`,
html: notificationHtml,
});
logger.info("Notification email sent successfully", { messageId: info?.messageId });
} catch (notifyError) {
logger.error("Failed to send notification email", { error: notifyError });
throw notifyError; // Re-throw to be caught by the outer SMTP catch
}
// 2b. Confirmation to the User
try {
logger.info("Preparing to send confirmation email", { to: email });
const confirmationHtml = await render(
React.createElement(ConfirmationMessage, {
name,
@@ -112,16 +139,17 @@ export async function POST(req: Request) {
}),
);
await payload.sendEmail({
const info = await transporter.sendMail({
from: config.mail.from,
to: email,
subject: `Ihre Kontaktanfrage bei ${clientName}`,
html: confirmationHtml,
});
logger.info("Confirmation email sent successfully", { messageId: info?.messageId });
} catch (confirmError) {
logger.warn(
"Failed to send confirmation email, but notification was sent",
{ error: confirmError },
{ error: confirmError instanceof Error ? confirmError.message : String(confirmError) },
);
}

View File

@@ -1,12 +1,12 @@
Liefer- und Zahlungsbedingungen
Stand März 2026
Allgemeine Verkaufsbedingungen (AVB)
Stand April 2026
1. Allgemeines
Diese Liefer- und Zahlungsbedingungen (L&Z) der MB Grid Solutions & Services gelten ausschließlich; entgegenstehende oder von unseren Bedingungen abweichende Bedingungen des Kunden erkennen wir nicht an, es sei denn, wir hätten ausdrücklich schriftlich ihrer Geltung zugestimmt. Unsere L&Z gelten auch dann, wenn wir in Kenntnis entgegenstehender oder von unseren L&Z abweichender Bedingungen des Bestellers die Lieferung an diesen vorbehaltlos ausführen. Unsere L&Z gelten nur gegenüber Unternehmern im Sinn von § 310 Abs. 1 BGB sowie juristischen Personen des öffentlichen Rechts oder öffentlich-rechtliches Sondervermögen.
Diese Allgemeinen Verkaufsbedingungen (AVB) der MB Grid Solutions & Services gelten ausschließlich; entgegenstehende oder von unseren Bedingungen abweichende Bedingungen des Kunden erkennen wir nicht an, es sei denn, wir hätten ausdrücklich schriftlich ihrer Geltung zugestimmt. Unsere AVB gelten auch dann, wenn wir in Kenntnis entgegenstehender oder von unseren AVB abweichender Bedingungen des Bestellers die Lieferung an diesen vorbehaltlos ausführen. Unsere AVB gelten nur gegenüber Unternehmern im Sinn von § 310 Abs. 1 BGB sowie juristischen Personen des öffentlichen Rechts oder öffentlich-rechtliches Sondervermögen.
Nebenabreden, Vorbehalte, Änderungen, Ergänzungen usw. bedürfen zu ihrer Wirksamkeit unserer schriftlichen Bestätigung.
Hinweise auf die Geltung gesetzlicher Vorschriften haben nur klarstellende Bedeutung. Auch ohne eine derartige Klarstellung gelten daher die gesetzlichen Vorschriften, soweit sie in diesen L&Z nicht unmittelbar abgeändert oder ausdrücklich ausgeschlossen werden. Bezüglich Beratungsleistungen weisen wir ausdrücklich auf Punkt 17 hin.
Hinweise auf die Geltung gesetzlicher Vorschriften haben nur klarstellende Bedeutung. Auch ohne eine derartige Klarstellung gelten daher die gesetzlichen Vorschriften, soweit sie in diesen AVB nicht unmittelbar abgeändert oder ausdrücklich ausgeschlossen werden. Bezüglich Beratungsleistungen weisen wir ausdrücklich auf Punkt 17 hin.
2. Angebote
Sofern nicht ausdrücklich als bindend bezeichnet, sind unsere Angebote freibleibend; die Bestellung des Kunden ist als Angebot gemäß § 145 BGB zu qualifizieren.
@@ -73,7 +73,7 @@ Sollte es bei einer Mängelrüge zu unterschiedlichen Meinungen bezüglich des K
Wir haften unbeschränkt nur für Vorsatz und grobe Fahrlässigkeit sowie für Schäden aus einer Verletzung von Leben, Körper oder Gesundheit, die auf mindestens fahrlässiger Pflichtverletzung unsererseits oder unserer gesetzlichen Vertreter oder Erfüllungsgehilfen beruhen; ebenso haften wir unbeschränkt im Fall von uns übernommenen bzw. abgegebenen Garantien und Zusicherungen, sofern ein davon umfasster Mangel unsere Haftung auslöst sowie im Fall einer Haftung nach dem Produkthaftungsgesetz oder sonstigen Gefährdungshaftungstatbeständen. Im Fall sonstiger schuldhafter Verletzung wesentlicher Vertragspflichten („Kardinalpflichten“) ist unsere verbleibende Haftung auf den vertragstypischen vorhersehbaren Schaden beschränkt. Mangelfolgeschäden sowie entgangener Gewinn schließen wir grundsätzlich aus.
16. Kabeltrommeln
Unsere Kabel werden auf stabilen Vollholztrommeln geliefert. Bei Lagerzeiten über 3 Monaten ist zu beachten, dass die Trommel regelmäßig bewegt wird, um eine einseitige Belastung zu vermeiden. Folgeschäden bei Nichtbeachtung wie Instabilität schließen wir aus. Zusätzlich die [PDF zum Download](/assets/AGB MB Grid 3-2026.pdf). Auf Wunsch vermitteln wir Ihnen Partner, die diese Trommeln gegen eine Gebühr abholen.
Unsere Kabel werden auf stabilen Vollholztrommeln geliefert. Bei Lagerzeiten über 3 Monaten ist zu beachten, dass die Trommel regelmäßig bewegt wird, um eine einseitige Belastung zu vermeiden. Folgeschäden bei Nichtbeachtung wie Instabilität schließen wir aus. Zusätzlich die [PDF zum Download](/assets/AVB MB Grid 4-2026.pdf). Auf Wunsch vermitteln wir Ihnen Partner, die diese Trommeln gegen eine Gebühr abholen.
17. Technische Beratungsdienstleistungen
Die technische Unterstützung ersetzt weder die Fachplanung noch die Ausführungs- oder Prüfverantwortung des beauftragten Ingenieurbüros, Planers oder der ausführenden Fachfirma bzw. verantwortlichen Abteilung.
@@ -83,6 +83,6 @@ Alle Hinweise, Einschätzungen und Empfehlungen der MB Grid Solutions and Servic
18. Sonstiges
Es gilt ausschließlich das Recht der Bundesrepublik Deutschland unter Ausschluss des UN-Kaufrechts (CISG). Gerichtsstand ist nach unserer Wahl Stuttgart, der Erfüllungsort der Lieferverpflichtung oder das für den Sitz des Bestellers zuständige Gericht, sofern der Besteller Kaufmann, juristische Person des öffentlichen Rechts oder öffentlich-rechtliches Sondervermögen ist oder keinen allgemeinen Gerichtsstand im Inland hat.
Mit der Veröffentlichung der vorliegenden L&Z im Internet werden alle von uns früher verwendeten Bedingungen gegenstandslos.
Mit der Veröffentlichung der vorliegenden AVB im Internet werden alle von uns früher verwendeten Bedingungen gegenstandslos.
Remshalden, 22.1.2026

View File

@@ -20,7 +20,7 @@ services:
- "traefik.docker.network=infra"
# Public Router paths that bypass Gatekeeper auth
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-public.rule=Host(`${TRAEFIK_HOST:-mb-grid-solutions.localhost}`) && PathRegexp(`^/([a-z]{2}/)?(health|login|gatekeeper|uploads|media|robots\\.txt|manifest\\.webmanifest|sitemap(-[0-9]+)?\\.xml|(.*/)?api/og(/.*)?|(.*/)?opengraph-image.*)`)"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-public.rule=Host(`${TRAEFIK_HOST:-mb-grid-solutions.localhost}`) && PathRegexp(`^/([a-z]{2}/)?(health|login|gatekeeper|uploads|media|robots\\.txt|manifest\\.webmanifest|sitemap(-[0-9]+)?\\.xml|(.*/)?api/og(/.*)?|(.*/)?opengraph-image.*|stats(/.*)?|errors(/.*)?)`)"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-public.entrypoints=websecure"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-public.tls.certresolver=le"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-public.tls=true"

View File

@@ -36,12 +36,14 @@ function createConfig() {
},
errors: {
glitchtip: {
// Use NEXT_PUBLIC_SENTRY_DSN if available (required for browser-side SDK)
dsn: env.NEXT_PUBLIC_SENTRY_DSN || (env as any).SENTRY_DSN,
dsn: env.NEXT_PUBLIC_SENTRY_DSN || env.SENTRY_DSN,
// The proxied origin used in the frontend
proxyPath: "/errors",
enabled: Boolean(env.SENTRY_DSN),
},
},
cache: {
enabled: false,

View File

@@ -18,6 +18,7 @@ const envExtension = {
// Project specific overrides or additions
AUTH_COOKIE_NAME: z.string().default("mb_gatekeeper_session"),
NEXT_PUBLIC_SENTRY_DSN: z.string().optional(),
SENTRY_DSN: z.string().optional(),
};
/**

View File

@@ -81,7 +81,7 @@
"legal": "Rechtliches",
"impressum": "Impressum",
"datenschutz": "Datenschutz",
"agb": "AGB",
"agb": "AVB",
"rights": "Alle Rechte vorbehalten.",
"madeWith": "Entwickelt mit",
"precision": "Präzision",

View File

@@ -1,6 +1,8 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import createMiddleware from "next-intl/middleware";
export default createMiddleware({
const intlMiddleware = createMiddleware({
// A list of all locales that are supported
locales: ["de"],
@@ -11,6 +13,17 @@ export default createMiddleware({
localePrefix: "as-needed",
});
export default function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Explicit bypass for analytics and error tracking paths
if (pathname.startsWith("/stats") || pathname.startsWith("/errors")) {
return NextResponse.next();
}
return intlMiddleware(request);
}
export const config = {
// Matcher for all pages and internationalized pathnames
// excluding api, _next, static files, etc.

View File

@@ -1,6 +1,6 @@
{
"name": "mb-grid-solutions.com",
"version": "1.0.0",
"version": "1.3.13",
"type": "module",
"packageManager": "pnpm@10.18.3",
"scripts": {
@@ -17,6 +17,9 @@
"check:http": "tsx ./scripts/check-http.ts",
"check:apis": "tsx ./scripts/check-apis.ts",
"check:locale": "tsx ./scripts/check-locale.ts",
"check:html": "tsx ./scripts/check-html.ts",
"check:assets": "tsx ./scripts/check-broken-assets.ts",
"check:wcag": "tsx ./scripts/wcag-sitemap.ts",
"backup:db": "bash ./scripts/backup-db.sh"
},
"keywords": [],
@@ -72,12 +75,15 @@
"eslint": "^8.57.1",
"eslint-config-next": "15.1.6",
"happy-dom": "^20.6.1",
"html-validate": "^10.8.0",
"husky": "^9.1.7",
"jsdom": "^27.4.0",
"lint-staged": "^16.2.7",
"pa11y-ci": "^4.0.1",
"pino-pretty": "^13.1.3",
"postcss": "^8.5.6",
"prettier": "^3.5.0",
"puppeteer": "^24.37.3",
"tailwindcss": "^4.1.18",
"tsx": "^4.21.0",
"typescript": "^5.9.3",

Binary file not shown.

View File

@@ -0,0 +1,197 @@
import puppeteer, { HTTPResponse } from 'puppeteer';
import axios from 'axios';
import * as cheerio from 'cheerio';
const targetUrl =
process.argv.find((arg) => !arg.startsWith('--') && arg.startsWith('http')) ||
process.env.NEXT_PUBLIC_BASE_URL ||
'http://localhost:3000';
const limit = process.env.ASSET_CHECK_LIMIT ? parseInt(process.env.ASSET_CHECK_LIMIT) : 20;
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'mbgrid';
async function main() {
console.log(`\n🚀 Starting Strict Asset Integrity Check for: ${targetUrl}`);
console.log(`📊 Limit: ${limit} pages\n`);
// 1. Fetch Sitemap to discover all routes
const sitemapUrl = `${targetUrl.replace(/\/$/, '')}/sitemap.xml`;
let urls: string[] = [];
try {
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
const response = await axios.get(sitemapUrl, {
headers: { Cookie: `mb_gatekeeper_session=${gatekeeperPassword}` },
});
const $ = cheerio.load(response.data, { xmlMode: true });
urls = $('url loc')
.map((i, el) => $(el).text())
.get();
// Normalize to target URL instance
const urlPattern = /https?:\/\/[^\/]+/;
urls = [...new Set(urls)]
.filter((u) => u.startsWith('http'))
.map((u) => u.replace(urlPattern, targetUrl.replace(/\/$/, '')))
.sort();
console.log(`✅ Found ${urls.length} target URLs.`);
if (urls.length > limit) {
console.log(
`⚠️ Too many pages (${urls.length}). Limiting to ${limit} representative pages.`,
);
// Simplify selection: home pages + a slice of the rest
const homeDE = urls.filter((u) => u.endsWith('/de') || u === targetUrl);
const homeEN = urls.filter((u) => u.endsWith('/en'));
const others = urls.filter((u) => !homeEN.includes(u) && !homeDE.includes(u));
urls = [...homeDE, ...homeEN, ...others.slice(0, limit - (homeEN.length + homeDE.length))];
}
} catch (err: any) {
console.error(`❌ Failed to fetch sitemap: ${err.message}`);
process.exit(1);
}
// 2. Launch Headless Browser
console.log(`\n🕷 Launching Puppeteer Headless Engine...`);
const browser = await puppeteer.launch({
headless: true,
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || process.env.CHROME_PATH || undefined,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
});
const page = await browser.newPage();
// Inject Gatekeeper session bypassing auth screens
await page.setCookie({
name: 'mb_gatekeeper_session',
value: gatekeeperPassword,
domain: new URL(targetUrl).hostname,
path: '/',
httpOnly: true,
secure: targetUrl.startsWith('https://'),
});
let hasBrokenAssets = false;
let hasConsoleErrors = false;
const brokenAssetsList: Array<{ url: string; status: number; page: string }> = [];
const consoleErrorsList: Array<{ type: string; error: string; page: string }> = [];
// Listen for unhandled exceptions natively in the page
page.on('pageerror', (err: any) => {
consoleErrorsList.push({
type: 'PAGE_ERROR',
error: err.message,
page: page.url(),
});
hasConsoleErrors = true;
});
// Listen for console.error and console.warn messages (like Next.js Image warnings, hydration errors, CSP blocks)
page.on('console', (msg) => {
const type = msg.type();
if (type === 'error' || type === 'warn') {
const text = msg.text();
// Exclude common browser extension noise or third party tracker warnings
if (
text.includes('google-analytics') ||
text.includes('googletagmanager') ||
text.includes('SES Removing unpermitted intrinsics') ||
text.includes('Third-party cookie will be blocked') ||
text.includes('Fast Refresh')
)
return;
consoleErrorsList.push({
type: type.toUpperCase(),
error: text,
page: page.url(),
});
hasConsoleErrors = true;
}
});
// Listen to ALL network responses
page.on('response', (response: HTTPResponse) => {
const status = response.status();
// Catch classic 404s and 500s on ANY fetch/image/script
if (
status >= 400 &&
status !== 999 &&
!response.url().includes('google-analytics') &&
!response.url().includes('googletagmanager')
) {
const type = response.request().resourceType();
// We explicitly care about images, stylesheets, scripts, and fetch requests (API) getting 404/500s.
if (['image', 'script', 'stylesheet', 'fetch', 'xhr'].includes(type)) {
brokenAssetsList.push({
url: response.url(),
status: status,
page: page.url(),
});
hasBrokenAssets = true;
}
}
});
// 3. Scan each page
for (let i = 0; i < urls.length; i++) {
const u = urls[i];
console.log(`[${i + 1}/${urls.length}] Scanning: ${u}`);
try {
// Wait until network is idle to ensure all Next.js hydration and image lazy-loads trigger
await page.goto(u, { waitUntil: 'networkidle0', timeout: 30000 });
// Force scroll to bottom to trigger any IntersectionObserver lazy-loaded images
await page.evaluate(async () => {
await new Promise<void>((resolve) => {
let totalHeight = 0;
const distance = 100;
const timer = setInterval(() => {
const scrollHeight = document.body.scrollHeight;
window.scrollBy(0, distance);
totalHeight += distance;
if (totalHeight >= scrollHeight) {
clearInterval(timer);
resolve();
}
}, 100);
});
});
// Wait a tiny bit more for final lazy loads
await new Promise((r) => setTimeout(r, 1000));
} catch (err: any) {
console.error(`⚠️ Timeout or navigation error on ${u}: ${err.message}`);
// Don't fail the whole script just because one page timed out, but flag it
hasBrokenAssets = true;
}
}
await browser.close();
// 4. Report Results
if (hasBrokenAssets && brokenAssetsList.length > 0) {
console.error(`\n❌ FATAL: Broken assets (404/500) detected heavily on the site!`);
console.table(brokenAssetsList);
}
if (hasConsoleErrors && consoleErrorsList.length > 0) {
console.error(`\n❌ FATAL: Console Errors/Warnings detected on the site!`);
console.table(consoleErrorsList);
}
if (hasBrokenAssets || hasConsoleErrors) {
console.error(`\n🚨 The CI build will now fail to prevent bad code from reaching production.`);
process.exit(1);
} else {
console.log(
`\n🎉 SUCCESS: All ${urls.length} pages rendered perfectly with 0 broken images or console errors!`,
);
process.exit(0);
}
}
main();

83
scripts/check-html.ts Normal file
View File

@@ -0,0 +1,83 @@
import axios from 'axios';
import * as cheerio from 'cheerio';
import * as fs from 'fs';
import * as path from 'path';
import { execSync } from 'child_process';
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'mbgrid';
async function main() {
console.log(`\n🚀 Starting HTML Validation for: ${targetUrl}`);
console.log(`📊 Limit: None (Full Sitemap)\n`);
try {
const sitemapUrl = `${targetUrl.replace(/\/$/, '')}/sitemap.xml`;
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
const response = await axios.get(sitemapUrl, {
headers: { Cookie: `mb_gatekeeper_session=${gatekeeperPassword}` },
validateStatus: (status) => status < 400,
});
const $ = cheerio.load(response.data, { xmlMode: true });
let urls = $('url loc')
.map((i, el) => $(el).text())
.get();
const urlPattern = /https?:\/\/[^\/]+/;
urls = [...new Set(urls)]
.filter((u) => u.startsWith('http'))
.map((u) => u.replace(urlPattern, targetUrl.replace(/\/$/, '')))
.sort();
console.log(`✅ Found ${urls.length} URLs in sitemap.`);
if (urls.length === 0) {
console.error('❌ No URLs found in sitemap. Is the site up?');
process.exit(1);
}
const outputDir = path.join(process.cwd(), '.htmlvalidate-tmp');
if (fs.existsSync(outputDir)) fs.rmSync(outputDir, { recursive: true, force: true });
fs.mkdirSync(outputDir, { recursive: true });
console.log(`📥 Fetching HTML for ${urls.length} pages...`);
for (let i = 0; i < urls.length; i++) {
const u = urls[i];
try {
const res = await axios.get(u, {
headers: { Cookie: `mb_gatekeeper_session=${gatekeeperPassword}` },
validateStatus: (status) => status < 400,
});
// Generate a safe filename that retains URL information
const urlStr = new URL(u);
const safePath = (urlStr.pathname + urlStr.search).replace(/[^a-zA-Z0-9]/g, '_');
const filename = `${safePath || 'index'}.html`;
fs.writeFileSync(path.join(outputDir, filename), res.data);
} catch (err: any) {
console.error(`❌ HTTP Error fetching ${u}: ${err.message}`);
throw new Error(`Failed to fetch page: ${u} - ${err.message}`);
}
}
console.log(`\n💻 Executing html-validate...`);
try {
execSync(`npx html-validate .htmlvalidate-tmp/*.html`, { stdio: 'inherit' });
console.log(`✅ HTML Validation passed perfectly!`);
} catch (e) {
console.error(`❌ HTML Validation found issues.`);
process.exit(1);
}
} catch (error: any) {
console.error(`\n❌ Error during HTML Validation:`, error.message);
process.exit(1);
} finally {
const outputDir = path.join(process.cwd(), '.htmlvalidate-tmp');
if (fs.existsSync(outputDir)) fs.rmSync(outputDir, { recursive: true, force: true });
}
}
main();

187
scripts/wcag-sitemap.ts Normal file
View File

@@ -0,0 +1,187 @@
import axios from 'axios';
import * as cheerio from 'cheerio';
import { execSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
/**
* WCAG Audit Script
*
* 1. Fetches sitemap.xml from the target URL
* 2. Extracts all URLs
* 3. Runs pa11y-ci on those URLs
*/
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
const limit = process.env.PAGESPEED_LIMIT ? parseInt(process.env.PAGESPEED_LIMIT) : 20;
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'mbgrid';
async function main() {
console.log(`\n🚀 Starting WCAG Audit for: ${targetUrl}`);
console.log(`📊 Limit: ${limit} pages\n`);
try {
// 1. Fetch Sitemap
const sitemapUrl = `${targetUrl.replace(/\/$/, '')}/sitemap.xml`;
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
const response = await axios.get(sitemapUrl, {
headers: {
Cookie: `mb_gatekeeper_session=${gatekeeperPassword}`,
},
validateStatus: (status) => status < 400,
});
const $ = cheerio.load(response.data, { xmlMode: true });
let urls = $('url loc')
.map((i, el) => $(el).text())
.get();
// Cleanup, filter and normalize domains to targetUrl
const urlPattern = /https?:\/\/[^\/]+/;
urls = [...new Set(urls)]
.filter((u) => u.startsWith('http'))
.map((u) => u.replace(urlPattern, targetUrl.replace(/\/$/, '')))
.sort();
console.log(`✅ Found ${urls.length} URLs in sitemap.`);
if (urls.length === 0) {
console.error('❌ No URLs found in sitemap. Is the site up?');
process.exit(1);
}
if (urls.length > limit) {
console.log(
`⚠️ Too many pages (${urls.length}). Limiting to ${limit} representative pages.`,
);
const home = urls.filter((u) => u.endsWith('/de') || u.endsWith('/en') || u === targetUrl);
const others = urls.filter((u) => !home.includes(u));
urls = [...home, ...others.slice(0, limit - home.length)];
}
console.log(`🧪 Pages to be tested:`);
urls.forEach((u) => console.log(` - ${u}`));
// 2. Prepare pa11y-ci config
const baseConfigPath = path.join(process.cwd(), '.pa11yci.json');
let baseConfig: any = { defaults: {} };
if (fs.existsSync(baseConfigPath)) {
baseConfig = JSON.parse(fs.readFileSync(baseConfigPath, 'utf8'));
}
// Extract domain for cookie
const urlObj = new URL(targetUrl);
const domain = urlObj.hostname;
// Update config with discovered URLs and gatekeeper cookie
const tempConfig = {
...baseConfig,
defaults: {
...baseConfig.defaults,
threshold: 0, // Force threshold to 0 so all errors are shown in JSON
runners: ['axe'],
ignore: [...(baseConfig.defaults?.ignore || []), 'color-contrast'],
chromeLaunchConfig: {
...baseConfig.defaults?.chromeLaunchConfig,
...(process.env.CHROME_PATH ? { executablePath: process.env.CHROME_PATH } : {}),
args: [
...(baseConfig.defaults?.chromeLaunchConfig?.args || []),
'--no-sandbox',
'--disable-setuid-sandbox',
],
},
headers: {
Cookie: `mb_gatekeeper_session=${gatekeeperPassword}`,
},
timeout: 60000, // Increase timeout for slower pages
},
urls: urls,
};
// Create output directory
const outputDir = path.join(process.cwd(), '.pa11yci');
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const tempConfigPath = path.join(outputDir, 'config.temp.json');
const reportPath = path.join(outputDir, 'report.json');
fs.writeFileSync(tempConfigPath, JSON.stringify(tempConfig, null, 2));
// 3. Execute pa11y-ci
console.log(`\n💻 Executing pa11y-ci...`);
const pa11yCommand = `npx pa11y-ci --config .pa11yci/config.temp.json --reporter json > .pa11yci/report.json`;
try {
execSync(pa11yCommand, {
encoding: 'utf8',
stdio: 'inherit',
});
} catch (err: any) {
// pa11y-ci exits with non-zero if issues are found, which is expected
}
// 4. Summarize Results
if (fs.existsSync(reportPath)) {
const reportData = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
console.log(`\n📊 WCAG Audit Summary:\n`);
const summaryTable = Object.keys(reportData.results).map((url) => {
const results = reportData.results[url];
// Results might have errors or just a top level message if it crashed
let errors = 0;
let warnings = 0;
let notices = 0;
if (Array.isArray(results)) {
// pa11y action execution errors come as objects with a message but no type
const actionErrors = results.filter((r: any) => !r.type && r.message).length;
errors = results.filter((r: any) => r.type === 'error').length + actionErrors;
warnings = results.filter((r: any) => r.type === 'warning').length;
notices = results.filter((r: any) => r.type === 'notice').length;
}
// Clean URL for display
const displayUrl = url.replace(targetUrl, '') || '/';
return {
URL: displayUrl.length > 50 ? displayUrl.substring(0, 47) + '...' : displayUrl,
Errors: errors,
Warnings: warnings,
Notices: notices,
Status: errors === 0 ? '✅' : '❌',
};
});
console.table(summaryTable);
const totalErrors = summaryTable.reduce((acc, curr) => acc + curr.Errors, 0);
const totalPages = summaryTable.length;
const cleanPages = summaryTable.filter((p) => p.Errors === 0).length;
console.log(`\n📈 Result: ${cleanPages}/${totalPages} pages are error-free.`);
if (totalErrors > 0) {
console.log(` Total Errors discovered: ${totalErrors}`);
process.exitCode = 1;
}
}
console.log(`\n✨ WCAG Audit completed!`);
} catch (error: any) {
console.error(`\n❌ Error during WCAG Audit:`);
if (axios.isAxiosError(error)) {
console.error(`Status: ${error.response?.status}`);
console.error(`URL: ${error.config?.url}`);
} else {
console.error(error.message);
}
process.exit(1);
} finally {
// Clean up temp config file, keep report
const tempConfigPath = path.join(process.cwd(), '.pa11yci/config.temp.json');
if (fs.existsSync(tempConfigPath)) fs.unlinkSync(tempConfigPath);
}
}
main();

View File

@@ -50,24 +50,29 @@ export default buildConfig({
},
prodMigrations: migrations,
}),
...(process.env.SMTP_HOST
? {
email: nodemailerAdapter({
defaultFromAddress:
process.env.SMTP_FROM || "info@mb-grid-solutions.com",
defaultFromName: "MB Grid Solutions CMS",
transportOptions: {
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT || "587"),
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
secure: process.env.SMTP_SECURE === "true",
},
}),
}
: {}),
email: nodemailerAdapter({
defaultFromAddress:
process.env.MAIL_FROM ||
process.env.SMTP_FROM ||
"info@mb-grid-solutions.com",
defaultFromName: "MB Grid Solutions CMS",
transportOptions: {
host: process.env.MAIL_HOST || process.env.SMTP_HOST || "localhost",
port: parseInt(
process.env.MAIL_PORT || process.env.SMTP_PORT || "587",
),
auth: {
user:
process.env.MAIL_USERNAME ||
process.env.MAIL_USER ||
process.env.SMTP_USER,
pass: process.env.MAIL_PASSWORD || process.env.SMTP_PASS,
},
secure:
process.env.MAIL_SECURE === "true" ||
process.env.SMTP_SECURE === "true",
},
}),
sharp,
plugins: [
...(process.env.S3_ENDPOINT

View File

@@ -6,6 +6,19 @@ const { mockCreate, mockSendEmail } = vi.hoisted(() => ({
mockSendEmail: vi.fn(),
}));
// Mock Nodemailer
const { mockSendMail } = vi.hoisted(() => ({
mockSendMail: vi.fn().mockResolvedValue({ messageId: "mock-message-id" }),
}));
vi.mock("nodemailer", () => ({
default: {
createTransport: vi.fn().mockReturnValue({
sendMail: mockSendMail,
}),
},
}));
vi.mock("payload", () => ({
getPayload: vi.fn().mockResolvedValue({
create: mockCreate,
@@ -101,7 +114,7 @@ describe("Contact API Integration", () => {
// But it actually does NOTHING internally
expect(mockCreate).not.toHaveBeenCalled();
expect(mockSendEmail).not.toHaveBeenCalled();
expect(mockSendMail).not.toHaveBeenCalled();
});
it("should successfully save to Payload and send emails", async () => {
@@ -140,10 +153,10 @@ describe("Contact API Integration", () => {
});
// 2. Verify Email Sending
// Note: sendEmail is called twice (Notification + User Confirmation)
expect(mockSendEmail).toHaveBeenCalledTimes(2);
// Note: sendMail is called twice (Notification + User Confirmation)
expect(mockSendMail).toHaveBeenCalledTimes(2);
expect(mockSendEmail).toHaveBeenNthCalledWith(
expect(mockSendMail).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
subject: "Kontaktanfrage von Jane Doe",
@@ -151,7 +164,7 @@ describe("Contact API Integration", () => {
}),
);
expect(mockSendEmail).toHaveBeenNthCalledWith(
expect(mockSendMail).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
to: "jane@example.com",