10 Commits

Author SHA1 Message Date
d0d66dd85f fix: linting
Some checks failed
Build & Deploy / 🔍 Prepare Environment (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Failing after 1m14s
Build & Deploy / 🏗️ Build (push) Failing after 4m44s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🔔 Notifications (push) Successful in 1s
2026-02-07 01:25:15 +01:00
6f5c9bd613 fix: umami
Some checks failed
Build & Deploy / 🔍 Prepare Environment (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Failing after 35s
Build & Deploy / 🏗️ Build (push) Failing after 4m45s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🔔 Notifications (push) Successful in 1s
2026-02-07 01:15:55 +01:00
9f6168592c feat: umami migration 2026-02-07 01:11:28 +01:00
29d474a102 fix: traefik issues
All checks were successful
Build & Deploy / 🔍 Prepare Environment (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Successful in 1m13s
Build & Deploy / 🚀 Deploy (push) Successful in 9s
Build & Deploy / 🏗️ Build (push) Successful in 2m5s
Build & Deploy / 🔔 Notifications (push) Successful in 2s
2026-02-06 23:15:22 +01:00
a31202f63b refactor: use explicit Git reference variables for more robust deployment target and image tag determination in Gitea workflow.
All checks were successful
Build & Deploy / 🔍 Prepare Environment (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Successful in 1m33s
Build & Deploy / 🏗️ Build (push) Successful in 2m7s
Build & Deploy / 🚀 Deploy (push) Successful in 9s
Build & Deploy / 🔔 Notifications (push) Successful in 2s
2026-02-06 22:51:40 +01:00
0afd6bbb60 fix: logo position
All checks were successful
Build & Deploy / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 1m32s
Build & Deploy / 🏗️ Build (push) Successful in 4m44s
Build & Deploy / 🚀 Deploy (push) Successful in 13s
Build & Deploy / 🔔 Notifications (push) Successful in 2s
2026-02-06 21:42:43 +01:00
2c647f0284 chore: directus sync
All checks were successful
Build & Deploy / 🔍 Prepare Environment (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Successful in 1m57s
Build & Deploy / 🏗️ Build (push) Successful in 2m3s
Build & Deploy / 🚀 Deploy (push) Successful in 33s
Build & Deploy / 🔔 Notifications (push) Successful in 2s
2026-02-06 21:35:00 +01:00
d9ff6d640d feat: Configure Traefik to use the infra network for services, add an internal Directus URL, and enhance Directus and Gatekeeper configurations.
All checks were successful
Build & Deploy / 🔍 Prepare Environment (push) Successful in 9s
Build & Deploy / 🧪 QA (push) Successful in 1m13s
Build & Deploy / 🏗️ Build (push) Successful in 4m51s
Build & Deploy / 🚀 Deploy (push) Successful in 10s
Build & Deploy / 🔔 Notifications (push) Successful in 1s
2026-02-06 19:23:35 +01:00
8ab9ec7d1f chore: bootstrap command 2026-02-06 19:11:19 +01:00
0cc67d54ef refactor: overhaul Directus sync script with schema wiping and restart, update branding, and rename CMS scripts. 2026-02-06 19:09:56 +01:00
33 changed files with 3520 additions and 255 deletions

View File

@@ -80,5 +80,5 @@ SENTRY_DSN=
# GOTIFY_TOKEN= # GOTIFY_TOKEN=
# Analytics (Umami) # Analytics (Umami)
NEXT_PUBLIC_UMAMI_WEBSITE_ID= UMAMI_WEBSITE_ID=
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me

View File

@@ -26,6 +26,9 @@ jobs:
next_public_base_url: ${{ steps.determine.outputs.next_public_base_url }} next_public_base_url: ${{ steps.determine.outputs.next_public_base_url }}
directus_url: ${{ steps.determine.outputs.directus_url }} directus_url: ${{ steps.determine.outputs.directus_url }}
directus_host: ${{ steps.determine.outputs.directus_host }} directus_host: ${{ steps.determine.outputs.directus_host }}
gatekeeper_host: ${{ steps.determine.outputs.gatekeeper_host }}
traefik_rule: ${{ steps.determine.outputs.traefik_rule }}
gatekeeper_rule: ${{ steps.determine.outputs.gatekeeper_rule }}
project_name: ${{ steps.determine.outputs.project_name }} project_name: ${{ steps.determine.outputs.project_name }}
steps: steps:
- name: 🔍 Debug Info - name: 🔍 Debug Info
@@ -51,48 +54,82 @@ jobs:
id: determine id: determine
shell: bash shell: bash
run: | run: |
TAG="${{ github.ref_name }}" REF="${{ github.ref }}"
REF_NAME="${{ github.ref_name }}"
REF_TYPE="${{ github.ref_type }}"
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7) SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
DOMAIN_BASE="mb-grid-solutions.com" DOMAIN_BASE="mb-grid-solutions.com"
PRJ_ID="mb-grid-solutions" PRJ_ID="mb-grid-solutions"
if [[ "${{ github.ref_type }}" == "branch" && "$TAG" == "main" ]]; then echo "Detecting environment for ref: $REF ($REF_NAME, type: $REF_TYPE)"
# Fallback for REF_TYPE if missing
if [[ -z "$REF_TYPE" ]]; then
if [[ "$REF" == refs/tags/* ]]; then
REF_TYPE="tag"
elif [[ "$REF" == refs/heads/* ]]; then
REF_TYPE="branch"
fi
fi
if [[ "$REF_TYPE" == "branch" && "$REF_NAME" == "main" ]]; then
TARGET="testing" TARGET="testing"
IMAGE_TAG="testing-${SHORT_SHA}" IMAGE_TAG="testing-${SHORT_SHA}"
ENV_FILE=".env.testing" ENV_FILE=".env.testing"
TRAEFIK_HOST="testing.${DOMAIN_BASE}" TRAEFIK_HOST="testing.${DOMAIN_BASE}"
GATEKEEPER_HOST="gatekeeper.testing.${DOMAIN_BASE}"
NEXT_PUBLIC_BASE_URL="https://testing.${DOMAIN_BASE}" NEXT_PUBLIC_BASE_URL="https://testing.${DOMAIN_BASE}"
DIRECTUS_URL="https://cms.testing.${DOMAIN_BASE}" DIRECTUS_URL="https://cms.testing.${DOMAIN_BASE}"
DIRECTUS_HOST="cms.testing.${DOMAIN_BASE}" DIRECTUS_HOST="cms.testing.${DOMAIN_BASE}"
elif [[ "${{ github.ref_type }}" == "tag" ]]; then elif [[ "$REF_TYPE" == "tag" ]]; then
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then if [[ "$REF_NAME" =~ ^v[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then
TARGET="production" TARGET="production"
IMAGE_TAG="$TAG" IMAGE_TAG="$REF_NAME"
ENV_FILE=".env.prod" ENV_FILE=".env.prod"
TRAEFIK_HOST="${DOMAIN_BASE}, www.${DOMAIN_BASE}" TRAEFIK_HOST="${DOMAIN_BASE}" # Primary domain
GATEKEEPER_HOST="gatekeeper.${DOMAIN_BASE}"
NEXT_PUBLIC_BASE_URL="https://${DOMAIN_BASE}" NEXT_PUBLIC_BASE_URL="https://${DOMAIN_BASE}"
DIRECTUS_URL="https://cms.${DOMAIN_BASE}" DIRECTUS_URL="https://cms.${DOMAIN_BASE}"
DIRECTUS_HOST="cms.${DOMAIN_BASE}" DIRECTUS_HOST="cms.${DOMAIN_BASE}"
elif [[ "$TAG" =~ -rc || "$TAG" =~ -beta || "$TAG" =~ -alpha ]]; then elif [[ "$REF_NAME" =~ -rc || "$REF_NAME" =~ -beta || "$REF_NAME" =~ -alpha ]]; then
TARGET="staging" TARGET="staging"
IMAGE_TAG="$TAG" IMAGE_TAG="$REF_NAME"
ENV_FILE=".env.staging" ENV_FILE=".env.staging"
TRAEFIK_HOST="staging.${DOMAIN_BASE}" TRAEFIK_HOST="staging.${DOMAIN_BASE}"
GATEKEEPER_HOST="gatekeeper.staging.${DOMAIN_BASE}"
NEXT_PUBLIC_BASE_URL="https://staging.${DOMAIN_BASE}" NEXT_PUBLIC_BASE_URL="https://staging.${DOMAIN_BASE}"
DIRECTUS_URL="https://cms.staging.${DOMAIN_BASE}" DIRECTUS_URL="https://cms.staging.${DOMAIN_BASE}"
DIRECTUS_HOST="cms.staging.${DOMAIN_BASE}" DIRECTUS_HOST="cms.staging.${DOMAIN_BASE}"
else else
TARGET="skip" TARGET="skip"
echo "Tag $REF_NAME did not match any environment pattern."
fi fi
else else
TARGET="skip" TARGET="skip"
echo "Ref type $REF_TYPE is not handled for deployment."
fi
# Determine Rules based on target (if not skipped)
if [[ "$TARGET" != "skip" ]]; then
if [[ "$TARGET" == "production" ]]; then
TRAEFIK_RULE="Host(\`${DOMAIN_BASE}\`) || Host(\`www.${DOMAIN_BASE}\`)"
GATEKEEPER_RULE="(Host(\`${DOMAIN_BASE}\`) || Host(\`www.${DOMAIN_BASE}\`)) && PathPrefix(\`/gatekeeper\`) || Host(\`gatekeeper.${DOMAIN_BASE}\`)"
else
TRAEFIK_RULE="Host(\`${TRAEFIK_HOST}\`)"
GATEKEEPER_RULE="(Host(\`${TRAEFIK_HOST}\`) && PathPrefix(\`/gatekeeper\`)) || Host(\`gatekeeper.${TRAEFIK_HOST}\`)"
fi
fi fi
echo "Target determined: $TARGET" echo "Target determined: $TARGET"
echo "Image tag: $IMAGE_TAG"
echo "target=$TARGET" >> "$GITHUB_OUTPUT" echo "target=$TARGET" >> "$GITHUB_OUTPUT"
echo "image_tag=$IMAGE_TAG" >> "$GITHUB_OUTPUT" echo "image_tag=$IMAGE_TAG" >> "$GITHUB_OUTPUT"
echo "env_file=$ENV_FILE" >> "$GITHUB_OUTPUT" echo "env_file=$ENV_FILE" >> "$GITHUB_OUTPUT"
echo "traefik_host=$TRAEFIK_HOST" >> "$GITHUB_OUTPUT" echo "traefik_host=$TRAEFIK_HOST" >> "$GITHUB_OUTPUT"
echo "traefik_rule=$TRAEFIK_RULE" >> "$GITHUB_OUTPUT"
echo "gatekeeper_rule=$GATEKEEPER_RULE" >> "$GITHUB_OUTPUT"
echo "gatekeeper_host=$GATEKEEPER_HOST" >> "$GITHUB_OUTPUT"
echo "next_public_base_url=$NEXT_PUBLIC_BASE_URL" >> "$GITHUB_OUTPUT" echo "next_public_base_url=$NEXT_PUBLIC_BASE_URL" >> "$GITHUB_OUTPUT"
echo "directus_url=$DIRECTUS_URL" >> "$GITHUB_OUTPUT" echo "directus_url=$DIRECTUS_URL" >> "$GITHUB_OUTPUT"
echo "directus_host=$DIRECTUS_HOST" >> "$GITHUB_OUTPUT" echo "directus_host=$DIRECTUS_HOST" >> "$GITHUB_OUTPUT"
@@ -158,6 +195,7 @@ jobs:
--build-arg NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_base_url }} \ --build-arg NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_base_url }} \
--build-arg NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }} \ --build-arg NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }} \
--build-arg DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }} \ --build-arg DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }} \
--build-arg UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }} \
-t registry.infra.mintel.me/mintel/mb-grid-solutions:${{ needs.prepare.outputs.image_tag }} \ -t registry.infra.mintel.me/mintel/mb-grid-solutions:${{ needs.prepare.outputs.image_tag }} \
--push . --push .
@@ -189,12 +227,16 @@ jobs:
ENV_FILE=${{ needs.prepare.outputs.env_file }} ENV_FILE=${{ needs.prepare.outputs.env_file }}
IMAGE_TAG=${{ needs.prepare.outputs.image_tag }} IMAGE_TAG=${{ needs.prepare.outputs.image_tag }}
TRAEFIK_HOST=${{ needs.prepare.outputs.traefik_host }} TRAEFIK_HOST=${{ needs.prepare.outputs.traefik_host }}
TRAEFIK_RULE=${{ needs.prepare.outputs.traefik_rule }}
GATEKEEPER_RULE=${{ needs.prepare.outputs.gatekeeper_rule }}
GATEKEEPER_HOST=${{ needs.prepare.outputs.gatekeeper_host }}
PROJECT_NAME=${{ needs.prepare.outputs.project_name }} PROJECT_NAME=${{ needs.prepare.outputs.project_name }}
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_base_url }} NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_base_url }}
# Directus # Directus
DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }} DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }}
DIRECTUS_HOST=${{ needs.prepare.outputs.directus_host }} DIRECTUS_HOST=${{ needs.prepare.outputs.directus_host }}
INTERNAL_DIRECTUS_URL=http://directus:8055
DIRECTUS_API_TOKEN=${{ secrets.DIRECTUS_API_TOKEN || vars.DIRECTUS_API_TOKEN }} DIRECTUS_API_TOKEN=${{ secrets.DIRECTUS_API_TOKEN || vars.DIRECTUS_API_TOKEN }}
DIRECTUS_ADMIN_EMAIL=${{ secrets.DIRECTUS_ADMIN_EMAIL || vars.DIRECTUS_ADMIN_EMAIL || 'admin@mintel.me' }} DIRECTUS_ADMIN_EMAIL=${{ secrets.DIRECTUS_ADMIN_EMAIL || vars.DIRECTUS_ADMIN_EMAIL || 'admin@mintel.me' }}
DIRECTUS_ADMIN_PASSWORD=${{ secrets.DIRECTUS_ADMIN_PASSWORD || vars.DIRECTUS_ADMIN_PASSWORD }} DIRECTUS_ADMIN_PASSWORD=${{ secrets.DIRECTUS_ADMIN_PASSWORD || vars.DIRECTUS_ADMIN_PASSWORD }}
@@ -222,8 +264,8 @@ jobs:
SENTRY_DSN=${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }} SENTRY_DSN=${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
GOTIFY_URL=${{ secrets.GOTIFY_URL || vars.GOTIFY_URL }} GOTIFY_URL=${{ secrets.GOTIFY_URL || vars.GOTIFY_URL }}
GOTIFY_TOKEN=${{ secrets.GOTIFY_TOKEN || vars.GOTIFY_TOKEN }} GOTIFY_TOKEN=${{ secrets.GOTIFY_TOKEN || vars.GOTIFY_TOKEN }}
NEXT_PUBLIC_UMAMI_WEBSITE_ID=${{ secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }} UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID || vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
NEXT_PUBLIC_UMAMI_SCRIPT_URL=${{ secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || vars.NEXT_PUBLIC_UMAMI_SCRIPT_URL }} UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
# Project # Project
PROJECT_COLOR=${{ secrets.PROJECT_COLOR || vars.PROJECT_COLOR || '#82ed20' }} PROJECT_COLOR=${{ secrets.PROJECT_COLOR || vars.PROJECT_COLOR || '#82ed20' }}

View File

@@ -8,15 +8,13 @@ RUN rm -rf packages apps pnpm-workspace.yaml 2>/dev/null || true
# Build-time environment variables for Next.js # Build-time environment variables for Next.js
ARG NEXT_PUBLIC_BASE_URL ARG NEXT_PUBLIC_BASE_URL
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID ARG UMAMI_API_ENDPOINT
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
ARG NEXT_PUBLIC_TARGET ARG NEXT_PUBLIC_TARGET
ARG DIRECTUS_URL ARG DIRECTUS_URL
ARG NPM_TOKEN ARG NPM_TOKEN
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID ENV UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
ENV NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
ENV DIRECTUS_URL=$DIRECTUS_URL ENV DIRECTUS_URL=$DIRECTUS_URL
ENV NPM_TOKEN=$NPM_TOKEN ENV NPM_TOKEN=$NPM_TOKEN

View File

@@ -70,9 +70,9 @@ export default async function RootLayout({
params, params,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
params: Promise<{ locale: string }>; params: { locale: string };
}) { }) {
const { locale } = await params; const { locale } = params;
// Validate that the incoming `locale` is supported // Validate that the incoming `locale` is supported
if (locale !== "de") { if (locale !== "de") {
@@ -105,6 +105,13 @@ export default async function RootLayout({
}, },
}; };
// Track pageview on the server
// This is safe to call here because layout is a Server Component
const services = (
await import("@/lib/services/create-services.server")
).getServerAppServices();
services.analytics.trackPageview();
return ( return (
<html lang={locale} className={`${inter.variable}`}> <html lang={locale} className={`${inter.variable}`}>
<head> <head>

View File

@@ -52,10 +52,10 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
className="fixed top-0 left-0 right-0 z-[100]" className="fixed top-0 left-0 right-0 z-[100]"
> >
<header <header
className={`transition-all duration-300 flex items-center py-1 ${ className={`transition-all duration-300 flex items-center ${
isScrolled isScrolled
? "bg-white/90 backdrop-blur-lg border-b border-slate-200 shadow-sm" ? "bg-white/90 backdrop-blur-lg border-b border-slate-200 shadow-sm py-2"
: "bg-gradient-to-b from-white/80 via-white/40 to-transparent" : "bg-gradient-to-b from-white/80 via-white/40 to-transparent py-4"
}`} }`}
> >
<div className="container-custom flex justify-between items-center w-full relative z-10"> <div className="container-custom flex justify-between items-center w-full relative z-10">
@@ -65,7 +65,7 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
aria-label={`${t("nav.home")} - Zur Startseite`} aria-label={`${t("nav.home")} - Zur Startseite`}
> >
<div <div
className={`relative transition-all duration-300 ${isScrolled ? "h-[50px] md:h-[80px] w-[120px] md:w-[200px] mt-0 mb-[-10px]" : "h-[80px] md:h-[140px] w-[180px] md:w-[320px] mt-2 md:mt-4 mb-[-20px] md:mb-[-40px]"}`} className={`relative transition-all duration-300 ${isScrolled ? "h-[50px] md:h-[60px] w-[120px] md:w-[150px]" : "h-[70px] md:h-[100px] w-[160px] md:w-[240px]"}`}
> >
<Image <Image
src="/assets/logo.png" src="/assets/logo.png"

View File

@@ -0,0 +1,48 @@
"use client";
import { useEffect } from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { getAppServices } from "@/lib/services/create-services";
/**
* AnalyticsProvider Component
*
* Automatically tracks pageviews on client-side route changes.
* This component should be placed inside your layout to handle navigation events.
*
* @param {Object} props - Component props
* @param {string} [props.websiteId] - The Umami website ID (passed from server config)
*
* @example
* ```tsx
* // In your layout.tsx
* const { websiteId } = config.analytics.umami;
* <AnalyticsProvider websiteId={websiteId} />
* ```
*/
export default function AnalyticsProvider({
websiteId,
}: {
websiteId?: string;
}) {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
if (!pathname) return;
const services = getAppServices();
const url = `${pathname}${searchParams?.size ? `?${searchParams.toString()}` : ""}`;
// Track pageview with the full URL
services.analytics.trackPageview(url);
if (process.env.NODE_ENV === "development") {
console.log("[Umami] Tracked pageview:", url);
}
}, [pathname, searchParams]);
if (!websiteId) return null;
return null;
}

View File

@@ -8,27 +8,29 @@ services:
- ${ENV_FILE:-.env} - ${ENV_FILE:-.env}
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.${PROJECT_NAME}.rule=Host(`${TRAEFIK_HOST:-mb-grid-solutions.localhost}`)" - "traefik.http.routers.${PROJECT_NAME}.rule=${TRAEFIK_RULE:-Host(`${TRAEFIK_HOST:-mb-grid-solutions.localhost}`)}"
- "traefik.http.routers.${PROJECT_NAME}.entrypoints=websecure" - "traefik.http.routers.${PROJECT_NAME}.entrypoints=websecure"
- "traefik.http.routers.${PROJECT_NAME}.tls.certresolver=le" - "traefik.http.routers.${PROJECT_NAME}.tls.certresolver=le"
- "traefik.http.routers.${PROJECT_NAME}.tls=true" - "traefik.http.routers.${PROJECT_NAME}.tls=true"
- "traefik.http.services.${PROJECT_NAME}.loadbalancer.server.port=3000" - "traefik.http.services.${PROJECT_NAME}.loadbalancer.server.port=3000"
- "traefik.http.routers.${PROJECT_NAME}.middlewares=${PROJECT_NAME}-auth" - "traefik.http.routers.${PROJECT_NAME}.middlewares=${PROJECT_NAME}-auth"
- "traefik.docker.network=infra"
# Gatekeeper Router (Shared Host + dedicated Subdomain) # Gatekeeper Router (Shared Host + dedicated Subdomain)
- "traefik.http.routers.${PROJECT_NAME}-gatekeeper.rule=(Host(`${TRAEFIK_HOST:-mb-grid-solutions.localhost}`) && PathPrefix(`/gatekeeper`)) || Host(`gatekeeper.${TRAEFIK_HOST:-mb-grid-solutions.localhost}`)" - "traefik.http.routers.${PROJECT_NAME}-gatekeeper.rule=${GATEKEEPER_RULE:-(Host(`${TRAEFIK_HOST:-mb-grid-solutions.localhost}`) && PathPrefix(`/gatekeeper`)) || Host(`gatekeeper.${TRAEFIK_HOST:-mb-grid-solutions.localhost}`)}"
- "traefik.http.routers.${PROJECT_NAME}-gatekeeper.entrypoints=websecure" - "traefik.http.routers.${PROJECT_NAME}-gatekeeper.entrypoints=websecure"
- "traefik.http.routers.${PROJECT_NAME}-gatekeeper.tls.certresolver=le" - "traefik.http.routers.${PROJECT_NAME}-gatekeeper.tls.certresolver=le"
- "traefik.http.routers.${PROJECT_NAME}-gatekeeper.tls=true" - "traefik.http.routers.${PROJECT_NAME}-gatekeeper.tls=true"
- "traefik.http.routers.${PROJECT_NAME}-gatekeeper.service=${PROJECT_NAME}-gatekeeper" - "traefik.http.routers.${PROJECT_NAME}-gatekeeper.service=${PROJECT_NAME}-gatekeeper"
# Auth Middleware Definition
- "traefik.http.middlewares.${PROJECT_NAME}-auth.forwardauth.address=http://${PROJECT_NAME}-gatekeeper:3000/api/verify" - "traefik.http.middlewares.${PROJECT_NAME}-auth.forwardauth.address=http://${PROJECT_NAME}-gatekeeper:3000/api/verify"
- "traefik.http.middlewares.${PROJECT_NAME}-auth.forwardauth.trustForwardHeader=true" - "traefik.http.middlewares.${PROJECT_NAME}-auth.forwardauth.trustForwardHeader=true"
- "traefik.http.middlewares.${PROJECT_NAME}-auth.forwardauth.authResponseHeaders=X-Auth-User" - "traefik.http.middlewares.${PROJECT_NAME}-auth.forwardauth.authResponseHeaders=X-Auth-User"
- "traefik.docker.network=infra"
gatekeeper: gatekeeper:
image: registry.infra.mintel.me/mintel/gatekeeper:latest image: registry.infra.mintel.me/mintel/gatekeeper:latest
container_name: ${PROJECT_NAME:-mb-grid-solutions}-gatekeeper
restart: always restart: always
networks: networks:
infra: infra:
@@ -41,11 +43,14 @@ services:
PROJECT_NAME: ${PROJECT_NAME:-MB Grid Solutions} PROJECT_NAME: ${PROJECT_NAME:-MB Grid Solutions}
PROJECT_COLOR: ${PROJECT_COLOR:-#82ed20} PROJECT_COLOR: ${PROJECT_COLOR:-#82ed20}
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-.mb-grid-solutions.com} COOKIE_DOMAIN: ${COOKIE_DOMAIN:-.mb-grid-solutions.com}
AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:-mintel_gatekeeper_session}
GATEKEEPER_PASSWORD: ${GATEKEEPER_PASSWORD:-mintel}
# Dedicated Base URL for Gatekeeper subdomain to prevent redirect loops # Dedicated Base URL for Gatekeeper subdomain to prevent redirect loops
NEXT_PUBLIC_BASE_URL: https://gatekeeper.${TRAEFIK_HOST:-mb-grid-solutions.localhost} NEXT_PUBLIC_BASE_URL: https://${GATEKEEPER_HOST:-gatekeeper.mb-grid-solutions.localhost}
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.services.${PROJECT_NAME}-gatekeeper.loadbalancer.server.port=3000" - "traefik.http.services.${PROJECT_NAME}-gatekeeper.loadbalancer.server.port=3000"
- "traefik.docker.network=infra"
directus: directus:
image: directus/directus:11 image: directus/directus:11
@@ -68,6 +73,10 @@ services:
DB_DATABASE: ${DIRECTUS_DB_NAME:-directus} DB_DATABASE: ${DIRECTUS_DB_NAME:-directus}
DB_USER: ${DIRECTUS_DB_USER:-directus} DB_USER: ${DIRECTUS_DB_USER:-directus}
DB_PASSWORD: ${DIRECTUS_DB_PASSWORD:-directus} DB_PASSWORD: ${DIRECTUS_DB_PASSWORD:-directus}
# Telemetry & Performance
LOGGER_LEVEL: ${LOG_LEVEL:-info}
SENTRY_DSN: ${SENTRY_DSN}
SENTRY_ENVIRONMENT: ${TARGET:-development}
volumes: volumes:
- ./directus/uploads:/directus/uploads - ./directus/uploads:/directus/uploads
- ./directus/extensions:/directus/extensions - ./directus/extensions:/directus/extensions
@@ -77,8 +86,10 @@ services:
- "traefik.http.routers.${PROJECT_NAME}-directus.entrypoints=websecure" - "traefik.http.routers.${PROJECT_NAME}-directus.entrypoints=websecure"
- "traefik.http.routers.${PROJECT_NAME}-directus.tls.certresolver=le" - "traefik.http.routers.${PROJECT_NAME}-directus.tls.certresolver=le"
- "traefik.http.routers.${PROJECT_NAME}-directus.tls=true" - "traefik.http.routers.${PROJECT_NAME}-directus.tls=true"
- "traefik.http.routers.${PROJECT_NAME}-directus.middlewares=${PROJECT_NAME}-auth" - "traefik.http.routers.${PROJECT_NAME}-directus.middlewares=${PROJECT_NAME}-forward,compress"
- "traefik.http.services.${PROJECT_NAME}-directus.loadbalancer.server.port=8055" - "traefik.http.services.${PROJECT_NAME}-directus.loadbalancer.server.port=8055"
- "traefik.http.middlewares.${PROJECT_NAME}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
- "traefik.docker.network=infra"
directus-db: directus-db:
image: postgres:15-alpine image: postgres:15-alpine

2017
dump.sql Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -27,11 +27,9 @@ function createConfig() {
analytics: { analytics: {
umami: { umami: {
websiteId: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID, websiteId: env.UMAMI_WEBSITE_ID,
scriptUrl: env.NEXT_PUBLIC_UMAMI_SCRIPT_URL, apiEndpoint: env.UMAMI_API_ENDPOINT,
// The proxied path used in the frontend enabled: Boolean(env.UMAMI_WEBSITE_ID),
proxyPath: "/stats/script.js",
enabled: Boolean(env.NEXT_PUBLIC_UMAMI_WEBSITE_ID),
}, },
}, },
@@ -153,7 +151,7 @@ export function getMaskedConfig() {
analytics: { analytics: {
umami: { umami: {
websiteId: mask(c.analytics.umami.websiteId), websiteId: mask(c.analytics.umami.websiteId),
scriptUrl: c.analytics.umami.scriptUrl, apiEndpoint: c.analytics.umami.apiEndpoint,
enabled: c.analytics.umami.enabled, enabled: c.analytics.umami.enabled,
}, },
}, },

View File

@@ -8,78 +8,99 @@ const preprocessEmptyString = (val: unknown) => (val === "" ? undefined : val);
/** /**
* Environment variable schema. * Environment variable schema.
*/ */
export const envSchema = z.object({ export const envSchema = z
NODE_ENV: z .object({
.enum(["development", "production", "test"]) NODE_ENV: z
.default("development"), .enum(["development", "production", "test"])
NEXT_PUBLIC_BASE_URL: z.preprocess( .default("development"),
preprocessEmptyString, NEXT_PUBLIC_BASE_URL: z.preprocess(
z.string().url().optional(), preprocessEmptyString,
), z.string().url().optional(),
NEXT_PUBLIC_TARGET: z ),
.enum(["development", "testing", "staging", "production"]) NEXT_PUBLIC_TARGET: z
.optional(), .enum(["development", "testing", "staging", "production"])
.optional(),
// Analytics // Analytics
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.preprocess( UMAMI_WEBSITE_ID: z.preprocess(
preprocessEmptyString, preprocessEmptyString,
z.string().optional(), z.string().optional(),
), ),
NEXT_PUBLIC_UMAMI_SCRIPT_URL: z.preprocess( UMAMI_API_ENDPOINT: z.preprocess(
preprocessEmptyString, preprocessEmptyString,
z.string().url().default("https://analytics.infra.mintel.me/script.js"), z.string().url().default("https://analytics.infra.mintel.me"),
), ),
// Error Tracking // Error Tracking
SENTRY_DSN: z.preprocess(preprocessEmptyString, z.string().optional()), SENTRY_DSN: z.preprocess(preprocessEmptyString, z.string().optional()),
// Logging // Logging
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"), LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
// Mail // Mail
MAIL_HOST: z.preprocess(preprocessEmptyString, z.string().optional()), MAIL_HOST: z.preprocess(preprocessEmptyString, z.string().optional()),
MAIL_PORT: z.preprocess( MAIL_PORT: z.preprocess(
preprocessEmptyString, preprocessEmptyString,
z.coerce.number().default(587), z.coerce.number().default(587),
), ),
MAIL_USERNAME: z.preprocess(preprocessEmptyString, z.string().optional()), MAIL_USERNAME: z.preprocess(preprocessEmptyString, z.string().optional()),
MAIL_PASSWORD: z.preprocess(preprocessEmptyString, z.string().optional()), MAIL_PASSWORD: z.preprocess(preprocessEmptyString, z.string().optional()),
MAIL_FROM: z.preprocess(preprocessEmptyString, z.string().optional()), MAIL_FROM: z.preprocess(preprocessEmptyString, z.string().optional()),
MAIL_RECIPIENTS: z.preprocess( MAIL_RECIPIENTS: z.preprocess(
(val) => (typeof val === "string" ? val.split(",").filter(Boolean) : val), (val) => (typeof val === "string" ? val.split(",").filter(Boolean) : val),
z.array(z.string()).default([]), z.array(z.string()).default([]),
), ),
// Directus // Directus
DIRECTUS_URL: z.preprocess( DIRECTUS_URL: z.preprocess(
preprocessEmptyString, preprocessEmptyString,
z.string().url().default("http://localhost:8055"), z.string().url().default("http://localhost:8055"),
), ),
DIRECTUS_ADMIN_EMAIL: z.preprocess( DIRECTUS_ADMIN_EMAIL: z.preprocess(
preprocessEmptyString, preprocessEmptyString,
z.string().optional(), z.string().optional(),
), ),
DIRECTUS_ADMIN_PASSWORD: z.preprocess( DIRECTUS_ADMIN_PASSWORD: z.preprocess(
preprocessEmptyString, preprocessEmptyString,
z.string().optional(), z.string().optional(),
), ),
DIRECTUS_API_TOKEN: z.preprocess( DIRECTUS_API_TOKEN: z.preprocess(
preprocessEmptyString, preprocessEmptyString,
z.string().optional(), z.string().optional(),
), ),
INTERNAL_DIRECTUS_URL: z.preprocess( INTERNAL_DIRECTUS_URL: z.preprocess(
preprocessEmptyString, preprocessEmptyString,
z.string().url().optional(), z.string().url().optional(),
), ),
// Deploy Target // Deploy Target
TARGET: z TARGET: z
.enum(["development", "testing", "staging", "production"]) .enum(["development", "testing", "staging", "production"])
.optional(), .optional(),
// Gotify // Gotify
GOTIFY_URL: z.preprocess(preprocessEmptyString, z.string().url().optional()), GOTIFY_URL: z.preprocess(
GOTIFY_TOKEN: z.preprocess(preprocessEmptyString, z.string().optional()), preprocessEmptyString,
}); z.string().url().optional(),
),
GOTIFY_TOKEN: z.preprocess(preprocessEmptyString, z.string().optional()),
})
.superRefine((data, ctx) => {
const target = data.NEXT_PUBLIC_TARGET || data.TARGET;
const isDev = target === "development" || !target;
const isBuildTimeValidation =
process.env.SKIP_RUNTIME_ENV_VALIDATION === "true";
const isServer = typeof window === "undefined";
// Only enforce server-only variables when running on the server.
// In the browser, non-NEXT_PUBLIC_ variables are undefined and should not trigger validation errors.
if (isServer && !isDev && !isBuildTimeValidation && !data.MAIL_HOST) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "MAIL_HOST is required in non-development environments",
path: ["MAIL_HOST"],
});
}
});
export type Env = z.infer<typeof envSchema>; export type Env = z.infer<typeof envSchema>;
@@ -92,8 +113,12 @@ export function getRawEnv() {
NODE_ENV: process.env.NODE_ENV, NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL, NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
NEXT_PUBLIC_TARGET: process.env.NEXT_PUBLIC_TARGET, NEXT_PUBLIC_TARGET: process.env.NEXT_PUBLIC_TARGET,
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID, UMAMI_WEBSITE_ID:
NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL, process.env.UMAMI_WEBSITE_ID || process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
UMAMI_API_ENDPOINT:
process.env.UMAMI_API_ENDPOINT ||
process.env.UMAMI_SCRIPT_URL ||
process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
SENTRY_DSN: process.env.SENTRY_DSN, SENTRY_DSN: process.env.SENTRY_DSN,
LOG_LEVEL: process.env.LOG_LEVEL, LOG_LEVEL: process.env.LOG_LEVEL,
MAIL_HOST: process.env.MAIL_HOST, MAIL_HOST: process.env.MAIL_HOST,

View File

@@ -0,0 +1,445 @@
# Analytics Service Layer
This directory contains the service layer implementation for analytics tracking in the KLZ Cables application.
## Overview
The analytics service layer provides a clean abstraction over different analytics implementations (Umami, Google Analytics, etc.) while maintaining a consistent API.
## Architecture
```
lib/services/analytics/
├── analytics-service.ts # Interface definition
├── umami-analytics-service.ts # Umami implementation
├── noop-analytics-service.ts # No-op fallback implementation
└── README.md # This file
```
## Components
### 1. AnalyticsService Interface (`analytics-service.ts`)
Defines the contract for all analytics services:
```typescript
export interface AnalyticsService {
track(eventName: string, props?: AnalyticsEventProperties): void;
trackPageview(url?: string): void;
}
```
**Key Features:**
- Type-safe event properties
- Consistent API across implementations
- Well-documented with JSDoc comments
### 2. UmamiAnalyticsService (`umami-analytics-service.ts`)
Implements the `AnalyticsService` interface for Umami analytics.
**Features:**
- Type-safe event tracking
- Automatic pageview tracking
- Browser environment detection
- Graceful error handling
- Comprehensive JSDoc documentation
**Usage:**
```typescript
import { UmamiAnalyticsService } from "@/lib/services/analytics/umami-analytics-service";
const service = new UmamiAnalyticsService({ enabled: true });
service.track("button_click", { button_id: "cta" });
service.trackPageview("/products/123");
```
### 3. NoopAnalyticsService (`noop-analytics-service.ts`)
A no-op implementation used as a fallback when analytics are disabled.
**Features:**
- Maintains the same API as other services
- Safe to call even when analytics are disabled
- No performance impact
- Comprehensive JSDoc documentation
**Usage:**
```typescript
import { NoopAnalyticsService } from "@/lib/services/analytics/noop-analytics-service";
const service = new NoopAnalyticsService();
service.track("button_click", { button_id: "cta" }); // Does nothing
service.trackPageview("/products/123"); // Does nothing
```
## Service Selection
The service layer automatically selects the appropriate implementation based on environment variables:
```typescript
// In lib/services/create-services.ts
const umamiEnabled = Boolean(process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID);
const analytics = umamiEnabled
? new UmamiAnalyticsService({ enabled: true })
: new NoopAnalyticsService();
```
## Environment Variables
### Required for Umami
```bash
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
```
### Optional (defaults provided)
```bash
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
```
## API Reference
### AnalyticsService Interface
#### `track(eventName: string, props?: AnalyticsEventProperties): void`
Track a custom event with optional properties.
**Parameters:**
- `eventName` - The name of the event to track
- `props` - Optional event properties (metadata)
**Example:**
```typescript
service.track("product_add_to_cart", {
product_id: "123",
product_name: "Cable",
price: 99.99,
quantity: 1,
});
```
#### `trackPageview(url?: string): void`
Track a pageview.
**Parameters:**
- `url` - The URL to track (defaults to current location)
**Example:**
```typescript
// Track current page
service.trackPageview();
// Track custom URL
service.trackPageview("/products/123?category=cables");
```
### UmamiAnalyticsService
#### Constructor
```typescript
new UmamiAnalyticsService(options: UmamiAnalyticsServiceOptions)
```
**Options:**
- `enabled: boolean` - Whether analytics are enabled
**Example:**
```typescript
const service = new UmamiAnalyticsService({ enabled: true });
```
### NoopAnalyticsService
#### Constructor
```typescript
new NoopAnalyticsService();
```
**Example:**
```typescript
const service = new NoopAnalyticsService();
```
## Type Definitions
### AnalyticsEventProperties
```typescript
type AnalyticsEventProperties = Record<
string,
string | number | boolean | null | undefined
>;
```
**Example:**
```typescript
const properties: AnalyticsEventProperties = {
product_id: "123",
product_name: "Cable",
price: 99.99,
quantity: 1,
in_stock: true,
discount: null,
};
```
### UmamiAnalyticsServiceOptions
```typescript
type UmamiAnalyticsServiceOptions = {
enabled: boolean;
};
```
## Best Practices
### 1. Use the Service Layer
Always use the service layer instead of calling Umami directly:
```typescript
// ✅ Good
import { getAppServices } from "@/lib/services/create-services";
const services = getAppServices();
services.analytics.track("button_click", { button_id: "cta" });
// ❌ Avoid
(window as any).umami?.track("button_click", { button_id: "cta" });
```
### 2. Check Environment
The service layer automatically handles environment detection:
```typescript
// ✅ Safe - works in both server and client
const services = getAppServices();
services.analytics.track("event", { prop: "value" });
// ❌ Unsafe - may fail in server environment
if (typeof window !== "undefined") {
window.umami?.track("event", { prop: "value" });
}
```
### 3. Use Type-Safe Events
Import events from the centralized definitions:
```typescript
import { AnalyticsEvents } from "@/components/analytics/analytics-events";
// ✅ Type-safe
services.analytics.track(AnalyticsEvents.BUTTON_CLICK, {
button_id: "cta",
});
// ❌ Prone to typos
services.analytics.track("button_click", {
button_id: "cta",
});
```
### 4. Handle Disabled Analytics
The service layer gracefully handles disabled analytics:
```typescript
// When NEXT_PUBLIC_UMAMI_WEBSITE_ID is not set:
// - NoopAnalyticsService is used
// - All calls are safe (no-op)
// - No errors are thrown
const services = getAppServices();
services.analytics.track("event", { prop: "value" }); // Safe, does nothing
```
## Testing
### Mocking for Tests
```typescript
// __tests__/analytics-mock.ts
export const mockAnalytics = {
track: jest.fn(),
trackPageview: jest.fn(),
};
jest.mock("@/lib/services/create-services", () => ({
getAppServices: () => ({
analytics: mockAnalytics,
}),
}));
// Usage in tests
import { mockAnalytics } from "./analytics-mock";
test("tracks button click", () => {
// ... test code ...
expect(mockAnalytics.track).toHaveBeenCalledWith("button_click", {
button_id: "cta",
});
});
```
### Development Mode
In development, the service layer logs to console:
```bash
# Console output:
[Umami] Tracked event: button_click { button_id: 'cta' }
[Umami] Tracked pageview: /products/123
```
## Error Handling
The service layer includes built-in error handling:
1. **Environment Detection** - Checks for browser environment
2. **Service Availability** - Checks if Umami is loaded
3. **Graceful Degradation** - Falls back to NoopAnalyticsService if needed
```typescript
// These are all safe:
const services = getAppServices();
services.analytics.track("event", { prop: "value" }); // Works or does nothing
services.analytics.trackPageview("/path"); // Works or does nothing
```
## Performance
### Singleton Pattern
The service layer uses a singleton pattern for performance:
```typescript
// First call creates the singleton
const services1 = getAppServices();
// Subsequent calls return the cached singleton
const services2 = getAppServices();
// services1 === services2 (same instance)
```
### Lazy Initialization
Services are only created when first accessed:
```typescript
// Services are not created until getAppServices() is called
// This keeps initial bundle size minimal
```
## Integration with Components
### Client Components
```typescript
'use client';
import { getAppServices } from '@/lib/services/create-services';
function MyComponent() {
const handleClick = () => {
const services = getAppServices();
services.analytics.track('button_click', { button_id: 'my-button' });
};
return <button onClick={handleClick}>Click Me</button>;
}
```
### Server Components
```typescript
import { getAppServices } from '@/lib/services/create-services';
async function MyServerComponent() {
const services = getAppServices();
// Note: Analytics won't work in server components
// Use client components for analytics tracking
// But you can still access other services like cache
const data = await services.cache.get('key');
return <div>{data}</div>;
}
```
## Troubleshooting
### Analytics Not Working
1. **Check environment variables:**
```bash
echo $NEXT_PUBLIC_UMAMI_WEBSITE_ID
```
2. **Verify service selection:**
```typescript
import { getAppServices } from "@/lib/services/create-services";
const services = getAppServices();
console.log(services.analytics); // Should be UmamiAnalyticsService
```
3. **Check Umami dashboard:**
- Log into Umami
- Verify website ID matches
- Check if data is being received
### Common Issues
| Issue | Solution |
| ------------------- | ----------------------------------- |
| No data in Umami | Check website ID and script URL |
| Events not tracking | Verify service is being used |
| Script not loading | Check network connection, CORS |
| Wrong data | Verify event properties are correct |
## Related Files
- [`components/analytics/useAnalytics.ts`](../components/analytics/useAnalytics.ts) - Custom hook for easy event tracking
- [`components/analytics/analytics-events.ts`](../components/analytics/analytics-events.ts) - Event definitions
- [`components/analytics/UmamiScript.tsx`](../components/analytics/UmamiScript.tsx) - Script loader component
- [`components/analytics/AnalyticsProvider.tsx`](../components/analytics/AnalyticsProvider.tsx) - Route change tracker
- [`lib/services/create-services.ts`](../lib/services/create-services.ts) - Service factory
## Summary
The analytics service layer provides:
-**Type-safe API** - TypeScript throughout
-**Clean abstraction** - Easy to switch analytics providers
-**Graceful degradation** - Safe no-op fallback
-**Comprehensive documentation** - JSDoc comments and examples
-**Performance optimized** - Singleton pattern, lazy initialization
-**Error handling** - Safe in all environments
This layer is the foundation for all analytics tracking in the application.

View File

@@ -1,3 +1,76 @@
/**
* Type definition for analytics event properties.
*
* @example
* ```typescript
* const properties: AnalyticsEventProperties = {
* product_id: '123',
* product_name: 'Cable',
* price: 99.99,
* quantity: 1,
* in_stock: true,
* };
* ```
*/
export type AnalyticsEventProperties = Record<
string,
string | number | boolean | null | undefined
>;
/**
* Interface for analytics service implementations.
*
* This interface defines the contract for all analytics services,
* allowing for different implementations (Umami, Google Analytics, etc.)
* while maintaining a consistent API.
*
* @example
* ```typescript
* // Using the service directly
* const service = new UmamiAnalyticsService({ enabled: true });
* service.track('button_click', { button_id: 'cta' });
* service.trackPageview('/products/123');
* ```
*
* @example
* ```typescript
* // Using the useAnalytics hook (recommended)
* const { trackEvent, trackPageview } = useAnalytics();
* trackEvent('button_click', { button_id: 'cta' });
* trackPageview('/products/123');
* ```
*/
export interface AnalyticsService { export interface AnalyticsService {
trackEvent(name: string, properties?: Record<string, unknown>): void; /**
* Track a custom event with optional properties.
*
* @param eventName - The name of the event to track
* @param props - Optional event properties (metadata)
*
* @example
* ```typescript
* track('product_add_to_cart', {
* product_id: '123',
* product_name: 'Cable',
* price: 99.99,
* });
* ```
*/
track(eventName: string, props?: AnalyticsEventProperties): void;
/**
* Track a pageview.
*
* @param url - The URL to track (defaults to current location)
*
* @example
* ```typescript
* // Track current page
* trackPageview();
*
* // Track custom URL
* trackPageview('/products/123?category=cables');
* ```
*/
trackPageview(url?: string): void;
} }

View File

@@ -1,5 +1,71 @@
import type { AnalyticsService } from "./analytics-service"; /* eslint-disable @typescript-eslint/no-unused-vars */
import type {
AnalyticsEventProperties,
AnalyticsService,
} from "./analytics-service";
/**
* No-op Analytics Service Implementation.
*
* This service implements the AnalyticsService interface but does nothing.
* It's used as a fallback when analytics are disabled or not configured.
*
* @example
* ```typescript
* // Service creation (usually done by create-services.ts)
* const service = new NoopAnalyticsService();
*
* // These calls do nothing but are safe to execute
* service.track('button_click', { button_id: 'cta' });
* service.trackPageview('/products/123');
* ```
*
* @example
* ```typescript
* // Automatic fallback in create-services.ts
* const umamiEnabled = Boolean(process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID);
* const analytics = umamiEnabled
* ? new UmamiAnalyticsService({ enabled: true })
* : new NoopAnalyticsService(); // Fallback when no website ID
* ```
*/
export class NoopAnalyticsService implements AnalyticsService { export class NoopAnalyticsService implements AnalyticsService {
trackEvent() {} /**
* No-op implementation of track.
*
* This method does nothing but maintains the same signature as other
* analytics services for consistency.
*
* @param _eventName - Event name (ignored)
* @param _props - Event properties (ignored)
*
* @example
* ```typescript
* // Safe to call even when analytics are disabled
* service.track('button_click', { button_id: 'cta' });
* // No error, no action taken
* ```
*/
track(_eventName: string, _props?: AnalyticsEventProperties) {
// intentionally noop - analytics are disabled
}
/**
* No-op implementation of trackPageview.
*
* This method does nothing but maintains the same signature as other
* analytics services for consistency.
*
* @param _url - URL to track (ignored)
*
* @example
* ```typescript
* // Safe to call even when analytics are disabled
* service.trackPageview('/products/123');
* // No error, no action taken
* ```
*/
trackPageview(_url?: string) {
// intentionally noop - analytics are disabled
}
} }

View File

@@ -0,0 +1,112 @@
import type {
AnalyticsEventProperties,
AnalyticsService,
} from "./analytics-service";
import { config } from "../../config";
/**
* Configuration options for UmamiAnalyticsService.
*
* @property enabled - Whether analytics are enabled
*/
export type UmamiAnalyticsServiceOptions = {
enabled: boolean;
};
/**
* Umami Analytics Service Implementation (Script-less/Proxy edition).
*
* This version implements the Umami tracking protocol directly via fetch,
* eliminating the need to load an external script.js file.
*
* In the browser, it gathers standard metadata (screen, language, referrer)
* and sends it to the proxied '/stats/api/send' endpoint.
*/
export class UmamiAnalyticsService implements AnalyticsService {
private websiteId?: string;
private endpoint: string;
constructor(private readonly options: UmamiAnalyticsServiceOptions) {
this.websiteId = config.analytics.umami.websiteId;
// On server, use the full internal URL; on client, use the proxied path
this.endpoint =
typeof window === "undefined"
? config.analytics.umami.apiEndpoint
: "/stats";
}
/**
* Internal method to send the payload to Umami API.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private async sendPayload(type: "event", data: Record<string, unknown>) {
if (!this.options.enabled || !this.websiteId) return;
try {
const payload = {
website: this.websiteId,
hostname:
typeof window !== "undefined" ? window.location.hostname : "server",
screen:
typeof window !== "undefined"
? `${window.screen.width}x${window.screen.height}`
: undefined,
language:
typeof window !== "undefined" ? navigator.language : undefined,
referrer: typeof window !== "undefined" ? document.referrer : undefined,
...data,
};
const response = await fetch(`${this.endpoint}/api/send`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent":
typeof window === "undefined" ? "KLZ-Server" : navigator.userAgent,
},
body: JSON.stringify({ type, payload }),
keepalive: true,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
if (!response.ok && process.env.NODE_ENV === "development") {
const errorText = await response.text();
console.warn(
`[Umami] API responded with ${response.status}: ${errorText}`,
);
}
} catch (error) {
if (process.env.NODE_ENV === "development") {
console.error("[Umami] Failed to send analytics:", error);
}
}
}
/**
* Track a custom event.
*/
track(eventName: string, props?: AnalyticsEventProperties) {
this.sendPayload("event", {
name: eventName,
data: props,
url:
typeof window !== "undefined"
? window.location.pathname + window.location.search
: undefined,
});
}
/**
* Track a pageview.
*/
trackPageview(url?: string) {
this.sendPayload("event", {
url:
url ||
(typeof window !== "undefined"
? window.location.pathname + window.location.search
: undefined),
});
}
}

View File

@@ -4,6 +4,7 @@ import type { ErrorReportingService } from "./errors/error-reporting-service";
import type { LoggerService } from "./logging/logger-service"; import type { LoggerService } from "./logging/logger-service";
import type { NotificationService } from "./notifications/notification-service"; import type { NotificationService } from "./notifications/notification-service";
// Simple constructor-based DI container.
export class AppServices { export class AppServices {
constructor( constructor(
public readonly analytics: AnalyticsService, public readonly analytics: AnalyticsService,

View File

@@ -1,5 +1,9 @@
export type CacheSetOptions = {
ttlSeconds?: number;
};
export interface CacheService { export interface CacheService {
get<T>(key: string): Promise<T | null>; get<T>(key: string): Promise<T | undefined>;
set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>; set<T>(key: string, value: T, options?: CacheSetOptions): Promise<void>;
delete(key: string): Promise<void>; del(key: string): Promise<void>;
} }

View File

@@ -1,26 +1,30 @@
import type { CacheService } from "./cache-service"; import type { CacheService, CacheSetOptions } from "./cache-service";
type Entry = {
value: unknown;
expiresAt?: number;
};
export class MemoryCacheService implements CacheService { export class MemoryCacheService implements CacheService {
private cache = new Map<string, { value: unknown; expiry: number | null }>(); private readonly store = new Map<string, Entry>();
async get<T>(key: string): Promise<T | null> { async get<T>(key: string) {
const item = this.cache.get(key); const entry = this.store.get(key);
if (!item) return null; if (!entry) return undefined;
if (entry.expiresAt && Date.now() > entry.expiresAt) {
if (item.expiry && item.expiry < Date.now()) { this.store.delete(key);
this.cache.delete(key); return undefined;
return null;
} }
return entry.value as T;
return item.value as T;
} }
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> { async set<T>(key: string, value: T, options?: CacheSetOptions) {
const expiry = ttlSeconds ? Date.now() + ttlSeconds * 1000 : null; const ttl = options?.ttlSeconds;
this.cache.set(key, { value, expiry }); const expiresAt = ttl ? Date.now() + ttl * 1000 : undefined;
this.store.set(key, { value, expiresAt });
} }
async delete(key: string): Promise<void> { async del(key: string) {
this.cache.delete(key); this.store.delete(key);
} }
} }

View File

@@ -1,18 +1,21 @@
import { AppServices } from "./app-services"; import { AppServices } from "./app-services";
import { NoopAnalyticsService } from "./analytics/noop-analytics-service"; import { NoopAnalyticsService } from "./analytics/noop-analytics-service";
import { UmamiAnalyticsService } from "./analytics/umami-analytics-service";
import { MemoryCacheService } from "./cache/memory-cache-service"; import { MemoryCacheService } from "./cache/memory-cache-service";
import { GlitchtipErrorReportingService } from "./errors/glitchtip-error-reporting-service"; import { GlitchtipErrorReportingService } from "./errors/glitchtip-error-reporting-service";
import { NoopErrorReportingService } from "./errors/noop-error-reporting-service"; import { NoopErrorReportingService } from "./errors/noop-error-reporting-service";
import { GotifyNotificationService } from "./notifications/gotify-notification-service"; import {
import { NoopNotificationService } from "./notifications/noop-notification-service"; GotifyNotificationService,
NoopNotificationService,
} from "./notifications/gotify-notification-service";
import { PinoLoggerService } from "./logging/pino-logger-service"; import { PinoLoggerService } from "./logging/pino-logger-service";
import { config, getMaskedConfig } from "../config"; import { config, getMaskedConfig } from "../config";
let singleton: AppServices | undefined; let singleton: AppServices | undefined;
export function getServerAppServices(): AppServices { export function getServerAppServices(): AppServices {
if (singleton) return singleton; if (singleton) return singleton;
// Create logger first to log initialization
const logger = new PinoLoggerService("server"); const logger = new PinoLoggerService("server");
logger.info("Initializing server application services", { logger.info("Initializing server application services", {
@@ -20,7 +23,22 @@ export function getServerAppServices(): AppServices {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
const analytics = new NoopAnalyticsService(); logger.info("Service configuration", {
umamiEnabled: config.analytics.umami.enabled,
sentryEnabled: config.errors.glitchtip.enabled,
mailEnabled: Boolean(config.mail.host && config.mail.user),
gotifyEnabled: config.notifications.gotify.enabled,
});
const analytics = config.analytics.umami.enabled
? new UmamiAnalyticsService({ enabled: true })
: new NoopAnalyticsService();
if (config.analytics.umami.enabled) {
logger.info("Umami analytics service initialized");
} else {
logger.info("Noop analytics service initialized (analytics disabled)");
}
const notifications = config.notifications.gotify.enabled const notifications = config.notifications.gotify.enabled
? new GotifyNotificationService({ ? new GotifyNotificationService({
@@ -30,11 +48,35 @@ export function getServerAppServices(): AppServices {
}) })
: new NoopNotificationService(); : new NoopNotificationService();
if (config.notifications.gotify.enabled) {
logger.info("Gotify notification service initialized");
} else {
logger.info(
"Noop notification service initialized (notifications disabled)",
);
}
const errors = config.errors.glitchtip.enabled const errors = config.errors.glitchtip.enabled
? new GlitchtipErrorReportingService({ enabled: true }, notifications) ? new GlitchtipErrorReportingService({ enabled: true }, notifications)
: new NoopErrorReportingService(); : new NoopErrorReportingService();
if (config.errors.glitchtip.enabled) {
logger.info("GlitchTip error reporting service initialized", {
dsnPresent: Boolean(config.errors.glitchtip.dsn),
});
} else {
logger.info(
"Noop error reporting service initialized (error reporting disabled)",
);
}
const cache = new MemoryCacheService(); const cache = new MemoryCacheService();
logger.info("Memory cache service initialized");
logger.info("Pino logger service initialized", {
name: "server",
level: config.logging.level,
});
singleton = new AppServices(analytics, errors, cache, logger, notifications); singleton = new AppServices(analytics, errors, cache, logger, notifications);

View File

@@ -0,0 +1,154 @@
import { AppServices } from "./app-services";
import { NoopAnalyticsService } from "./analytics/noop-analytics-service";
import { MemoryCacheService } from "./cache/memory-cache-service";
import { GlitchtipErrorReportingService } from "./errors/glitchtip-error-reporting-service";
import { NoopErrorReportingService } from "./errors/noop-error-reporting-service";
import { NoopLoggerService } from "./logging/noop-logger-service";
import { PinoLoggerService } from "./logging/pino-logger-service";
import { NoopNotificationService } from "./notifications/gotify-notification-service";
import { config, getMaskedConfig } from "../config";
/**
* Singleton instance of AppServices.
*
* In Next.js, module singletons are per-process (server) and per-tab (client).
* This is sufficient for a small service layer and provides better performance
* than creating new instances on every request.
*
* @private
*/
let singleton: AppServices | undefined;
/**
* Get the application services singleton.
*
* This function creates and caches the application services, including:
* - Analytics service (Umami or no-op)
* - Error reporting service (GlitchTip/Sentry or no-op)
* - Cache service (in-memory)
*
* The services are configured based on environment variables:
* - `UMAMI_WEBSITE_ID` - Enables Umami analytics
* - `NEXT_PUBLIC_SENTRY_DSN` - Enables client-side error reporting
* - `SENTRY_DSN` - Enables server-side error reporting
*
* @returns {AppServices} The application services singleton
*
* @example
* ```typescript
* // Get services in a client component
* import { getAppServices } from '@/lib/services/create-services';
*
* const services = getAppServices();
* services.analytics.track('button_click', { button_id: 'cta' });
* ```
*
* @example
* ```typescript
* // Get services in a server component or API route
* import { getAppServices } from '@/lib/services/create-services';
*
* const services = getAppServices();
* await services.cache.set('key', 'value');
* ```
*
* @example
* ```typescript
* // Automatic service selection based on environment
* // If NEXT_PUBLIC_UMAMI_WEBSITE_ID is set:
* // services.analytics = UmamiAnalyticsService
* // If not set:
* // services.analytics = NoopAnalyticsService (safe no-op)
* ```
*
* @see {@link UmamiAnalyticsService} for analytics implementation
* @see {@link NoopAnalyticsService} for no-op fallback
* @see {@link GlitchtipErrorReportingService} for error reporting
* @see {@link MemoryCacheService} for caching
*/
export function getAppServices(): AppServices {
// Return cached instance if available
if (singleton) return singleton;
// Create logger first to log initialization
const logger =
typeof window === "undefined"
? new PinoLoggerService("server")
: new NoopLoggerService();
// Log initialization
if (typeof window === "undefined") {
// Server-side
logger.info("Initializing server application services", {
environment: getMaskedConfig(),
timestamp: new Date().toISOString(),
});
} else {
// Client-side
logger.info("Initializing client application services", {
environment: getMaskedConfig(),
timestamp: new Date().toISOString(),
});
}
// Determine which services to enable based on environment variables
const umamiEnabled = config.analytics.umami.enabled;
const sentryEnabled = config.errors.glitchtip.enabled;
logger.info("Service configuration", {
umamiEnabled,
sentryEnabled,
isServer: typeof window === "undefined",
});
// Create analytics service (Umami or no-op)
// Use dynamic import to avoid importing server-only code in client components
const analytics = umamiEnabled
? (() => {
const { UmamiAnalyticsService } =
// eslint-disable-next-line @typescript-eslint/no-require-imports
require("./analytics/umami-analytics-service");
return new UmamiAnalyticsService({ enabled: true });
})()
: new NoopAnalyticsService();
if (umamiEnabled) {
logger.info("Umami analytics service initialized");
} else {
logger.info("Noop analytics service initialized (analytics disabled)");
}
// Create error reporting service (GlitchTip/Sentry or no-op)
const errors = sentryEnabled
? new GlitchtipErrorReportingService({ enabled: true })
: new NoopErrorReportingService();
if (sentryEnabled) {
logger.info(
`GlitchTip error reporting service initialized (${typeof window === "undefined" ? "server" : "client"})`,
);
} else {
logger.info(
"Noop error reporting service initialized (error reporting disabled)",
);
}
// IMPORTANT: This module is imported by client components.
// Do not import Node-only modules (like the `redis` client) here.
// Use [`getServerAppServices()`](lib/services/create-services.server.ts:1) on the server.
const cache = new MemoryCacheService();
logger.info("Memory cache service initialized");
logger.info("Pino logger service initialized", {
name: typeof window === "undefined" ? "server" : "client",
level: config.logging.level,
});
// Create and cache the singleton
const notifications = new NoopNotificationService();
singleton = new AppServices(analytics, errors, cache, logger, notifications);
logger.info("All application services initialized successfully");
return singleton;
}

View File

@@ -1,4 +1,27 @@
export type ErrorReportingUser = {
id?: string;
email?: string;
username?: string;
};
export type ErrorReportingLevel =
| "fatal"
| "error"
| "warning"
| "info"
| "debug"
| "log";
export interface ErrorReportingService { export interface ErrorReportingService {
captureException(error: unknown, context?: Record<string, unknown>): void; captureException(
captureMessage(message: string, context?: Record<string, unknown>): void; error: unknown,
context?: Record<string, unknown>,
): Promise<string | undefined> | string | undefined;
captureMessage(
message: string,
level?: ErrorReportingLevel,
): Promise<string | undefined> | string | undefined;
setUser(user: ErrorReportingUser | null): void;
setTag(key: string, value: string): void;
withScope<T>(fn: () => T, context?: Record<string, unknown>): T;
} }

View File

@@ -1,48 +1,77 @@
import * as Sentry from "@sentry/nextjs"; import * as Sentry from "@sentry/nextjs";
import type { ErrorReportingService } from "./error-reporting-service"; import type {
ErrorReportingLevel,
ErrorReportingService,
ErrorReportingUser,
} from "./error-reporting-service";
import type { NotificationService } from "../notifications/notification-service"; import type { NotificationService } from "../notifications/notification-service";
export interface GlitchtipConfig { type SentryLike = typeof Sentry;
enabled: boolean;
}
export type GlitchtipErrorReportingServiceOptions = {
enabled: boolean;
};
// GlitchTip speaks the Sentry protocol; @sentry/nextjs can send to GlitchTip via DSN.
export class GlitchtipErrorReportingService implements ErrorReportingService { export class GlitchtipErrorReportingService implements ErrorReportingService {
constructor( constructor(
private readonly config: GlitchtipConfig, private readonly options: GlitchtipErrorReportingServiceOptions,
private readonly notifications?: NotificationService, private readonly notifications?: NotificationService,
private readonly sentry: SentryLike = Sentry,
) {} ) {}
captureException(error: unknown, context?: Record<string, unknown>) { async captureException(error: unknown, context?: Record<string, unknown>) {
if (!this.config.enabled) return; if (!this.options.enabled) return undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Sentry.withScope((scope) => { const result = this.sentry.captureException(error, context as any) as any;
if (context) {
scope.setExtras(context);
}
Sentry.captureException(error);
});
// Send to Gotify if it's considered critical or if we just want all exceptions there
// For now, let's send all exceptions to Gotify as requested "notify me via gotify about critical error messages"
// We'll treat all captureException calls as potentially critical or at least noteworthy
if (this.notifications) { if (this.notifications) {
this.notifications const errorMessage =
.notify({ error instanceof Error ? error.message : String(error);
title: "🚨 Exception Captured", const contextStr = context
message: error instanceof Error ? error.message : String(error), ? `\nContext: ${JSON.stringify(context, null, 2)}`
priority: 10, : "";
})
.catch((err) => await this.notifications.notify({
console.error("Failed to send notification for exception", err), title: "🔥 Critical Error Captured",
); message: `Error: ${errorMessage}${contextStr}`,
priority: 7,
});
} }
return result;
} }
captureMessage(message: string, context?: Record<string, unknown>) { captureMessage(message: string, level: ErrorReportingLevel = "error") {
if (!this.config.enabled) return; if (!this.options.enabled) return undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return this.sentry.captureMessage(message, level as any) as any;
}
Sentry.withScope((scope) => { setUser(user: ErrorReportingUser | null) {
if (!this.options.enabled) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.sentry.setUser(user as any);
}
setTag(key: string, value: string) {
if (!this.options.enabled) return;
this.sentry.setTag(key, value);
}
withScope<T>(fn: () => T, context?: Record<string, unknown>) {
if (!this.options.enabled) return fn();
return this.sentry.withScope((scope) => {
if (context) { if (context) {
scope.setExtras(context); for (const [key, value] of Object.entries(context)) {
scope.setExtra(key, value);
}
} }
Sentry.captureMessage(message); return fn();
}); });
} }
} }

View File

@@ -1,6 +1,23 @@
import type { ErrorReportingService } from "./error-reporting-service"; /* eslint-disable @typescript-eslint/no-unused-vars */
import type {
ErrorReportingLevel,
ErrorReportingService,
ErrorReportingUser,
} from "./error-reporting-service";
export class NoopErrorReportingService implements ErrorReportingService { export class NoopErrorReportingService implements ErrorReportingService {
captureException() {} async captureException(_error: unknown, _context?: Record<string, unknown>) {
captureMessage() {} return undefined;
}
async captureMessage(_message: string, _level?: ErrorReportingLevel) {
return undefined;
}
setUser(_user: ErrorReportingUser | null) {}
setTag(_key: string, _value: string) {}
withScope<T>(fn: () => T, _context?: Record<string, unknown>) {
return fn();
}
} }

View File

@@ -1,7 +1,11 @@
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
export interface LoggerService { export interface LoggerService {
debug(message: string, context?: Record<string, unknown>): void; trace(msg: string, ...args: unknown[]): void;
info(message: string, context?: Record<string, unknown>): void; debug(msg: string, ...args: unknown[]): void;
warn(message: string, context?: Record<string, unknown>): void; info(msg: string, ...args: unknown[]): void;
error(message: string, context?: Record<string, unknown>): void; warn(msg: string, ...args: unknown[]): void;
child(context: Record<string, unknown>): LoggerService; error(msg: string, ...args: unknown[]): void;
fatal(msg: string, ...args: unknown[]): void;
child(bindings: Record<string, unknown>): LoggerService;
} }

View File

@@ -0,0 +1,13 @@
import type { LoggerService } from "./logger-service";
export class NoopLoggerService implements LoggerService {
trace() {}
debug() {}
info() {}
warn() {}
error() {}
fatal() {}
child() {
return this;
}
}

View File

@@ -1,14 +1,17 @@
import { pino, type Logger as PinoLogger } from "pino"; import pino, { Logger as PinoLogger } from "pino";
import type { LoggerService } from "./logger-service"; import type { LoggerService } from "./logger-service";
import { config } from "../../config"; import { config } from "../../config";
export class PinoLoggerService implements LoggerService { export class PinoLoggerService implements LoggerService {
private logger: PinoLogger; private readonly logger: PinoLogger;
constructor(name?: string, parent?: PinoLogger) { constructor(name?: string, parent?: PinoLogger) {
if (parent) { if (parent) {
this.logger = parent.child({ name }); this.logger = parent.child({ name });
} else { } else {
// In Next.js, especially in the Edge runtime or during instrumentation,
// pino transports (which use worker threads) can cause issues.
// We disable transport in production and during instrumentation.
const useTransport = const useTransport =
config.isDevelopment && typeof window === "undefined"; config.isDevelopment && typeof window === "undefined";
@@ -27,30 +30,41 @@ export class PinoLoggerService implements LoggerService {
} }
} }
debug(message: string, context?: Record<string, unknown>) { trace(msg: string, ...args: unknown[]) {
if (context) this.logger.debug(context, message); // eslint-disable-next-line @typescript-eslint/no-explicit-any
else this.logger.debug(message); this.logger.trace(msg, ...(args as any));
} }
info(message: string, context?: Record<string, unknown>) { debug(msg: string, ...args: unknown[]) {
if (context) this.logger.info(context, message); // eslint-disable-next-line @typescript-eslint/no-explicit-any
else this.logger.info(message); this.logger.debug(msg, ...(args as any));
} }
warn(message: string, context?: Record<string, unknown>) { info(msg: string, ...args: unknown[]) {
if (context) this.logger.warn(context, message); // eslint-disable-next-line @typescript-eslint/no-explicit-any
else this.logger.warn(message); this.logger.info(msg, ...(args as any));
} }
error(message: string, context?: Record<string, unknown>) { warn(msg: string, ...args: unknown[]) {
if (context) this.logger.error(context, message); // eslint-disable-next-line @typescript-eslint/no-explicit-any
else this.logger.error(message); this.logger.warn(msg, ...(args as any));
} }
child(context: Record<string, unknown>): LoggerService { error(msg: string, ...args: unknown[]) {
const childPino = this.logger.child(context); // eslint-disable-next-line @typescript-eslint/no-explicit-any
this.logger.error(msg, ...(args as any));
}
fatal(msg: string, ...args: unknown[]) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.logger.fatal(msg, ...(args as any));
}
child(bindings: Record<string, unknown>): LoggerService {
const childPino = this.logger.child(bindings);
const service = new PinoLoggerService(); const service = new PinoLoggerService();
service.logger = childPino; // eslint-disable-next-line @typescript-eslint/no-explicit-any
(service as any).logger = childPino;
return service; return service;
} }
} }

View File

@@ -1,5 +1,5 @@
import type { import {
NotificationMessage, NotificationOptions,
NotificationService, NotificationService,
} from "./notification-service"; } from "./notification-service";
@@ -10,35 +10,44 @@ export interface GotifyConfig {
} }
export class GotifyNotificationService implements NotificationService { export class GotifyNotificationService implements NotificationService {
constructor(private readonly config: GotifyConfig) {} constructor(private config: GotifyConfig) {}
async notify(message: NotificationMessage): Promise<void> { async notify(options: NotificationOptions): Promise<void> {
if (!this.config.enabled) return; if (!this.config.enabled) return;
try { try {
const response = await fetch( const { title, message, priority = 4 } = options;
`${this.config.url}/message?token=${this.config.token}`, const url = new URL("message", this.config.url);
{ url.searchParams.set("token", this.config.token);
method: "POST",
headers: { const response = await fetch(url.toString(), {
"Content-Type": "application/json", method: "POST",
}, headers: {
body: JSON.stringify({ "Content-Type": "application/json",
title: message.title,
message: message.message,
priority: message.priority ?? 5,
}),
}, },
); body: JSON.stringify({
title,
message,
priority,
}),
});
if (!response.ok) { if (!response.ok) {
console.error( const errorText = await response.text();
"Failed to send Gotify notification", console.error("Gotify notification failed:", {
await response.text(), status: response.status,
); error: errorText,
});
} }
} catch (error) { } catch (error) {
console.error("Error sending Gotify notification", error); console.error("Gotify notification error:", error);
} }
} }
} }
export class NoopNotificationService implements NotificationService {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async notify(_options: NotificationOptions): Promise<void> {
// Do nothing
}
}

View File

@@ -1,5 +0,0 @@
import type { NotificationService } from "./notification-service";
export class NoopNotificationService implements NotificationService {
async notify() {}
}

View File

@@ -1,9 +1,9 @@
export interface NotificationMessage { export interface NotificationOptions {
title: string; title: string;
message: string; message: string;
priority?: number; // 0-10, Gotify style priority?: number;
} }
export interface NotificationService { export interface NotificationService {
notify(message: NotificationMessage): Promise<void>; notify(options: NotificationOptions): Promise<void>;
} }

View File

@@ -1,6 +1,28 @@
import withMintelConfig from "@mintel/next-config"; import withMintelConfig from "@mintel/next-config";
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = {}; const nextConfig = {
async rewrites() {
const umamiUrl =
process.env.UMAMI_API_ENDPOINT ||
process.env.UMAMI_SCRIPT_URL ||
process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL ||
"https://analytics.infra.mintel.me";
const glitchtipUrl = process.env.SENTRY_DSN
? new URL(process.env.SENTRY_DSN).origin
: "https://errors.infra.mintel.me";
return [
{
source: "/stats/:path*",
destination: `${umamiUrl}/:path*`,
},
{
source: "/errors/:path*",
destination: `${glitchtipUrl}/:path*`,
},
];
},
};
export default withMintelConfig(nextConfig); export default withMintelConfig(nextConfig);

View File

@@ -11,13 +11,13 @@
"lint": "eslint app components lib scripts", "lint": "eslint app components lib scripts",
"test": "vitest", "test": "vitest",
"prepare": "husky", "prepare": "husky",
"directus:bootstrap": "DIRECTUS_URL=http://localhost:8055 npx tsx --env-file=.env scripts/setup-directus.ts", "cms:bootstrap": "DIRECTUS_URL=http://localhost:8055 npx tsx --env-file=.env scripts/setup-directus.ts",
"directus:push:staging": "./scripts/sync-directus.sh push staging", "cms:push:staging": "./scripts/sync-directus.sh push staging",
"directus:pull:staging": "./scripts/sync-directus.sh pull staging", "cms:pull:staging": "./scripts/sync-directus.sh pull staging",
"directus:push:testing": "./scripts/sync-directus.sh push testing", "cms:push:testing": "./scripts/sync-directus.sh push testing",
"directus:pull:testing": "./scripts/sync-directus.sh pull testing", "cms:pull:testing": "./scripts/sync-directus.sh pull testing",
"directus:push:prod": "./scripts/sync-directus.sh push production", "cms:push:prod": "./scripts/sync-directus.sh push production",
"directus:pull:prod": "./scripts/sync-directus.sh pull production", "cms:pull:prod": "./scripts/sync-directus.sh pull production",
"pagespeed:test": "mintel pagespeed test" "pagespeed:test": "mintel pagespeed test"
}, },
"keywords": [], "keywords": [],

7
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,7 @@
onlyBuiltDependencies:
- '@parcel/watcher'
- '@sentry/cli'
- '@swc/core'
- esbuild
- sharp
- unrs-resolver

View File

@@ -7,26 +7,36 @@ import { createCollection, createField, updateSettings } from "@directus/sdk";
const client = createMintelDirectusClient(); const client = createMintelDirectusClient();
async function setupBranding() { async function setupBranding() {
const prjName = process.env.PROJECT_NAME || "Mintel Project"; const prjName = process.env.PROJECT_NAME || "MB Grid Solutions";
const prjColor = process.env.PROJECT_COLOR || "#82ed20"; const prjColor = process.env.PROJECT_COLOR || "#82ed20";
console.log(`🎨 Setup Directus Branding for ${prjName}...`); console.log(`🎨 Refining Directus Branding for ${prjName}...`);
await ensureDirectusAuthenticated(client); await ensureDirectusAuthenticated(client);
const cssInjection = ` const cssInjection = `
<style> <style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap');
body, .v-app { font-family: 'Inter', sans-serif !important; }
body, .v-app { font-family: 'Outfit', sans-serif !important; }
.public-view .v-card { .public-view .v-card {
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
background: rgba(255, 255, 255, 0.9) !important;
border-radius: 32px !important; border-radius: 32px !important;
box-shadow: 0 50px 100px -20px rgba(0, 0, 0, 0.4) !important; box-shadow: 0 50px 100px -20px rgba(0, 0, 0, 0.4) !important;
border: 1px solid rgba(255, 255, 255, 0.3) !important;
} }
.v-navigation-drawer { background: #000c24 !important; } .v-navigation-drawer { background: #000c24 !important; }
.v-list-item--active {
color: ${prjColor} !important;
background: rgba(130, 237, 32, 0.1) !important;
}
</style> </style>
<div style="font-family: 'Inter', sans-serif; text-align: center; margin-top: 24px;"> <div style="font-family: 'Outfit', sans-serif; text-align: center; margin-top: 24px;">
<p style="color: rgba(255,255,255,0.7); font-size: 14px; margin-bottom: 4px; font-weight: 500;">MINTEL INFRASTRUCTURE ENGINE</p> <p style="color: rgba(255,255,255,0.6); font-size: 11px; letter-spacing: 2px; margin-bottom: 4px; font-weight: 600; text-transform: uppercase;">Mintel Infrastructure Engine</p>
<h1 style="color: #ffffff; font-size: 18px; font-weight: 700; margin: 0;">${prjName.toUpperCase()} <span style="color: ${prjColor};">RELIABILITY.</span></h1> <h1 style="color: #ffffff; font-size: 20px; font-weight: 700; margin: 0; letter-spacing: -0.5px;">${prjName.toUpperCase()} <span style="color: ${prjColor};">SYNC.</span></h1>
</div> </div>
`; `;
@@ -36,25 +46,23 @@ async function setupBranding() {
project_name: prjName, project_name: prjName,
project_color: prjColor, project_color: prjColor,
public_note: cssInjection, public_note: cssInjection,
module_bar_background: "#00081a",
theme_light_overrides: { theme_light_overrides: {
primary: prjColor, primary: prjColor,
borderRadius: "16px", borderRadius: "12px",
navigationBackground: "#000c24", navigationBackground: "#000c24",
navigationForeground: "#ffffff", navigationForeground: "#ffffff",
moduleBarBackground: "#00081a",
}, },
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any), } as any),
); );
console.log("✨ Branding applied!"); console.log("✨ Branding applied!");
try { await createCollectionAndFields();
await createCollectionAndFields(); console.log("🏗️ Schema alignment complete!");
console.log("🏗️ Schema alignment complete!");
} catch (error) {
console.error("❌ Error aligning schema:", error);
}
} catch (error) { } catch (error) {
console.error("❌ Error setting up branding:", error); console.error("❌ Error during bootstrap:", error);
} }
} }
@@ -69,6 +77,9 @@ async function createCollectionAndFields() {
meta: { meta: {
icon: "contact_mail", icon: "contact_mail",
display_template: "{{name}} <{{email}}>", display_template: "{{name}} <{{email}}>",
group: null,
sort: null,
collapse: "open",
}, },
}), }),
); );
@@ -84,9 +95,7 @@ async function createCollectionAndFields() {
); );
console.log(`✅ Collection ${collectionName} created.`); console.log(`✅ Collection ${collectionName} created.`);
} catch { } catch {
console.log( console.log(` Collection ${collectionName} exists.`);
` Collection ${collectionName} already exists or error occured.`,
);
} }
const safeAddField = async ( const safeAddField = async (
@@ -102,14 +111,40 @@ async function createCollectionAndFields() {
} }
}; };
await safeAddField("name", "string", { interface: "input" }); await safeAddField("name", "string", {
await safeAddField("email", "string", { interface: "input" }); interface: "input",
await safeAddField("company", "string", { interface: "input" }); display: "raw",
await safeAddField("message", "text", { interface: "textarea" }); width: "half",
});
await safeAddField("email", "string", {
interface: "input",
display: "raw",
width: "half",
});
await safeAddField("company", "string", {
interface: "input",
display: "raw",
width: "half",
});
await safeAddField("message", "text", {
interface: "textarea",
display: "raw",
width: "full",
});
await safeAddField("date_created", "timestamp", { await safeAddField("date_created", "timestamp", {
interface: "datetime", interface: "datetime",
special: ["date-created"], special: ["date-created"],
display: "datetime",
display_options: { relative: true },
width: "half",
}); });
} }
setupBranding(); setupBranding()
.then(() => {
process.exit(0);
})
.catch((err) => {
console.error("🚨 Fatal bootstrap error:", err);
process.exit(1);
});

