feat: Integrate Directus CMS, add i18n with next-intl, and configure project tooling with pnpm, husky, and commitlint.**

This commit is contained in:
2026-02-05 01:18:06 +01:00
parent 765cfd4c69
commit e80140f7cf
65 changed files with 12793 additions and 5879 deletions

193
lib/config.ts Normal file
View File

@@ -0,0 +1,193 @@
/**
* Centralized configuration management for the application.
* This file provides a type-safe way to access environment variables.
*/
import { envSchema, getRawEnv } from "./env";
let memoizedConfig: ReturnType<typeof createConfig> | undefined;
/**
* Creates and validates the configuration object.
* Throws if validation fails.
*/
function createConfig() {
const env = envSchema.parse(getRawEnv());
const target = env.NEXT_PUBLIC_TARGET || env.TARGET;
return {
env: env.NODE_ENV,
target,
isProduction: target === "production" || !target,
isStaging: target === "staging",
isTesting: target === "testing",
isDevelopment: target === "development",
baseUrl: env.NEXT_PUBLIC_BASE_URL,
analytics: {
umami: {
websiteId: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
scriptUrl: env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
// The proxied path used in the frontend
proxyPath: "/stats/script.js",
enabled: Boolean(env.NEXT_PUBLIC_UMAMI_WEBSITE_ID),
},
},
errors: {
glitchtip: {
// Use SENTRY_DSN for both server and client (proxied)
dsn: env.SENTRY_DSN,
// The proxied origin used in the frontend
proxyPath: "/errors",
enabled: Boolean(env.SENTRY_DSN),
},
},
cache: {
enabled: false,
},
logging: {
level: env.LOG_LEVEL,
},
mail: {
host: env.MAIL_HOST,
port: env.MAIL_PORT,
user: env.MAIL_USERNAME,
pass: env.MAIL_PASSWORD,
from: env.MAIL_FROM,
recipients: env.MAIL_RECIPIENTS,
},
directus: {
url: env.DIRECTUS_URL,
adminEmail: env.DIRECTUS_ADMIN_EMAIL,
password: env.DIRECTUS_ADMIN_PASSWORD,
token: env.DIRECTUS_API_TOKEN,
internalUrl: env.INTERNAL_DIRECTUS_URL,
proxyPath: "/cms",
},
notifications: {
gotify: {
url: env.GOTIFY_URL,
token: env.GOTIFY_TOKEN,
enabled: Boolean(env.GOTIFY_URL && env.GOTIFY_TOKEN),
},
},
} as const;
}
/**
* Returns the validated configuration.
* Memoizes the result after the first call.
*/
export function getConfig() {
if (!memoizedConfig) {
memoizedConfig = createConfig();
}
return memoizedConfig;
}
/**
* Exported config object for convenience.
* Uses getters to ensure it's only initialized when accessed.
*/
export const config = {
get env() {
return getConfig().env;
},
get target() {
return getConfig().target;
},
get isProduction() {
return getConfig().isProduction;
},
get isStaging() {
return getConfig().isStaging;
},
get isTesting() {
return getConfig().isTesting;
},
get isDevelopment() {
return getConfig().isDevelopment;
},
get baseUrl() {
return getConfig().baseUrl;
},
get analytics() {
return getConfig().analytics;
},
get errors() {
return getConfig().errors;
},
get cache() {
return getConfig().cache;
},
get logging() {
return getConfig().logging;
},
get mail() {
return getConfig().mail;
},
get directus() {
return getConfig().directus;
},
get notifications() {
return getConfig().notifications;
},
};
/**
* Helper to get a masked version of the config for logging.
*/
export function getMaskedConfig() {
const c = getConfig();
const mask = (val: string | undefined) =>
val ? `***${val.slice(-4)}` : "not set";
return {
env: c.env,
baseUrl: c.baseUrl,
analytics: {
umami: {
websiteId: mask(c.analytics.umami.websiteId),
scriptUrl: c.analytics.umami.scriptUrl,
enabled: c.analytics.umami.enabled,
},
},
errors: {
glitchtip: {
dsn: mask(c.errors.glitchtip.dsn),
enabled: c.errors.glitchtip.enabled,
},
},
cache: {
enabled: c.cache.enabled,
},
logging: {
level: c.logging.level,
},
mail: {
host: c.mail.host,
port: c.mail.port,
user: mask(c.mail.user),
from: c.mail.from,
recipients: c.mail.recipients,
},
directus: {
url: c.directus.url,
adminEmail: mask(c.directus.adminEmail),
password: mask(c.directus.password),
token: mask(c.directus.token),
},
notifications: {
gotify: {
url: c.notifications.gotify.url,
token: mask(c.notifications.gotify.token),
enabled: c.notifications.gotify.enabled,
},
},
};
}

