diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index b748be01..462dcf32 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -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 diff --git a/app/actions/contact.ts b/app/actions/contact.ts index abe3082d..18f327d1 100644 --- a/app/actions/contact.ts +++ b/app/actions/contact.ts @@ -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, diff --git a/components/CMSConnectivityNotice.tsx b/components/CMSConnectivityNotice.tsx index 63b7f592..33ccd4a6 100644 --- a/components/CMSConnectivityNotice.tsx +++ b/components/CMSConnectivityNotice.tsx @@ -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'); diff --git a/docker-compose.yml b/docker-compose.yml index 82af1f67..63024108 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/lib/config.ts b/lib/config.ts index 3d8b35c2..4857922d 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -13,11 +13,15 @@ let memoizedConfig: ReturnType | 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; }, diff --git a/lib/directus.ts b/lib/directus.ts index 62a74c3f..26f36d2c 100644 --- a/lib/directus.ts +++ b/lib/directus.ts @@ -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', }; } diff --git a/lib/env.ts b/lib/env.ts index f93eba50..64a22ee7 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -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; @@ -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, }; }