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

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>;
}