feat: Add deployment target configuration for environment-specific settings, Sentry integration, and CMS notice logic.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Failing after 56s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Has been skipped
Build & Deploy KLZ Cables / 🏗️ Build App (push) Has been skipped
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Has been skipped
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s

This commit is contained in:
2026-02-02 14:31:03 +01:00
parent b1854d5255
commit 8eeb571c2d
7 changed files with 101 additions and 46 deletions

View File

@@ -318,9 +318,11 @@ jobs:
# Generated by CI - $TARGET - $(date -u)
NODE_ENV=production
NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
NEXT_PUBLIC_TARGET=$TARGET
NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
SENTRY_DSN=$SENTRY_DSN
LOG_LEVEL=$( [[ "$TARGET" == "testing" || "$TARGET" == "development" ]] && echo "debug" || echo "info" )
MAIL_HOST=$MAIL_HOST
MAIL_PORT=$MAIL_PORT
MAIL_USERNAME=$MAIL_USERNAME
@@ -342,6 +344,8 @@ jobs:
INTERNAL_DIRECTUS_URL=http://directus:8055
GATEKEEPER_PASSWORD=$GATEKEEPER_PASSWORD
TARGET=$TARGET
SENTRY_ENVIRONMENT=$TARGET
IMAGE_TAG=$IMAGE_TAG
TRAEFIK_HOST=$TRAEFIK_HOST
ENV_FILE=$ENV_FILE

View File

