chore: overhaul infrastructure and integrate @mintel packages
Some checks failed
🧪 CI (QA) / 🧪 Quality Assurance (push) Failing after 1m3s

- Restructure to pnpm monorepo (site moved to apps/web)
- Integrate @mintel/tsconfig, @mintel/eslint-config, @mintel/husky-config
- Implement Docker service architecture (Varnish, Directus, Gatekeeper)
- Setup environment-aware Gitea Actions deployment
This commit is contained in:
2026-02-05 14:18:51 +01:00
parent 190720ad92
commit 103d71851c
1029 changed files with 13242 additions and 27898 deletions

View File

@@ -0,0 +1,96 @@
import type { CacheAdapter, CacheConfig } from './interfaces';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { existsSync, mkdirSync } from 'node:fs';
import * as crypto from 'node:crypto';
export class FileCacheAdapter implements CacheAdapter {
private cacheDir: string;
private prefix: string;
private defaultTTL: number;
constructor(config?: CacheConfig & { cacheDir?: string }) {
this.cacheDir = config?.cacheDir || path.resolve(process.cwd(), '.cache');
this.prefix = config?.prefix || '';
this.defaultTTL = config?.defaultTTL || 3600;
if (!existsSync(this.cacheDir)) {
fs.mkdir(this.cacheDir, { recursive: true }).catch(err => {
console.error(`Failed to create cache directory: ${this.cacheDir}`, err);
});
}
}
private sanitize(key: string): string {
const clean = key.replace(/[^a-z0-9]/gi, '_');
if (clean.length > 64) {
return crypto.createHash('md5').update(key).digest('hex');
}
return clean;
}
private getFilePath(key: string): string {
const safeKey = this.sanitize(`${this.prefix}${key}`).toLowerCase();
return path.join(this.cacheDir, `${safeKey}.json`);
}
async get<T>(key: string): Promise<T | null> {
const filePath = this.getFilePath(key);
try {
if (!existsSync(filePath)) return null;
const content = await fs.readFile(filePath, 'utf8');
const data = JSON.parse(content);
if (data.expiry && Date.now() > data.expiry) {
await this.del(key);
return null;
}
return data.value;
} catch (error) {
return null;
}
}
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
const filePath = this.getFilePath(key);
const effectiveTTL = ttl !== undefined ? ttl : this.defaultTTL;
const data = {
value,
expiry: effectiveTTL > 0 ? Date.now() + effectiveTTL * 1000 : null,
updatedAt: new Date().toISOString()
};
try {
await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf8');
} catch (error) {
console.error(`Failed to write cache file: ${filePath}`, error);
}
}
async del(key: string): Promise<void> {
const filePath = this.getFilePath(key);
try {
if (existsSync(filePath)) {
await fs.unlink(filePath);
}
} catch (error) {
// Ignore
}
}
async clear(): Promise<void> {
try {
if (existsSync(this.cacheDir)) {
const files = await fs.readdir(this.cacheDir);
for (const file of files) {
if (file.endsWith('.json')) {
await fs.unlink(path.join(this.cacheDir, file));
}
}
}
} catch (error) {
// Ignore
}
}
}

101
apps/web/src/utils/cache/index.ts vendored Normal file
View File

@@ -0,0 +1,101 @@
/**
* Cache Service - Main entry point with DI
* Simple constructor-based dependency injection
*/
import type { CacheAdapter, CacheConfig } from './interfaces';
import { MemoryCacheAdapter } from './memory-adapter';
import { RedisCacheAdapter } from './redis-adapter';
export class CacheService {
private adapter: CacheAdapter;
/**
* Create cache service with dependency injection
* @param adapter - Cache adapter implementation
*/
constructor(adapter: CacheAdapter) {
this.adapter = adapter;
}
async get<T>(key: string): Promise<T | null> {
return this.adapter.get<T>(key);
}
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
return this.adapter.set(key, value, ttl);
}
async del(key: string): Promise<void> {
return this.adapter.del(key);
}
async clear(): Promise<void> {
return this.adapter.clear();
}
/**
* Cache wrapper for functions
* Returns cached result or executes function and caches result
*/
async wrap<T>(key: string, fn: () => Promise<T>, ttl?: number): Promise<T> {
const cached = await this.get<T>(key);
if (cached !== null) return cached;
const result = await fn();
await this.set(key, result, ttl);
return result;
}
}
// Factory functions for easy instantiation
export function createMemoryCache(config?: CacheConfig): CacheService {
return new CacheService(new MemoryCacheAdapter(config));
}
export function createRedisCache(config?: CacheConfig & { redisUrl?: string }): CacheService {
return new CacheService(new RedisCacheAdapter(config));
}
// Default singleton instance - uses Redis if available, otherwise Memory
let defaultCache: CacheService | null = null;
export async function getDefaultCache(): Promise<CacheService> {
if (!defaultCache) {
// Try Redis first
const redisAdapter = new RedisCacheAdapter({
prefix: 'mintel:blog:',
defaultTTL: 3600
});
const redisAvailable = await redisAdapter.get('__test__')
.then(() => true)
.catch(() => false);
if (redisAvailable) {
console.log('✅ Using Redis cache');
defaultCache = new CacheService(redisAdapter);
} else {
console.log('⚠️ Redis unavailable, using Memory cache');
defaultCache = createMemoryCache({
prefix: 'mintel:blog:',
defaultTTL: 3600
});
}
}
return defaultCache;
}
// Convenience function using default cache
export async function withCache<T>(
key: string,
fn: () => Promise<T>,
ttl?: number
): Promise<T> {
const cache = await getDefaultCache();
return cache.wrap(key, fn, ttl);
}
// Re-export interfaces and adapters for advanced usage
export type { CacheAdapter, CacheConfig };
export { MemoryCacheAdapter, RedisCacheAdapter };