View File

@@ -1,15 +1,13 @@
#!/bin/bash #!/bin/bash
# Mintel Directus Sync Engine # Configuration
# Synchronizes Directus Data (Postgres + Uploads) between Local and Remote
REMOTE_HOST="${SSH_HOST:-root@alpha.mintel.me}" REMOTE_HOST="${SSH_HOST:-root@alpha.mintel.me}"
ACTION=$1 ACTION=$1
ENV=$2 ENV=$2
# Help # Help
if [ -z "$ACTION" ] || [ -z "$ENV" ]; then if [ -z "$ACTION" ] || [ -z "$ENV" ]; then
echo "Usage: mintel-sync [push|pull] [testing|staging|production]" echo "Usage: ./scripts/sync-directus.sh [push|pull] [testing|staging|production]"
echo "" echo ""
echo "Commands:" echo "Commands:"
echo " push Sync LOCAL data -> REMOTE" echo " push Sync LOCAL data -> REMOTE"
@@ -20,7 +18,10 @@ if [ -z "$ACTION" ] || [ -z "$ENV" ]; then
exit 1 exit 1
fi fi
PRJ_ID=$(jq -r .name package.json | sed 's/@mintel\///') # Project Configuration (extracted from package.json and aligned with deploy.yml)
PRJ_ID=$(jq -r .name package.json | sed 's/@mintel\///' | sed 's/\.com$//')
REMOTE_DIR="/home/deploy/sites/${PRJ_ID}.com"
case $ENV in case $ENV in
testing) PROJECT_NAME="${PRJ_ID}-testing"; ENV_FILE=".env.testing" ;; testing) PROJECT_NAME="${PRJ_ID}-testing"; ENV_FILE=".env.testing" ;;
staging) PROJECT_NAME="${PRJ_ID}-staging"; ENV_FILE=".env.staging" ;; staging) PROJECT_NAME="${PRJ_ID}-staging"; ENV_FILE=".env.staging" ;;
@@ -28,41 +29,90 @@ case $ENV in
*) echo "❌ Invalid environment: $ENV"; exit 1 ;; *) echo "❌ Invalid environment: $ENV"; exit 1 ;;
esac esac
REMOTE_DIR="/home/deploy/sites/${PRJ_ID}.com" # DB Details (matching docker-compose defaults)
# DB Details
DB_USER="directus" DB_USER="directus"
DB_NAME="directus" DB_NAME="directus"
echo "🔍 Detecting local database..." echo "🔍 Detecting local database..."
LOCAL_DB_CONTAINER=$(docker compose ps -q directus-db) LOCAL_DB_CONTAINER=$(docker compose ps -q directus-db)
if [ -z "$LOCAL_DB_CONTAINER" ]; then if [ -z "$LOCAL_DB_CONTAINER" ]; then
echo "❌ Local directus-db container not found. Running?" echo "❌ Local directus-db container not found. Is it running? (npm run dev)"
exit 1 exit 1
fi fi
if [ "$ACTION" == "push" ]; then if [ "$ACTION" == "push" ]; then
echo "🚀 Pushing LOCAL -> $ENV ($PROJECT_NAME)..." echo "🚀 Pushing LOCAL -> $ENV ($PROJECT_NAME)..."
# 1. DB Dump
echo "📦 Dumping local database..."
docker exec "$LOCAL_DB_CONTAINER" pg_dump -U "$DB_USER" --clean --if-exists --no-owner --no-privileges "$DB_NAME" > dump.sql docker exec "$LOCAL_DB_CONTAINER" pg_dump -U "$DB_USER" --clean --if-exists --no-owner --no-privileges "$DB_NAME" > dump.sql
# 2. Upload Dump
echo "📤 Uploading dump to remote server..."
scp dump.sql "$REMOTE_HOST:$REMOTE_DIR/dump.sql" scp dump.sql "$REMOTE_HOST:$REMOTE_DIR/dump.sql"
# 3. Restore on Remote
echo "🔄 Restoring dump on $ENV..."
REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus-db") REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus-db")
if [ -z "$REMOTE_DB_CONTAINER" ]; then
echo "❌ Remote $ENV-db container not found!"
exit 1
fi
echo "🧹 Wiping remote database schema..."
ssh "$REMOTE_HOST" "docker exec $REMOTE_DB_CONTAINER psql -U $DB_USER $DB_NAME -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'"
echo "⚡ Restoring database..."
ssh "$REMOTE_HOST" "docker exec -i $REMOTE_DB_CONTAINER psql -U $DB_USER $DB_NAME < $REMOTE_DIR/dump.sql" ssh "$REMOTE_HOST" "docker exec -i $REMOTE_DB_CONTAINER psql -U $DB_USER $DB_NAME < $REMOTE_DIR/dump.sql"
# 4. Sync Uploads
echo "📁 Syncing uploads (Local -> $ENV)..."
rsync -avz --progress ./directus/uploads/ "$REMOTE_HOST:$REMOTE_DIR/directus/uploads/" rsync -avz --progress ./directus/uploads/ "$REMOTE_HOST:$REMOTE_DIR/directus/uploads/"
# Clean up
rm dump.sql rm dump.sql
ssh "$REMOTE_HOST" "rm $REMOTE_DIR/dump.sql" ssh "$REMOTE_HOST" "rm $REMOTE_DIR/dump.sql"
echo "✨ Push complete!"
# 5. Restart Directus to trigger migrations and refresh schema cache
echo "🔄 Restarting remote Directus to apply migrations..."
ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME restart directus"
echo "✨ Push to $ENV complete!"
elif [ "$ACTION" == "pull" ]; then elif [ "$ACTION" == "pull" ]; then
echo "📥 Pulling $ENV -> LOCAL..." echo "📥 Pulling $ENV Data -> LOCAL..."
# 1. DB Dump on Remote
echo "📦 Dumping remote database ($ENV)..."
REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus-db") REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus-db")
if [ -z "$REMOTE_DB_CONTAINER" ]; then
echo "❌ Remote $ENV-db container not found!"
exit 1
fi
ssh "$REMOTE_HOST" "docker exec $REMOTE_DB_CONTAINER pg_dump -U $DB_USER --clean --if-exists --no-owner --no-privileges $DB_NAME > $REMOTE_DIR/dump.sql" ssh "$REMOTE_HOST" "docker exec $REMOTE_DB_CONTAINER pg_dump -U $DB_USER --clean --if-exists --no-owner --no-privileges $DB_NAME > $REMOTE_DIR/dump.sql"
# 2. Download Dump
echo "📥 Downloading dump..."
scp "$REMOTE_HOST:$REMOTE_DIR/dump.sql" dump.sql scp "$REMOTE_HOST:$REMOTE_DIR/dump.sql" dump.sql
# 3. Restore Locally
echo "🧹 Wiping local database schema..."
docker exec "$LOCAL_DB_CONTAINER" psql -U "$DB_USER" "$DB_NAME" -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'
echo "⚡ Restoring database locally..."
docker exec -i "$LOCAL_DB_CONTAINER" psql -U "$DB_USER" "$DB_NAME" < dump.sql docker exec -i "$LOCAL_DB_CONTAINER" psql -U "$DB_USER" "$DB_NAME" < dump.sql
# 4. Sync Uploads
echo "📁 Syncing uploads ($ENV -> Local)..."
rsync -avz --progress "$REMOTE_HOST:$REMOTE_DIR/directus/uploads/" ./directus/uploads/ rsync -avz --progress "$REMOTE_HOST:$REMOTE_DIR/directus/uploads/" ./directus/uploads/
# Clean up
rm dump.sql rm dump.sql
ssh "$REMOTE_HOST" "rm $REMOTE_DIR/dump.sql" ssh "$REMOTE_HOST" "rm $REMOTE_DIR/dump.sql"
echo "✨ Pull complete!"
echo "✨ Pull to Local complete!"
fi fi