37
lib/directus.ts Normal file
View File

@@ -0,0 +1,37 @@
import { createDirectus, rest, authentication } from "@directus/sdk";
import { config } from "./config";
import { getServerAppServices } from "./services/create-services.server";
const { url, adminEmail, password, token, internalUrl } = config.directus;
// Use internal URL if on server to bypass Gatekeeper/Auth/Proxy issues
const effectiveUrl =
typeof window === "undefined" && internalUrl ? internalUrl : url;
const client = createDirectus(effectiveUrl).with(rest()).with(authentication());
/**
* Ensures the client is authenticated.
* Falls back to login with admin credentials if no static token is provided.
*/
export async function ensureAuthenticated() {
if (token) {
client.setToken(token);
return;
}
if (adminEmail && password) {
try {
await client.login({ email: adminEmail, password: password });
} catch (e) {
if (typeof window === "undefined") {
getServerAppServices().errors.captureException(e, {
phase: "directus_auth",
});
}
console.error("Failed to authenticate with Directus login fallback:", e);
}
}
}
export default client;

114
lib/env.ts Normal file
View File

@@ -0,0 +1,114 @@
import { z } from "zod";
/**
* Helper to treat empty strings as undefined.
*/
const preprocessEmptyString = (val: unknown) => (val === "" ? undefined : val);
/**
* Environment variable schema.
*/
export const envSchema = z.object({
NODE_ENV: z
.enum(["development", "production", "test"])
.default("development"),
NEXT_PUBLIC_BASE_URL: z.preprocess(
preprocessEmptyString,
z.string().url().optional(),
),
NEXT_PUBLIC_TARGET: z
.enum(["development", "testing", "staging", "production"])
.optional(),
// Analytics
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.preprocess(
preprocessEmptyString,
z.string().optional(),
),
NEXT_PUBLIC_UMAMI_SCRIPT_URL: z.preprocess(
preprocessEmptyString,
z.string().url().default("https://analytics.infra.mintel.me/script.js"),
),
// Error Tracking
SENTRY_DSN: z.preprocess(preprocessEmptyString, z.string().optional()),
// Logging
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
// Mail
MAIL_HOST: z.preprocess(preprocessEmptyString, z.string().optional()),
MAIL_PORT: z.preprocess(
preprocessEmptyString,
z.coerce.number().default(587),
),
MAIL_USERNAME: z.preprocess(preprocessEmptyString, z.string().optional()),
MAIL_PASSWORD: z.preprocess(preprocessEmptyString, z.string().optional()),
MAIL_FROM: z.preprocess(preprocessEmptyString, z.string().optional()),
MAIL_RECIPIENTS: z.preprocess(
(val) => (typeof val === "string" ? val.split(",").filter(Boolean) : val),
z.array(z.string()).default([]),
),
// Directus
DIRECTUS_URL: z.preprocess(
preprocessEmptyString,
z.string().url().default("http://localhost:8055"),
),
DIRECTUS_ADMIN_EMAIL: z.preprocess(
preprocessEmptyString,
z.string().optional(),
),
DIRECTUS_ADMIN_PASSWORD: z.preprocess(
preprocessEmptyString,
z.string().optional(),
),
DIRECTUS_API_TOKEN: z.preprocess(
preprocessEmptyString,
z.string().optional(),
),
INTERNAL_DIRECTUS_URL: z.preprocess(
preprocessEmptyString,
z.string().url().optional(),
),
// Deploy Target
TARGET: z
.enum(["development", "testing", "staging", "production"])
.optional(),
// Gotify
GOTIFY_URL: z.preprocess(preprocessEmptyString, z.string().url().optional()),
GOTIFY_TOKEN: z.preprocess(preprocessEmptyString, z.string().optional()),
});
export type Env = z.infer<typeof envSchema>;
/**
* Collects all environment variables from the process.
* Explicitly references NEXT_PUBLIC_ variables for Next.js inlining.
*/
export function getRawEnv() {
return {
NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
NEXT_PUBLIC_TARGET: process.env.NEXT_PUBLIC_TARGET,
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
SENTRY_DSN: process.env.SENTRY_DSN,
LOG_LEVEL: process.env.LOG_LEVEL,
MAIL_HOST: process.env.MAIL_HOST,
MAIL_PORT: process.env.MAIL_PORT,
MAIL_USERNAME: process.env.MAIL_USERNAME,
MAIL_PASSWORD: process.env.MAIL_PASSWORD,
MAIL_FROM: process.env.MAIL_FROM,
MAIL_RECIPIENTS: process.env.MAIL_RECIPIENTS,
DIRECTUS_URL: process.env.DIRECTUS_URL,
DIRECTUS_ADMIN_EMAIL: process.env.DIRECTUS_ADMIN_EMAIL,
DIRECTUS_ADMIN_PASSWORD: process.env.DIRECTUS_ADMIN_PASSWORD,
DIRECTUS_API_TOKEN: process.env.DIRECTUS_API_TOKEN,
INTERNAL_DIRECTUS_URL: process.env.INTERNAL_DIRECTUS_URL,
TARGET: process.env.TARGET,
GOTIFY_URL: process.env.GOTIFY_URL,
GOTIFY_TOKEN: process.env.GOTIFY_TOKEN,
};
}