@@ -1,54 +1,60 @@
"use server";
'use server';
import client, { ensureAuthenticated } from "@/lib/directus";
import { createItem } from "@directus/sdk";
import { sendEmail } from "@/lib/mail/mailer";
import ContactEmail from "@/components/emails/ContactEmail";
import React from "react";
import { getServerAppServices } from "@/lib/services/create-services.server";
import client, { ensureAuthenticated } from '@/lib/directus';
import { createItem } from '@directus/sdk';
import { sendEmail } from '@/lib/mail/mailer';
import ContactEmail from '@/components/emails/ContactEmail';
import React from 'react';
import { getServerAppServices } from '@/lib/services/create-services.server';
export async function sendContactFormAction(formData: FormData) {
const services = getServerAppServices();
const logger = services.logger.child({ action: 'sendContactFormAction' });
const name = formData.get("name") as string;
const email = formData.get("email") as string;
const message = formData.get("message") as string;
const productName = formData.get("productName") as string | null;
const name = formData.get('name') as string;
const email = formData.get('email') as string;
const message = formData.get('message') as string;
const productName = formData.get('productName') as string | null;
if (!name || !email || !message) {
logger.warn('Missing required fields in contact form', { name: !!name, email: !!email, message: !!message });
return { success: false, error: "Missing required fields" };
logger.warn('Missing required fields in contact form', {
name: !!name,
email: !!email,
message: !!message,
});
return { success: false, error: 'Missing required fields' };
}
// 1. Save to Directus
try {
await ensureAuthenticated();
if (productName) {
await client.request(createItem('product_requests', {
product_name: productName,
email,
message
}));
await client.request(
createItem('product_requests', {
product_name: productName,
email,
message,
}),
);
logger.info('Product request stored in Directus');
} else {
await client.request(createItem('contact_submissions', {
name,
email,
message
}));
await client.request(
createItem('contact_submissions', {
name,
email,
message,
}),
);
logger.info('Contact submission stored in Directus');
}
} catch (error) {
logger.error('Failed to store submission in Directus', { error });
// We continue anyway to try sending the email, but maybe we should report this
services.errors.captureException(error, { action: 'directus_store_submission' });
}
// 2. Send Email
logger.info('Sending contact form email', { email, productName });
const subject = productName
? `Product Inquiry: ${productName}`
: "New Contact Form Submission";
const subject = productName ? `Product Inquiry: ${productName}` : 'New Contact Form Submission';
const result = await sendEmail({
subject,

View File

@@ -2,6 +2,7 @@
import React, { useEffect, useState } from 'react';
import { AlertCircle, RefreshCw, Database } from 'lucide-react';
import { config } from '../lib/config';
export default function CMSConnectivityNotice() {
const [status, setStatus] = useState<'checking' | 'ok' | 'error'>('checking');
@@ -12,14 +13,12 @@ export default function CMSConnectivityNotice() {
// Only show if we've detected an issue AND we are in a context where we want to see it
const checkCMS = async () => {
const isDebug = new URLSearchParams(window.location.search).has('cms_debug');
const isLocal =
window.location.hostname === 'localhost' || window.location.hostname.includes('127.0.0.1');
const isStaging =
window.location.hostname.includes('staging') ||
window.location.hostname.includes('testing');
const isLocal = config.isDevelopment;
const isTesting = config.isTesting;
// Only proceed with check if it's developer context
if (!isLocal && !isStaging && !isDebug) return;
// Only proceed with check if it's developer context (Local or Testing)
// Staging and Production should NEVER see this unless forced with ?cms_debug
if (!isLocal && !isTesting && !isDebug) return;
try {
const response = await fetch('/api/health/cms');

View File

@@ -75,6 +75,7 @@ services:
# Error Tracking
SENTRY_DSN: ${SENTRY_DSN}
SENTRY_ENVIRONMENT: ${TARGET:-development}
LOGGER_LEVEL: ${LOG_LEVEL:-info}
volumes:
- ./directus/uploads:/directus/uploads
- ./directus/extensions:/directus/extensions

View File

@@ -13,11 +13,15 @@ let memoizedConfig: ReturnType<typeof createConfig> | undefined;
function createConfig() {
const env = envSchema.parse(getRawEnv());
const target = env.NEXT_PUBLIC_TARGET || env.TARGET;
return {
env: env.NODE_ENV,
isProduction: env.NODE_ENV === 'production',
isDevelopment: env.NODE_ENV === 'development',
isTest: env.NODE_ENV === 'test',
target,
isProduction: target === 'production',
isStaging: target === 'staging',
isTesting: target === 'testing',
isDevelopment: target === 'development' || env.NODE_ENV === 'development',
baseUrl: env.NEXT_PUBLIC_BASE_URL,
@@ -87,15 +91,21 @@ export const config = {
get env() {
return getConfig().env;
},
get target() {
return getConfig().target;
},
get isProduction() {
return getConfig().isProduction;
},
get isStaging() {
return getConfig().isStaging;
},
get isTesting() {
return getConfig().isTesting;
},
get isDevelopment() {
return getConfig().isDevelopment;
},
get isTest() {
return getConfig().isTest;
},
get baseUrl() {
return getConfig().baseUrl;
},

View File

@@ -1,5 +1,6 @@
import { createDirectus, rest, authentication, readItems, readCollections } from '@directus/sdk';
import { config } from './config';
import * as Sentry from '@sentry/nextjs';
const { url, adminEmail, password, token, proxyPath, internalUrl } = config.directus;
@@ -8,6 +9,21 @@ const effectiveUrl = typeof window === 'undefined' && internalUrl ? internalUrl
const client = createDirectus(effectiveUrl).with(rest()).with(authentication());
/**
* Helper to determine if we should show detailed errors
*/
const shouldShowDevErrors = config.isTesting || config.isDevelopment;
/**
* Genericizes error messages for production/staging
*/
function formatError(error: any) {
if (shouldShowDevErrors) {
return error.message || 'An unexpected error occurred.';
}
return 'A system error occurred. Our team has been notified.';
}
export async function ensureAuthenticated() {
if (token) {
client.setToken(token);
@@ -17,6 +33,7 @@ export async function ensureAuthenticated() {
try {
await client.login(adminEmail, password);
} catch (e) {
Sentry.captureException(e);
console.error('Failed to authenticate with Directus:', e);
}
}
@@ -61,6 +78,7 @@ export async function getProducts(locale: string = 'de') {
);
return items.map((item) => mapDirectusProduct(item, locale));
} catch (error) {
Sentry.captureException(error);
console.error('Error fetching products:', error);
return [];
}
@@ -86,6 +104,7 @@ export async function getProductBySlug(slug: string, locale: string = 'de') {
if (!items || items.length === 0) return null;
return mapDirectusProduct(items[0], locale);
} catch (error) {
Sentry.captureException(error);
console.error(`Error fetching product ${slug}:`, error);
return null;
}
@@ -98,13 +117,15 @@ export async function checkHealth() {
await ensureAuthenticated();
await client.request(readCollections());
} catch (e: any) {
Sentry.captureException(e);
console.error('Directus authentication failed during health check:', e);
return {
status: 'error',
message:
'Authentication failed. Check your DIRECTUS_ADMIN_EMAIL and DIRECTUS_ADMIN_PASSWORD.',
message: shouldShowDevErrors
? 'Authentication failed. Check your DIRECTUS_ADMIN_EMAIL and DIRECTUS_ADMIN_PASSWORD.'
: 'CMS is currently unavailable due to an internal authentication error.',
code: 'AUTH_FAILED',
details: e.message,
details: shouldShowDevErrors ? e.message : undefined,
};
}
@@ -112,6 +133,7 @@ export async function checkHealth() {
try {
await client.request(readItems('products', { limit: 1 }));
} catch (e: any) {
Sentry.captureException(e);
if (
e.message?.includes('does not exist') ||
e.code === 'INVALID_PAYLOAD' ||
@@ -119,23 +141,28 @@ export async function checkHealth() {
) {
return {
status: 'error',
message: 'The "products" collection is missing or inaccessible. Please sync your data.',
message: shouldShowDevErrors
? 'The "products" collection is missing or inaccessible. Please sync your data.'
: 'Required data structures are currently unavailable.',
code: 'SCHEMA_MISSING',
};
}
return {
status: 'error',
message: `Schema error: ${e.message}`,
message: shouldShowDevErrors
? `Schema error: ${e.message}`
: 'The data schema is currently misconfigured.',
code: 'SCHEMA_ERROR',
};
}
return { status: 'ok', message: 'Directus is reachable and responding.' };
} catch (error: any) {
Sentry.captureException(error);
console.error('Directus health check failed with unexpected error:', error);
return {
status: 'error',
message: error.message || 'An unexpected error occurred while connecting to the CMS.',
message: formatError(error),
code: error.code || 'UNKNOWN',
};
}

View File

@@ -11,6 +11,9 @@ const preprocessEmptyString = (val: unknown) => (val === '' ? undefined : val);
export const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
NEXT_PUBLIC_BASE_URL: z.preprocess(preprocessEmptyString, z.string().url()),
NEXT_PUBLIC_TARGET: z
.enum(['development', 'testing', 'staging', 'production'])
.default('development'),
// Analytics
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.preprocess(preprocessEmptyString, z.string().optional()),
@@ -45,6 +48,9 @@ export const envSchema = z.object({
DIRECTUS_ADMIN_PASSWORD: z.preprocess(preprocessEmptyString, z.string().optional()),
DIRECTUS_API_TOKEN: z.preprocess(preprocessEmptyString, z.string().optional()),
INTERNAL_DIRECTUS_URL: z.preprocess(preprocessEmptyString, z.string().url().optional()),
// Deploy Target
TARGET: z.enum(['development', 'testing', 'staging', 'production']).default('development'),
});
export type Env = z.infer<typeof envSchema>;
@@ -57,6 +63,7 @@ export function getRawEnv() {
return {
NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
NEXT_PUBLIC_TARGET: process.env.NEXT_PUBLIC_TARGET,
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
SENTRY_DSN: process.env.SENTRY_DSN,
@@ -72,5 +79,6 @@ export function getRawEnv() {
DIRECTUS_ADMIN_PASSWORD: process.env.DIRECTUS_ADMIN_PASSWORD,
DIRECTUS_API_TOKEN: process.env.DIRECTUS_API_TOKEN,
INTERNAL_DIRECTUS_URL: process.env.INTERNAL_DIRECTUS_URL,
TARGET: process.env.TARGET,
};
}