Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 42295c3c41 | |||
| 1e00690dd8 | |||
| 90e9f37849 | |||
| 9eaaa798a3 | |||
| f7685fdb2f | |||
| 609422b5b9 | |||
| 76cf6e7b62 | |||
| cc04b71327 |
@@ -200,6 +200,7 @@ jobs:
|
|||||||
--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' }} \
|
--build-arg UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }} \
|
||||||
|
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID || vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }} \
|
||||||
-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 .
|
||||||
|
|
||||||
@@ -251,14 +252,13 @@ jobs:
|
|||||||
DIRECTUS_KEY=${{ secrets.DIRECTUS_KEY || vars.DIRECTUS_KEY }}
|
DIRECTUS_KEY=${{ secrets.DIRECTUS_KEY || vars.DIRECTUS_KEY }}
|
||||||
DIRECTUS_SECRET=${{ secrets.DIRECTUS_SECRET || vars.DIRECTUS_SECRET }}
|
DIRECTUS_SECRET=${{ secrets.DIRECTUS_SECRET || vars.DIRECTUS_SECRET }}
|
||||||
|
|
||||||
# SMTP Config
|
# Mail
|
||||||
SMTP_HOST=${{ secrets.SMTP_HOST || vars.SMTP_HOST }}
|
MAIL_HOST=${{ secrets.SMTP_HOST || vars.SMTP_HOST }}
|
||||||
SMTP_PORT=${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }}
|
MAIL_PORT=${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }}
|
||||||
SMTP_SECURE=${{ secrets.SMTP_SECURE || vars.SMTP_SECURE || 'false' }}
|
MAIL_USERNAME=${{ secrets.SMTP_USER || vars.SMTP_USER }}
|
||||||
SMTP_USER=${{ secrets.SMTP_USER || vars.SMTP_USER }}
|
MAIL_PASSWORD=${{ secrets.SMTP_PASS || vars.SMTP_PASS }}
|
||||||
SMTP_PASS=${{ secrets.SMTP_PASS || vars.SMTP_PASS }}
|
MAIL_FROM=${{ secrets.SMTP_FROM || vars.SMTP_FROM }}
|
||||||
SMTP_FROM=${{ secrets.SMTP_FROM || vars.SMTP_FROM }}
|
MAIL_RECIPIENTS=${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT }}
|
||||||
CONTACT_RECIPIENT=${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT }}
|
|
||||||
|
|
||||||
# Authentication
|
# Authentication
|
||||||
GATEKEEPER_PASSWORD=${{ secrets.GATEKEEPER_PASSWORD || vars.GATEKEEPER_PASSWORD }}
|
GATEKEEPER_PASSWORD=${{ secrets.GATEKEEPER_PASSWORD || vars.GATEKEEPER_PASSWORD }}
|
||||||
@@ -270,6 +270,7 @@ jobs:
|
|||||||
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 }}
|
||||||
UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || vars.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_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID || vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
|
||||||
UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||||
|
|
||||||
# Project
|
# Project
|
||||||
@@ -289,7 +290,7 @@ jobs:
|
|||||||
|
|
||||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
docker compose -p "${{ needs.prepare.outputs.project_name }}" --env-file ${{ needs.prepare.outputs.env_file }} pull
|
docker compose -p "${{ needs.prepare.outputs.project_name }}" --env-file ${{ needs.prepare.outputs.env_file }} pull
|
||||||
docker compose -p "${{ needs.prepare.outputs.project_name }}" --env-file ${{ needs.prepare.outputs.env_file }} up -d --remove-orphans
|
docker compose -p "${{ needs.prepare.outputs.project_name }}" --env-file ${{ needs.prepare.outputs.env_file }} up -d --wait --remove-orphans
|
||||||
docker system prune -f --filter "until=24h"
|
docker system prune -f --filter "until=24h"
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
|||||||
10
Dockerfile
10
Dockerfile
@@ -11,12 +11,14 @@ ARG NEXT_PUBLIC_BASE_URL
|
|||||||
ARG UMAMI_API_ENDPOINT
|
ARG UMAMI_API_ENDPOINT
|
||||||
ARG NEXT_PUBLIC_TARGET
|
ARG NEXT_PUBLIC_TARGET
|
||||||
ARG DIRECTUS_URL
|
ARG DIRECTUS_URL
|
||||||
|
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||||
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 UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
|
ENV UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
|
||||||
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 NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||||
ENV NPM_TOKEN=$NPM_TOKEN
|
ENV NPM_TOKEN=$NPM_TOKEN
|
||||||
ENV SENTRY_SUPPRESS_TURBOPACK_WARNING=1
|
ENV SENTRY_SUPPRESS_TURBOPACK_WARNING=1
|
||||||
ENV SKIP_RUNTIME_ENV_VALIDATION=true
|
ENV SKIP_RUNTIME_ENV_VALIDATION=true
|
||||||
@@ -38,13 +40,19 @@ RUN pnpm build
|
|||||||
|
|
||||||
# Production runner image
|
# Production runner image
|
||||||
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner
|
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner
|
||||||
WORKDIR /app
|
# Production environment configuration
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
# Copy standalone output and static files
|
# Copy standalone output and static files
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
# Ensure the cache directory specifically is writeable (Mintel Standard #16)
|
||||||
|
RUN mkdir -p .next/cache && chown -R nextjs:nodejs .next/cache
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { NextIntlClientProvider } from "next-intl";
|
|||||||
import { getMessages } from "next-intl/server";
|
import { getMessages } from "next-intl/server";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { LazyMotion, domAnimation } from "framer-motion";
|
import { LazyMotion, domAnimation } from "framer-motion";
|
||||||
|
import AnalyticsProvider from "@/components/analytics/AnalyticsProvider";
|
||||||
|
import { config } from "@/lib/config";
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
@@ -108,10 +110,26 @@ export default async function RootLayout({
|
|||||||
|
|
||||||
// Track pageview on the server
|
// Track pageview on the server
|
||||||
// This is safe to call here because layout is a Server Component
|
// This is safe to call here because layout is a Server Component
|
||||||
const services = (
|
const serverServices = (
|
||||||
await import("@/lib/services/create-services.server")
|
await import("@/lib/services/create-services.server")
|
||||||
).getServerAppServices();
|
).getServerAppServices();
|
||||||
services.analytics.trackPageview();
|
|
||||||
|
// Populate analytics context with headers for high-fidelity server-side tracking
|
||||||
|
const { headers } = await import("next/headers");
|
||||||
|
const requestHeaders = await headers();
|
||||||
|
|
||||||
|
if ("setServerContext" in serverServices.analytics) {
|
||||||
|
(serverServices.analytics as any).setServerContext({
|
||||||
|
userAgent: requestHeaders.get("user-agent") || undefined,
|
||||||
|
language:
|
||||||
|
requestHeaders.get("accept-language")?.split(",")[0] || undefined,
|
||||||
|
referrer: requestHeaders.get("referer") || undefined,
|
||||||
|
ip: requestHeaders.get("x-forwarded-for")?.split(",")[0] || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track server-side (initial load)
|
||||||
|
serverServices.analytics.trackPageview("/");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale} className={`${inter.variable}`}>
|
<html lang={locale} className={`${inter.variable}`}>
|
||||||
@@ -123,6 +141,7 @@ export default async function RootLayout({
|
|||||||
</head>
|
</head>
|
||||||
<body className="antialiased">
|
<body className="antialiased">
|
||||||
<NextIntlClientProvider messages={messages}>
|
<NextIntlClientProvider messages={messages}>
|
||||||
|
<AnalyticsProvider websiteId={config.analytics.umami.websiteId} />
|
||||||
<LazyMotion features={domAnimation}>
|
<LazyMotion features={domAnimation}>
|
||||||
<Layout>{children}</Layout>
|
<Layout>{children}</Layout>
|
||||||
</LazyMotion>
|
</LazyMotion>
|
||||||
|
|||||||
@@ -8,9 +8,23 @@ export async function POST(req: Request) {
|
|||||||
const services = getServerAppServices();
|
const services = getServerAppServices();
|
||||||
const logger = services.logger.child({ action: "contact_submission" });
|
const logger = services.logger.child({ action: "contact_submission" });
|
||||||
|
|
||||||
|
// Set analytics context from request headers for high-fidelity server-side tracking
|
||||||
|
// This fulfills the "server-side via nextjs proxy" requirement
|
||||||
|
if ("setServerContext" in services.analytics) {
|
||||||
|
(services.analytics as any).setServerContext({
|
||||||
|
userAgent: req.headers.get("user-agent") || undefined,
|
||||||
|
language: req.headers.get("accept-language")?.split(",")[0] || undefined,
|
||||||
|
referrer: req.headers.get("referer") || undefined,
|
||||||
|
ip: req.headers.get("x-forwarded-for")?.split(",")[0] || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { name, email, company, message, website } = await req.json();
|
const { name, email, company, message, website } = await req.json();
|
||||||
|
|
||||||
|
// Track attempt
|
||||||
|
services.analytics.track("contact-form-attempt");
|
||||||
|
|
||||||
// Honeypot check
|
// Honeypot check
|
||||||
if (website) {
|
if (website) {
|
||||||
logger.info("Spam detected (honeypot)");
|
logger.info("Spam detected (honeypot)");
|
||||||
@@ -47,7 +61,14 @@ export async function POST(req: Request) {
|
|||||||
logger.info("Contact submission saved to Directus");
|
logger.info("Contact submission saved to Directus");
|
||||||
directusSaved = true;
|
directusSaved = true;
|
||||||
} catch (directusError) {
|
} catch (directusError) {
|
||||||
logger.error("Failed to save to Directus", { error: directusError });
|
const errorMessage =
|
||||||
|
directusError instanceof Error
|
||||||
|
? directusError.message
|
||||||
|
: String(directusError);
|
||||||
|
logger.error("Failed to save to Directus", {
|
||||||
|
error: errorMessage,
|
||||||
|
details: directusError,
|
||||||
|
});
|
||||||
services.errors.captureException(directusError, {
|
services.errors.captureException(directusError, {
|
||||||
phase: "directus_save",
|
phase: "directus_save",
|
||||||
});
|
});
|
||||||
@@ -56,19 +77,20 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
// 2. Email sending
|
// 2. Email sending
|
||||||
try {
|
try {
|
||||||
|
const { config } = await import("@/lib/config");
|
||||||
const transporter = nodemailer.createTransport({
|
const transporter = nodemailer.createTransport({
|
||||||
host: process.env.SMTP_HOST,
|
host: config.mail.host,
|
||||||
port: parseInt(process.env.SMTP_PORT || "587"),
|
port: config.mail.port,
|
||||||
secure: process.env.SMTP_SECURE === "true",
|
secure: config.mail.port === 465,
|
||||||
auth: {
|
auth: {
|
||||||
user: process.env.SMTP_USER,
|
user: config.mail.user,
|
||||||
pass: process.env.SMTP_PASS,
|
pass: config.mail.pass,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await transporter.sendMail({
|
await transporter.sendMail({
|
||||||
from: process.env.SMTP_FROM,
|
from: config.mail.from,
|
||||||
to: process.env.CONTACT_RECIPIENT || "info@mb-grid-solutions.com",
|
to: config.mail.recipients.join(",") || "info@mb-grid-solutions.com",
|
||||||
replyTo: email,
|
replyTo: email,
|
||||||
subject: `Kontaktanfrage von ${name}`,
|
subject: `Kontaktanfrage von ${name}`,
|
||||||
text: `
|
text: `
|
||||||
@@ -110,6 +132,11 @@ ${message}
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track success
|
||||||
|
services.analytics.track("contact-form-success", {
|
||||||
|
has_company: Boolean(company),
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json({ message: "Ok" });
|
return NextResponse.json({ message: "Ok" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Global API Error", { error });
|
logger.error("Global API Error", { error });
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ services:
|
|||||||
- "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"
|
- "traefik.docker.network=infra"
|
||||||
|
healthcheck:
|
||||||
|
test: [ "CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))" ]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
gatekeeper:
|
gatekeeper:
|
||||||
image: registry.infra.mintel.me/mintel/gatekeeper:latest
|
image: registry.infra.mintel.me/mintel/gatekeeper:latest
|
||||||
|
|||||||
@@ -27,9 +27,11 @@ function createConfig() {
|
|||||||
|
|
||||||
analytics: {
|
analytics: {
|
||||||
umami: {
|
umami: {
|
||||||
websiteId: env.UMAMI_WEBSITE_ID,
|
websiteId: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || env.UMAMI_WEBSITE_ID,
|
||||||
apiEndpoint: env.UMAMI_API_ENDPOINT,
|
apiEndpoint: env.UMAMI_API_ENDPOINT,
|
||||||
enabled: Boolean(env.UMAMI_WEBSITE_ID),
|
enabled: Boolean(
|
||||||
|
env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || env.UMAMI_WEBSITE_ID,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -23,15 +23,21 @@ export async function ensureAuthenticated() {
|
|||||||
if (adminEmail && password) {
|
if (adminEmail && password) {
|
||||||
try {
|
try {
|
||||||
await client.login({ email: adminEmail, password: password });
|
await client.login({ email: adminEmail, password: password });
|
||||||
|
return;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
getServerAppServices().errors.captureException(e, {
|
getServerAppServices().errors.captureException(e, {
|
||||||
phase: "directus_auth",
|
phase: "directus_auth_fallback",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
console.error("Failed to authenticate with Directus login fallback:", e);
|
console.error("Failed to authenticate with Directus login fallback:", e);
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
"Missing Directus authentication credentials (token or admin email/password)",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default client;
|
export default client;
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ export const envSchema = z
|
|||||||
preprocessEmptyString,
|
preprocessEmptyString,
|
||||||
z.string().optional(),
|
z.string().optional(),
|
||||||
),
|
),
|
||||||
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.preprocess(
|
||||||
|
preprocessEmptyString,
|
||||||
|
z.string().optional(),
|
||||||
|
),
|
||||||
UMAMI_API_ENDPOINT: z.preprocess(
|
UMAMI_API_ENDPOINT: z.preprocess(
|
||||||
preprocessEmptyString,
|
preprocessEmptyString,
|
||||||
z.string().url().default("https://analytics.infra.mintel.me"),
|
z.string().url().default("https://analytics.infra.mintel.me"),
|
||||||
@@ -115,6 +119,7 @@ export function getRawEnv() {
|
|||||||
NEXT_PUBLIC_TARGET: process.env.NEXT_PUBLIC_TARGET,
|
NEXT_PUBLIC_TARGET: process.env.NEXT_PUBLIC_TARGET,
|
||||||
UMAMI_WEBSITE_ID:
|
UMAMI_WEBSITE_ID:
|
||||||
process.env.UMAMI_WEBSITE_ID || process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
process.env.UMAMI_WEBSITE_ID || process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
||||||
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
||||||
UMAMI_API_ENDPOINT:
|
UMAMI_API_ENDPOINT:
|
||||||
process.env.UMAMI_API_ENDPOINT ||
|
process.env.UMAMI_API_ENDPOINT ||
|
||||||
process.env.UMAMI_SCRIPT_URL ||
|
process.env.UMAMI_SCRIPT_URL ||
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ export type UmamiAnalyticsServiceOptions = {
|
|||||||
export class UmamiAnalyticsService implements AnalyticsService {
|
export class UmamiAnalyticsService implements AnalyticsService {
|
||||||
private websiteId?: string;
|
private websiteId?: string;
|
||||||
private endpoint: string;
|
private endpoint: string;
|
||||||
|
private serverContext?: {
|
||||||
|
userAgent?: string;
|
||||||
|
language?: string;
|
||||||
|
referrer?: string;
|
||||||
|
ip?: string;
|
||||||
|
};
|
||||||
|
|
||||||
constructor(private readonly options: UmamiAnalyticsServiceOptions) {
|
constructor(private readonly options: UmamiAnalyticsServiceOptions) {
|
||||||
this.websiteId = config.analytics.umami.websiteId;
|
this.websiteId = config.analytics.umami.websiteId;
|
||||||
@@ -36,6 +42,19 @@ export class UmamiAnalyticsService implements AnalyticsService {
|
|||||||
: "/stats";
|
: "/stats";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the server-side context for the current request.
|
||||||
|
* This allows the service to use real request headers for tracking.
|
||||||
|
*/
|
||||||
|
setServerContext(context: {
|
||||||
|
userAgent?: string;
|
||||||
|
language?: string;
|
||||||
|
referrer?: string;
|
||||||
|
ip?: string;
|
||||||
|
}) {
|
||||||
|
this.serverContext = context;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal method to send the payload to Umami API.
|
* Internal method to send the payload to Umami API.
|
||||||
*/
|
*/
|
||||||
@@ -53,18 +72,37 @@ export class UmamiAnalyticsService implements AnalyticsService {
|
|||||||
? `${window.screen.width}x${window.screen.height}`
|
? `${window.screen.width}x${window.screen.height}`
|
||||||
: undefined,
|
: undefined,
|
||||||
language:
|
language:
|
||||||
typeof window !== "undefined" ? navigator.language : undefined,
|
typeof window !== "undefined"
|
||||||
referrer: typeof window !== "undefined" ? document.referrer : undefined,
|
? navigator.language
|
||||||
|
: this.serverContext?.language,
|
||||||
|
referrer:
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? document.referrer
|
||||||
|
: this.serverContext?.referrer,
|
||||||
...data,
|
...data,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set User-Agent
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
headers["User-Agent"] = navigator.userAgent;
|
||||||
|
} else if (this.serverContext?.userAgent) {
|
||||||
|
headers["User-Agent"] = this.serverContext.userAgent;
|
||||||
|
} else {
|
||||||
|
headers["User-Agent"] = "Mintel-Server-Proxy";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward client IP if available (Umami must be configured to trust this)
|
||||||
|
if (this.serverContext?.ip) {
|
||||||
|
headers["X-Forwarded-For"] = this.serverContext.ip;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(`${this.endpoint}/api/send`, {
|
const response = await fetch(`${this.endpoint}/api/send`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers,
|
||||||
"Content-Type": "application/json",
|
|
||||||
"User-Agent":
|
|
||||||
typeof window === "undefined" ? "KLZ-Server" : navigator.userAgent,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ type, payload }),
|
body: JSON.stringify({ type, payload }),
|
||||||
keepalive: true,
|
keepalive: true,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
|||||||
@@ -14,5 +14,9 @@ export default createMiddleware({
|
|||||||
export const config = {
|
export const config = {
|
||||||
// Matcher for all pages and internationalized pathnames
|
// Matcher for all pages and internationalized pathnames
|
||||||
// excluding api, _next, static files, etc.
|
// excluding api, _next, static files, etc.
|
||||||
matcher: ["/((?!api|_next|_vercel|.*\\..*).*)", "/", "/(de)/:path*"],
|
matcher: [
|
||||||
|
"/((?!api|stats|errors|_next|_vercel|.*\\..*).*)",
|
||||||
|
"/",
|
||||||
|
"/(de)/:path*",
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,10 +17,18 @@ const nextConfig = {
|
|||||||
source: "/stats/:path*",
|
source: "/stats/:path*",
|
||||||
destination: `${umamiUrl}/:path*`,
|
destination: `${umamiUrl}/:path*`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
source: "/:locale(de)/stats/:path*",
|
||||||
|
destination: `${umamiUrl}/:path*`,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
source: "/errors/:path*",
|
source: "/errors/:path*",
|
||||||
destination: `${glitchtipUrl}/:path*`,
|
destination: `${glitchtipUrl}/:path*`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
source: "/:locale(de)/errors/:path*",
|
||||||
|
destination: `${glitchtipUrl}/:path*`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ 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" ;;
|
||||||
production) PROJECT_NAME="${PRJ_ID}-prod"; ENV_FILE=".env.prod" ;;
|
production) PROJECT_NAME="${PRJ_ID}-production"; ENV_FILE=".env.prod" ;;
|
||||||
*) echo "❌ Invalid environment: $ENV"; exit 1 ;;
|
*) echo "❌ Invalid environment: $ENV"; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
@@ -35,8 +35,21 @@ 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. Is it running? (npm run dev)"
|
# Check if it exists but is stopped
|
||||||
|
LOCAL_DB_EXISTS=$(docker compose ps -a -q directus-db)
|
||||||
|
if [ -n "$LOCAL_DB_EXISTS" ]; then
|
||||||
|
echo "⏳ Local directus-db is stopped. Starting it..."
|
||||||
|
docker compose up -d directus-db
|
||||||
|
# Wait a few seconds for PG to be ready
|
||||||
|
sleep 2
|
||||||
|
LOCAL_DB_CONTAINER=$(docker compose ps -q directus-db)
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$LOCAL_DB_CONTAINER" ]; then
|
||||||
|
echo "❌ Local directus-db container not found. Is it defined in docker-compose.yaml?"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user