View File

@@ -0,0 +1,3 @@
export interface AnalyticsService {
trackEvent(name: string, properties?: Record<string, unknown>): void;
}

View File

@@ -0,0 +1,5 @@
import type { AnalyticsService } from "./analytics-service";
export class NoopAnalyticsService implements AnalyticsService {
trackEvent() {}
}

View File

@@ -0,0 +1,15 @@
import type { AnalyticsService } from "./analytics/analytics-service";
import type { CacheService } from "./cache/cache-service";
import type { ErrorReportingService } from "./errors/error-reporting-service";
import type { LoggerService } from "./logging/logger-service";
import type { NotificationService } from "./notifications/notification-service";
export class AppServices {
constructor(
public readonly analytics: AnalyticsService,
public readonly errors: ErrorReportingService,
public readonly cache: CacheService,
public readonly logger: LoggerService,
public readonly notifications: NotificationService,
) {}
}

5
lib/services/cache/cache-service.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
export interface CacheService {
get<T>(key: string): Promise<T | null>;
set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
delete(key: string): Promise<void>;
}

View File

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

View File

@@ -0,0 +1,44 @@
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 { GotifyNotificationService } from "./notifications/gotify-notification-service";
import { NoopNotificationService } from "./notifications/noop-notification-service";
import { PinoLoggerService } from "./logging/pino-logger-service";
import { config, getMaskedConfig } from "../config";
let singleton: AppServices | undefined;
export function getServerAppServices(): AppServices {
if (singleton) return singleton;
const logger = new PinoLoggerService("server");
logger.info("Initializing server application services", {
environment: getMaskedConfig(),
timestamp: new Date().toISOString(),
});
const analytics = new NoopAnalyticsService();
const notifications = config.notifications.gotify.enabled
? new GotifyNotificationService({
url: config.notifications.gotify.url!,
token: config.notifications.gotify.token!,
enabled: true,
})
: new NoopNotificationService();
const errors = config.errors.glitchtip.enabled
? new GlitchtipErrorReportingService({ enabled: true }, notifications)
: new NoopErrorReportingService();
const cache = new MemoryCacheService();
singleton = new AppServices(analytics, errors, cache, logger, notifications);
logger.info("All application services initialized successfully");
return singleton;
}