15
apps/web/src/utils/cache/interfaces.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
/**
* Cache interfaces - decoupled contracts
*/
export interface CacheAdapter {
get<T>(key: string): Promise<T | null>;
set<T>(key: string, value: T, ttl?: number): Promise<void>;
del(key: string): Promise<void>;
clear(): Promise<void>;
}
export interface CacheConfig {
prefix?: string;
defaultTTL?: number;
}

View File

@@ -0,0 +1,43 @@
/**
* Memory Cache Adapter
* Simple in-memory implementation
*/
import type { CacheAdapter, CacheConfig } from './interfaces';
export class MemoryCacheAdapter implements CacheAdapter {
private cache = new Map<string, { value: any; expiry: number }>();
private defaultTTL: number;
constructor(config: CacheConfig = {}) {
this.defaultTTL = config.defaultTTL || 3600;
}
async get<T>(key: string): Promise<T | null> {
const item = this.cache.get(key);
if (!item) return null;
if (Date.now() > item.expiry) {
this.cache.delete(key);
return null;
}
return item.value as T;
}
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
const finalTTL = ttl || this.defaultTTL;
this.cache.set(key, {
value,
expiry: Date.now() + (finalTTL * 1000)
});
}
async del(key: string): Promise<void> {
this.cache.delete(key);
}
async clear(): Promise<void> {
this.cache.clear();
}
}

View File

@@ -0,0 +1,95 @@
/**
* Redis Cache Adapter
* Decoupled Redis implementation
*/
import type { CacheAdapter, CacheConfig } from './interfaces';
export class RedisCacheAdapter implements CacheAdapter {
private client: any = null;
private prefix: string;
private defaultTTL: number;
private redisUrl: string;
constructor(config: CacheConfig & { redisUrl?: string } = {}) {
this.prefix = config.prefix || 'mintel:';
this.defaultTTL = config.defaultTTL || 3600;
this.redisUrl = config.redisUrl || process.env.REDIS_URL || 'redis://localhost:6379';
}
private async init(): Promise<boolean> {
if (this.client !== null) return true;
try {
const Redis = await import('ioredis');
this.client = new Redis.default(this.redisUrl);
this.client.on('error', (err: Error) => {
console.warn('Redis connection error:', err.message);
this.client = null;
});
this.client.on('connect', () => {
console.log('✅ Redis connected');
});
// Test connection
await this.client.set(this.prefix + '__test__', 'ok', 'EX', 1);
return true;
} catch (error) {
console.warn('Redis unavailable:', error);
this.client = null;
return false;
}
}
async get<T>(key: string): Promise<T | null> {
const available = await this.init();
if (!available || !this.client) return null;
try {
const data = await this.client.get(this.prefix + key);
return data ? JSON.parse(data) : null;
} catch (error) {
console.warn('Redis get error:', error);
return null;
}
}
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
const available = await this.init();
if (!available || !this.client) return;
try {
const finalTTL = ttl || this.defaultTTL;
await this.client.setex(this.prefix + key, finalTTL, JSON.stringify(value));
} catch (error) {
console.warn('Redis set error:', error);
}
}
async del(key: string): Promise<void> {
const available = await this.init();
if (!available || !this.client) return;
try {
await this.client.del(this.prefix + key);
} catch (error) {
console.warn('Redis del error:', error);
}
}
async clear(): Promise<void> {
const available = await this.init();
if (!available || !this.client) return;
try {
const keys = await this.client.keys(this.prefix + '*');
if (keys.length > 0) {
await this.client.del(...keys);
}
} catch (error) {
console.warn('Redis clear error:', error);
}
}
}