View File

@@ -0,0 +1,4 @@
export interface ErrorReportingService {
captureException(error: unknown, context?: Record<string, unknown>): void;
captureMessage(message: string, context?: Record<string, unknown>): void;
}

View File

@@ -0,0 +1,48 @@
import * as Sentry from "@sentry/nextjs";
import type { ErrorReportingService } from "./error-reporting-service";
import type { NotificationService } from "../notifications/notification-service";
export interface GlitchtipConfig {
enabled: boolean;
}
export class GlitchtipErrorReportingService implements ErrorReportingService {
constructor(
private readonly config: GlitchtipConfig,
private readonly notifications?: NotificationService,
) {}
captureException(error: unknown, context?: Record<string, unknown>) {
if (!this.config.enabled) return;
Sentry.withScope((scope) => {
if (context) {
scope.setExtras(context);
}
Sentry.captureException(error);
});
if (this.notifications) {
this.notifications
.notify({
title: "🚨 Exception Captured",
message: error instanceof Error ? error.message : String(error),
priority: 10,
})
.catch((err) =>
console.error("Failed to send notification for exception", err),
);
}
}
captureMessage(message: string, context?: Record<string, unknown>) {
if (!this.config.enabled) return;
Sentry.withScope((scope) => {
if (context) {
scope.setExtras(context);
}
Sentry.captureMessage(message);
});
}
}

View File

@@ -0,0 +1,6 @@
import type { ErrorReportingService } from "./error-reporting-service";
export class NoopErrorReportingService implements ErrorReportingService {
captureException() {}
captureMessage() {}
}

View File

@@ -0,0 +1,7 @@
export interface LoggerService {
debug(message: string, context?: Record<string, unknown>): void;
info(message: string, context?: Record<string, unknown>): void;
warn(message: string, context?: Record<string, unknown>): void;
error(message: string, context?: Record<string, unknown>): void;
child(context: Record<string, unknown>): LoggerService;
}

View File

@@ -0,0 +1,56 @@
import { pino, type Logger as PinoLogger } from "pino";
import type { LoggerService } from "./logger-service";
import { config } from "../../config";
export class PinoLoggerService implements LoggerService {
private logger: PinoLogger;
constructor(name?: string, parent?: PinoLogger) {
if (parent) {
this.logger = parent.child({ name });
} else {
const useTransport =
config.isDevelopment && typeof window === "undefined";
this.logger = pino({
name: name || "app",
level: config.logging.level,
transport: useTransport
? {
target: "pino-pretty",
options: {
colorize: true,
},
}
: undefined,
});
}
}
debug(message: string, context?: Record<string, unknown>) {
if (context) this.logger.debug(context, message);
else this.logger.debug(message);
}
info(message: string, context?: Record<string, unknown>) {
if (context) this.logger.info(context, message);
else this.logger.info(message);
}
warn(message: string, context?: Record<string, unknown>) {
if (context) this.logger.warn(context, message);
else this.logger.warn(message);
}
error(message: string, context?: Record<string, unknown>) {
if (context) this.logger.error(context, message);
else this.logger.error(message);
}
child(context: Record<string, unknown>): LoggerService {
const childPino = this.logger.child(context);
const service = new PinoLoggerService();
service.logger = childPino;
return service;
}
}

View File

@@ -0,0 +1,44 @@
import type {
NotificationMessage,
NotificationService,
} from "./notification-service";
export interface GotifyConfig {
url: string;
token: string;
enabled: boolean;
}
export class GotifyNotificationService implements NotificationService {
constructor(private readonly config: GotifyConfig) {}
async notify(message: NotificationMessage): Promise<void> {
if (!this.config.enabled) return;
try {
const response = await fetch(
`${this.config.url}/message?token=${this.config.token}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title: message.title,
message: message.message,
priority: message.priority ?? 5,
}),
},
);
if (!response.ok) {
console.error(
"Failed to send Gotify notification",
await response.text(),
);
}
} catch (error) {
console.error("Error sending Gotify notification", error);
}
}
}

View File

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

View File

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