From 2d79d8aefd59aad605c9594102506b51d9db138c Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Tue, 2 Jun 2026 21:06:42 +0700 Subject: [PATCH] feat(shared): reorder AppError constructor to (message, code, status), add singleton logger, retry and TTL cache utilities --- packages/shared/package.json | 1 + packages/shared/src/errors/index.ts | 28 +-- packages/shared/src/logger/index.ts | 47 +++-- packages/shared/src/utils/index.ts | 158 ++++++++++++++++- pnpm-lock.yaml | 73 ++++++++ services/backend/package.json | 1 + services/backend/src/http/app.ts | 2 +- services/backend/src/http/server.ts | 2 +- services/backend/src/index.ts | 2 +- .../src/modules/analysis/analysis.routes.ts | 2 +- .../src/modules/analysis/analysis.service.ts | 2 +- .../modules/analytics/analytics.controller.ts | 16 +- .../modules/analytics/analytics.repository.ts | 2 +- .../modules/analytics/analytics.service.ts | 4 +- .../backend/src/modules/auth/auth.routes.ts | 4 +- .../src/modules/health/health.repository.ts | 2 +- .../src/modules/health/health.service.ts | 2 +- .../backend/src/modules/media/media.routes.ts | 2 +- .../src/modules/media/media.service.ts | 2 +- .../modules/messages/messages.controller.ts | 23 +-- .../modules/messages/messages.repository.ts | 2 +- .../src/modules/messages/messages.routes.ts | 2 +- .../src/modules/messages/messages.service.ts | 4 +- .../modules/recordings/recordings.routes.ts | 2 +- .../modules/recordings/recordings.service.ts | 2 +- .../src/modules/ui-state/ui-state.routes.ts | 2 +- .../src/modules/ui-state/ui-state.service.ts | 2 +- .../src/modules/voice/voice.service.ts | 162 ++++-------------- services/backend/src/shared/database/index.ts | 2 +- services/backend/src/shared/errors/index.ts | 65 ------- services/backend/src/shared/logger/index.ts | 24 --- .../backend/src/shared/middlewares/index.ts | 20 ++- services/backend/src/shared/redis/index.ts | 2 +- services/backend/src/ws/broadcast.ts | 1 - services/backend/src/ws/redis-bridge.ts | 19 +- services/backend/src/ws/server.ts | 3 +- services/discord-gateway/package.json | 1 + .../modules/message-capture/broadcaster.ts | 2 +- .../src/modules/message-capture/types.ts | 29 +--- .../voice-recording/voiceController.ts | 2 +- services/frontend/src/entities/guild/types.ts | 13 +- services/frontend/src/entities/media/types.ts | 18 +- .../frontend/src/entities/message/types.ts | 9 + services/frontend/src/entities/ui/types.ts | 15 +- services/frontend/src/entities/voice/types.ts | 15 +- .../live/components/RecordingsSubPanel.tsx | 11 +- .../messages/components/ImageGrid.tsx | 16 +- .../messages/components/MessageCard.tsx | 16 +- services/frontend/src/shared/api/client.ts | 2 + services/frontend/src/shared/lib/utils.ts | 12 ++ 50 files changed, 401 insertions(+), 449 deletions(-) delete mode 100644 services/backend/src/shared/errors/index.ts delete mode 100644 services/backend/src/shared/logger/index.ts diff --git a/packages/shared/package.json b/packages/shared/package.json index c2f902b..03dc020 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -22,6 +22,7 @@ }, "devDependencies": { "@types/node": "^25.9.0", + "pino-pretty": "^13.1.3", "typescript": "^5.9.3" } } diff --git a/packages/shared/src/errors/index.ts b/packages/shared/src/errors/index.ts index acbf832..9737724 100644 --- a/packages/shared/src/errors/index.ts +++ b/packages/shared/src/errors/index.ts @@ -2,9 +2,9 @@ export class AppError extends Error { constructor( - public code: string, - public statusCode: number, message: string, + public code: string, + public statusCode: number = 500, public details?: Record, ) { super(message); @@ -14,35 +14,39 @@ export class AppError extends Error { export class ValidationError extends AppError { constructor(message: string, details?: Record) { - super("VALIDATION_ERROR", 400, message, details); + super(message, "VALIDATION_ERROR", 400, details); this.name = "ValidationError"; } } export class NotFoundError extends AppError { constructor(resource: string, id?: string) { - super("NOT_FOUND", 404, `${resource} not found${id ? `: ${id}` : ""}`); + super( + `${resource} not found${id ? `: ${id}` : ""}`, + "NOT_FOUND", + 404, + ); this.name = "NotFoundError"; } } export class UnauthorizedError extends AppError { constructor(message = "Unauthorized") { - super("UNAUTHORIZED", 401, message); + super(message, "UNAUTHORIZED", 401); this.name = "UnauthorizedError"; } } export class ForbiddenError extends AppError { constructor(message = "Forbidden") { - super("FORBIDDEN", 403, message); + super(message, "FORBIDDEN", 403); this.name = "ForbiddenError"; } } export class ConflictError extends AppError { constructor(message: string) { - super("CONFLICT", 409, message); + super(message, "CONFLICT", 409); this.name = "ConflictError"; } } @@ -52,35 +56,35 @@ export class InternalServerError extends AppError { message = "Internal server error", details?: Record, ) { - super("INTERNAL_SERVER_ERROR", 500, message, details); + super(message, "INTERNAL_SERVER_ERROR", 500, details); this.name = "InternalServerError"; } } export class DatabaseError extends AppError { constructor(message: string, details?: Record) { - super("DATABASE_ERROR", 500, message, details); + super(message, "DATABASE_ERROR", 500, details); this.name = "DatabaseError"; } } export class ConfigError extends AppError { constructor(message: string) { - super("CONFIG_ERROR", 500, message); + super(message, "CONFIG_ERROR", 500); this.name = "ConfigError"; } } export class DiscordError extends AppError { constructor(message: string, details?: Record) { - super("DISCORD_ERROR", 500, message, details); + super(message, "DISCORD_ERROR", 500, details); this.name = "DiscordError"; } } export class TimeoutError extends AppError { constructor(operation: string) { - super("TIMEOUT", 504, `${operation} timed out`); + super(`${operation} timed out`, "TIMEOUT", 504); this.name = "TimeoutError"; } } diff --git a/packages/shared/src/logger/index.ts b/packages/shared/src/logger/index.ts index 9587f4e..037ec3c 100644 --- a/packages/shared/src/logger/index.ts +++ b/packages/shared/src/logger/index.ts @@ -1,25 +1,34 @@ import pino from "pino"; -export type Logger = ReturnType; +const rootLogger = pino({ + level: process.env.LOG_LEVEL || "info", + transport: + process.env.NODE_ENV === "development" + ? { + target: "pino-pretty", + options: { + colorize: true, + translateTime: "SYS:standard", + ignore: "pid,hostname", + }, + } + : undefined, +} as pino.LoggerOptions); -export function createLogger(context: string) { - return pino({ - name: context, - level: process.env.LOG_LEVEL || "info", - transport: - process.env.NODE_ENV === "development" - ? { - target: "pino-pretty", - options: { - colorize: true, - translateTime: "SYS:standard", - ignore: "pid,hostname", - }, - } - : undefined, - } as pino.LoggerOptions); -} +export type Logger = ReturnType; +/** + * Returns a child logger bound to the root singleton via pino's .child(). + * Preserves parent context and is efficient (no transport re-init per call). + */ export function createChildLogger(context: string) { - return createLogger(context); + return rootLogger.child({ context }); +} + +/** + * Alias for createChildLogger for backwards compatibility. + * @deprecated Use createChildLogger instead. + */ +export function createLogger(context: string) { + return createChildLogger(context); } diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index cb25f2c..17dbf5d 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -13,7 +13,7 @@ export function formatBytes(bytes: number): string { } export function generateId(): string { - return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; } export function isValidUrl(url: string): boolean { @@ -26,7 +26,7 @@ export function isValidUrl(url: string): boolean { } export function sanitizeString(str: string): string { - return str.replace(/[<>]/g, "").trim().substring(0); + return str.replace(/[<>]/g, "").trim(); } export interface PaginationParams { @@ -59,3 +59,157 @@ export function calculatePagination( export function getOffset(page: number, limit: number): number { return (page - 1) * limit; } + +// --------------------------------------------------------------------------- +// Retry with exponential backoff (port of discord-gateway retry utility) +// --------------------------------------------------------------------------- + +export interface RetryOptions { + /** Number of retry attempts (default: 3) */ + retries?: number; + /** Initial delay in ms (default: 1000) */ + minTimeout?: number; + /** Maximum delay in ms (default: 30000) */ + maxTimeout?: number; + /** Multiplication factor for each retry (default: 2) */ + factor?: number; +} + +export async function retryWithBackoff( + fn: () => Promise, + options: RetryOptions = {}, +): Promise { + const { + retries = 3, + minTimeout = 1_000, + maxTimeout = 30_000, + factor = 2, + } = options; + + let lastError: Error | undefined; + for (let attempt = 0; attempt <= retries; attempt++) { + try { + return await fn(); + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + if (attempt === retries) break; + const backoff = Math.min( + minTimeout * factor ** attempt + Math.random() * 100, + maxTimeout, + ); + await delay(backoff); + } + } + throw lastError!; +} + +// --------------------------------------------------------------------------- +// Generic in-memory TTL cache with LRU-style pruning +// --------------------------------------------------------------------------- + +interface CacheEntry { + value: V; + expiresAt: number; +} + +export interface TtlCacheOptions { + /** Default TTL in ms for entries (default: 60000) */ + defaultTtlMs?: number; + /** Maximum entries before pruning (default: 500) */ + maxEntries?: number; + /** Called when an entry is evicted */ + onEvict?: (key: K, value: unknown) => void; +} + +export class TtlCache { + private store = new Map>(); + private readonly defaultTtlMs: number; + private readonly maxEntries: number; + private readonly onEvict?: (key: K, value: V) => void; + + constructor(options: TtlCacheOptions = {}) { + this.defaultTtlMs = options.defaultTtlMs ?? 60_000; + this.maxEntries = options.maxEntries ?? 500; + this.onEvict = options.onEvict; + } + + /** + * Get a value by key. Returns undefined if missing or expired. + */ + get(key: K): V | undefined { + const entry = this.store.get(key); + if (!entry) return undefined; + if (Date.now() > entry.expiresAt) { + this.store.delete(key); + return undefined; + } + return entry.value; + } + + /** + * Set a value with optional custom TTL. Prunes oldest entries if at capacity. + */ + set(key: K, value: V, ttlMs?: number): void { + if (this.store.size >= this.maxEntries) { + this.prune(); + } + this.store.set(key, { + value, + expiresAt: Date.now() + (ttlMs ?? this.defaultTtlMs), + }); + } + + /** + * Check if a key exists and is not expired (without removing it). + */ + has(key: K): boolean { + return this.get(key) !== undefined; + } + + /** + * Remove a specific entry. + */ + delete(key: K): boolean { + return this.store.delete(key); + } + + /** + * Remove all expired entries. + */ + prune(): void { + const now = Date.now(); + const toDelete: K[] = []; + for (const [key, entry] of this.store) { + if (now > entry.expiresAt) { + toDelete.push(key); + } + } + for (const key of toDelete) { + const entry = this.store.get(key); + this.store.delete(key); + if (entry && this.onEvict) this.onEvict(key, entry.value); + } + // If still over limit after TTL pruning, drop oldest entries + if (this.store.size > this.maxEntries) { + const keysToDelete = Array.from(this.store.keys()).slice( + 0, + this.store.size - this.maxEntries, + ); + for (const key of keysToDelete) { + const entry = this.store.get(key); + this.store.delete(key); + if (entry && this.onEvict) this.onEvict(key, entry.value); + } + } + } + + /** Current number of entries (including possibly expired ones). */ + get size(): number { + return this.store.size; + } + + /** Remove all entries. */ + clear(): void { + this.store.clear(); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a36fdfd..7883579 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,12 +36,18 @@ importers: '@types/node': specifier: ^25.9.0 version: 25.9.0 + pino-pretty: + specifier: ^13.1.3 + version: 13.1.3 typescript: specifier: ^5.9.3 version: 5.9.3 services/backend: dependencies: + '@bete/shared': + specifier: workspace:* + version: link:../../packages/shared '@discordjs/voice': specifier: ^0.19.2 version: 0.19.2(@discordjs/opus@0.10.0)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(opusscript@0.0.8) @@ -109,6 +115,9 @@ importers: services/discord-gateway: dependencies: + '@bete/shared': + specifier: workspace:* + version: link:../../packages/shared '@discordjs/opus': specifier: ^0.10.0 version: 0.10.0 @@ -2498,6 +2507,9 @@ packages: resolution: {integrity: sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==} engines: {node: '>=18'} + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -2575,6 +2587,9 @@ packages: resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} engines: {node: '>=0.11'} + dateformat@4.6.3: + resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + debug-level@4.1.1: resolution: {integrity: sha512-r/T+zzVbsy4FL91zdUrxc1I788DRXwU4mg65Yk8F5ACHNu9ucTXiWrb4JaKHbNtwuOUKKpE8oEnDg0fcBFmmBw==} engines: {node: '>=18'} @@ -2944,6 +2959,9 @@ packages: resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} engines: {node: '>=4'} + fast-copy@4.0.3: + resolution: {integrity: sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -3194,6 +3212,9 @@ packages: resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==} engines: {node: '>=18.0.0'} + help-me@5.0.0: + resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} @@ -3379,6 +3400,10 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3998,9 +4023,16 @@ packages: pino-abstract-transport@2.0.0: resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + pino-http@10.5.0: resolution: {integrity: sha512-hD91XjgaKkSsdn8P7LaebrNzhGTdB086W3pyPihX0EzGPjq5uBJBXo4N5guqNaK6mUjg9aubMF7wDViYek9dRA==} + pino-pretty@13.1.3: + resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==} + hasBin: true + pino-std-serializers@7.1.0: resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} @@ -4316,6 +4348,9 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + semver@5.7.2: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} hasBin: true @@ -4522,6 +4557,10 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -6822,6 +6861,8 @@ snapshots: color-convert: 3.1.3 color-string: 2.1.4 + colorette@2.0.20: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -6896,6 +6937,8 @@ snapshots: dependencies: '@babel/runtime': 7.29.2 + dateformat@4.6.3: {} + debug-level@4.1.1: dependencies: asyncc: 2.0.9 @@ -7300,6 +7343,8 @@ snapshots: iconv-lite: 0.4.24 tmp: 0.0.33 + fast-copy@4.0.3: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -7565,6 +7610,8 @@ snapshots: helmet@8.1.0: {} + help-me@5.0.0: {} + hosted-git-info@2.8.9: {} hosted-git-info@4.1.0: @@ -7723,6 +7770,8 @@ snapshots: jiti@2.7.0: {} + joycon@3.1.1: {} + js-tokens@4.0.0: {} js-yaml@3.14.2: @@ -8312,6 +8361,10 @@ snapshots: dependencies: split2: 4.2.0 + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + pino-http@10.5.0: dependencies: get-caller-file: 2.0.5 @@ -8319,6 +8372,22 @@ snapshots: pino-std-serializers: 7.1.0 process-warning: 5.0.0 + pino-pretty@13.1.3: + dependencies: + colorette: 2.0.20 + dateformat: 4.6.3 + fast-copy: 4.0.3 + fast-safe-stringify: 2.1.1 + help-me: 5.0.0 + joycon: 3.1.1 + minimist: 1.2.8 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pump: 3.0.4 + secure-json-parse: 4.1.0 + sonic-boom: 4.2.1 + strip-json-comments: 5.0.3 + pino-std-serializers@7.1.0: {} pino@9.14.0: @@ -8652,6 +8721,8 @@ snapshots: scheduler@0.27.0: {} + secure-json-parse@4.1.0: {} + semver@5.7.2: {} semver@6.3.1: {} @@ -8888,6 +8959,8 @@ snapshots: strip-json-comments@3.1.1: {} + strip-json-comments@5.0.3: {} + supports-color@5.5.0: dependencies: has-flag: 3.0.0 diff --git a/services/backend/package.json b/services/backend/package.json index db30daf..76b1d7f 100644 --- a/services/backend/package.json +++ b/services/backend/package.json @@ -14,6 +14,7 @@ "test": "vitest run" }, "dependencies": { + "@bete/shared": "workspace:*", "@discordjs/voice": "^0.19.2", "@types/pg": "^8.20.0", "axios": "^1.16.1", diff --git a/services/backend/src/http/app.ts b/services/backend/src/http/app.ts index d792779..dfb2a17 100644 --- a/services/backend/src/http/app.ts +++ b/services/backend/src/http/app.ts @@ -16,7 +16,7 @@ import { createRecordingsRouter } from "../modules/recordings/recordings.routes. import { createUiStateRouter } from "../modules/ui-state/ui-state.routes.js"; import { createVoiceRouter } from "../modules/voice/voice.routes.js"; import { createGuildsRouter } from "../modules/voice/guilds.routes.js"; -import { createChildLogger } from "../shared/logger/index.js"; +import { createChildLogger } from "@bete/shared/logger"; import { errorHandler } from "../shared/middlewares/index.js"; const logger = createChildLogger("http.app"); diff --git a/services/backend/src/http/server.ts b/services/backend/src/http/server.ts index 346f9ad..3e6eb2b 100644 --- a/services/backend/src/http/server.ts +++ b/services/backend/src/http/server.ts @@ -1,7 +1,7 @@ import { createServer, type Server } from "node:http"; import { config } from "../shared/config/index.js"; import { initializeDatabase } from "../shared/database/index.js"; -import { createChildLogger } from "../shared/logger/index.js"; +import { createChildLogger } from "@bete/shared/logger"; import { createHttpApp } from "./app.js"; import { createWebSocketServer } from "../ws/server.js"; import { startRedisBridge } from "../ws/redis-bridge.js"; diff --git a/services/backend/src/index.ts b/services/backend/src/index.ts index e859996..dca988c 100644 --- a/services/backend/src/index.ts +++ b/services/backend/src/index.ts @@ -1,5 +1,5 @@ import { startHttpServer } from "./http/server.js"; -import { createChildLogger } from "./shared/logger/index.js"; +import { createChildLogger } from "@bete/shared/logger"; import type { Server } from "node:http"; const logger = createChildLogger("backend"); diff --git a/services/backend/src/modules/analysis/analysis.routes.ts b/services/backend/src/modules/analysis/analysis.routes.ts index 73453e5..eb2d528 100644 --- a/services/backend/src/modules/analysis/analysis.routes.ts +++ b/services/backend/src/modules/analysis/analysis.routes.ts @@ -1,6 +1,6 @@ import type { Request, Response, Router } from "express"; import express from "express"; -import { createChildLogger } from "../../shared/logger/index.js"; +import { createChildLogger } from "@bete/shared/logger"; import { asyncHandler } from "../../shared/middlewares/index.js"; import { analysisService } from "./analysis.service.js"; diff --git a/services/backend/src/modules/analysis/analysis.service.ts b/services/backend/src/modules/analysis/analysis.service.ts index f8519de..92a6b61 100644 --- a/services/backend/src/modules/analysis/analysis.service.ts +++ b/services/backend/src/modules/analysis/analysis.service.ts @@ -1,7 +1,7 @@ import { sql } from "drizzle-orm"; import { config } from "../../shared/config/index.js"; import { getDatabase } from "../../shared/database/index.js"; -import { createChildLogger } from "../../shared/logger/index.js"; +import { createChildLogger } from "@bete/shared/logger"; const logger = createChildLogger("analysis.service"); diff --git a/services/backend/src/modules/analytics/analytics.controller.ts b/services/backend/src/modules/analytics/analytics.controller.ts index f642ad6..a0601c4 100644 --- a/services/backend/src/modules/analytics/analytics.controller.ts +++ b/services/backend/src/modules/analytics/analytics.controller.ts @@ -1,18 +1,14 @@ import type { NextFunction, Request, Response } from "express"; -import { createChildLogger } from "../../shared/logger/index.js"; -import { asyncHandler } from "../../shared/middlewares/index.js"; +import { createChildLogger } from "@bete/shared/logger"; +import { + asyncHandler, + requireParam, +} from "../../shared/middlewares/index.js"; import { analyticsQuerySchema } from "./analytics.schema.js"; import { analyticsService } from "./analytics.service.js"; const logger = createChildLogger("analytics.controller"); -function requireQueryString(value: unknown, name: string): string { - if (typeof value !== "string" || value.length === 0) { - throw new Error(`Missing query parameter: ${name}`); - } - return value; -} - export function handleGetOverview( req: Request, res: Response, @@ -32,7 +28,7 @@ export function handleGetDailyTrend( next: NextFunction, ) { return asyncHandler(async (req: Request, res: Response) => { - const guildId = requireQueryString(req.query.guildId, "guildId"); + const guildId = requireParam(req.query.guildId, "query parameter", "guildId"); const hours = req.query.hours ? Number(req.query.hours) : 24; logger.debug({ guildId, hours }, "Handling get daily trend"); const result = await analyticsService.getDailyTrend(guildId, hours); diff --git a/services/backend/src/modules/analytics/analytics.repository.ts b/services/backend/src/modules/analytics/analytics.repository.ts index e394090..6355793 100644 --- a/services/backend/src/modules/analytics/analytics.repository.ts +++ b/services/backend/src/modules/analytics/analytics.repository.ts @@ -1,5 +1,5 @@ import { getPool } from "../../shared/database/index.js"; -import { createChildLogger } from "../../shared/logger/index.js"; +import { createChildLogger } from "@bete/shared/logger"; const logger = createChildLogger("analytics.repository"); diff --git a/services/backend/src/modules/analytics/analytics.service.ts b/services/backend/src/modules/analytics/analytics.service.ts index 6795c4a..11037b8 100644 --- a/services/backend/src/modules/analytics/analytics.service.ts +++ b/services/backend/src/modules/analytics/analytics.service.ts @@ -1,6 +1,6 @@ import { config } from "../../shared/config/index.js"; -import { ForbiddenError, ValidationError } from "../../shared/errors/index.js"; -import { createChildLogger } from "../../shared/logger/index.js"; +import { ForbiddenError, ValidationError } from "@bete/shared/errors"; +import { createChildLogger } from "@bete/shared/logger"; import { analyticsRepository } from "./analytics.repository.js"; import type { AnalyticsQuery } from "./analytics.schema.js"; diff --git a/services/backend/src/modules/auth/auth.routes.ts b/services/backend/src/modules/auth/auth.routes.ts index 45cba44..54842f1 100644 --- a/services/backend/src/modules/auth/auth.routes.ts +++ b/services/backend/src/modules/auth/auth.routes.ts @@ -1,8 +1,8 @@ import type { Request, Response, Router } from "express"; import express from "express"; import { config } from "../../shared/config/index.js"; -import { UnauthorizedError } from "../../shared/errors/index.js"; -import { createChildLogger } from "../../shared/logger/index.js"; +import { UnauthorizedError } from "@bete/shared/errors"; +import { createChildLogger } from "@bete/shared/logger"; import { asyncHandler } from "../../shared/middlewares/index.js"; const logger = createChildLogger("auth.routes"); diff --git a/services/backend/src/modules/health/health.repository.ts b/services/backend/src/modules/health/health.repository.ts index bf289db..efe19e8 100644 --- a/services/backend/src/modules/health/health.repository.ts +++ b/services/backend/src/modules/health/health.repository.ts @@ -1,5 +1,5 @@ import { getPool } from "../../shared/database/index.js"; -import { createChildLogger } from "../../shared/logger/index.js"; +import { createChildLogger } from "@bete/shared/logger"; const logger = createChildLogger("health.repository"); diff --git a/services/backend/src/modules/health/health.service.ts b/services/backend/src/modules/health/health.service.ts index 6d66833..bb7d79b 100644 --- a/services/backend/src/modules/health/health.service.ts +++ b/services/backend/src/modules/health/health.service.ts @@ -1,4 +1,4 @@ -import { createChildLogger } from "../../shared/logger/index.js"; +import { createChildLogger } from "@bete/shared/logger"; import { healthRepository } from "./health.repository.js"; const logger = createChildLogger("health.service"); diff --git a/services/backend/src/modules/media/media.routes.ts b/services/backend/src/modules/media/media.routes.ts index 6d316d8..6b85bb0 100644 --- a/services/backend/src/modules/media/media.routes.ts +++ b/services/backend/src/modules/media/media.routes.ts @@ -1,6 +1,6 @@ import type { Request, Response, Router } from "express"; import express from "express"; -import { createChildLogger } from "../../shared/logger/index.js"; +import { createChildLogger } from "@bete/shared/logger"; import { asyncHandler } from "../../shared/middlewares/index.js"; import { queue, skip, stop, setVolume, getStatus } from "./media.service.js"; diff --git a/services/backend/src/modules/media/media.service.ts b/services/backend/src/modules/media/media.service.ts index cd30997..5984342 100644 --- a/services/backend/src/modules/media/media.service.ts +++ b/services/backend/src/modules/media/media.service.ts @@ -1,4 +1,4 @@ -import { createChildLogger } from "../../shared/logger/index.js"; +import { createChildLogger } from "@bete/shared/logger"; import { publishCommand, readRedisStatus } from "../../shared/redis/index.js"; const logger = createChildLogger("media.service"); diff --git a/services/backend/src/modules/messages/messages.controller.ts b/services/backend/src/modules/messages/messages.controller.ts index 54a70e7..7e57ab3 100644 --- a/services/backend/src/modules/messages/messages.controller.ts +++ b/services/backend/src/modules/messages/messages.controller.ts @@ -1,21 +1,14 @@ import type { NextFunction, Request, Response } from "express"; -import { createChildLogger } from "../../shared/logger/index.js"; -import { asyncHandler } from "../../shared/middlewares/index.js"; +import { createChildLogger } from "@bete/shared/logger"; +import { + asyncHandler, + requireParam, +} from "../../shared/middlewares/index.js"; import { messageQuerySchema } from "./messages.schema.js"; import { messagesService } from "./messages.service.js"; const logger = createChildLogger("messages.controller"); -function requireRouteParam( - value: string | string[] | undefined, - name: string, -): string { - if (typeof value !== "string" || value.length === 0) { - throw new Error(`Missing route parameter: ${name}`); - } - return value; -} - export function handleListMessages( req: Request, res: Response, @@ -35,7 +28,7 @@ export function handleGetMessagesByChannel( next: NextFunction, ) { return asyncHandler(async (req: Request, res: Response) => { - const channelId = requireRouteParam(req.params.channelId, "channelId"); + const channelId = requireParam(req.params.channelId, "route parameter", "channelId"); const query = messageQuerySchema.parse(req.query); logger.debug({ channelId, query }, "Handling get messages by channel"); const result = await messagesService.getMessagesByChannel(channelId, query); @@ -49,7 +42,7 @@ export function handleGetMessageById( next: NextFunction, ) { return asyncHandler(async (req: Request, res: Response) => { - const id = requireRouteParam(req.params.id, "id"); + const id = requireParam(req.params.id, "route parameter", "id"); logger.debug({ id }, "Handling get message by ID"); const result = await messagesService.getMessageById(id); res.json(result); @@ -62,7 +55,7 @@ export function handleGetAttachmentsByChannel( next: NextFunction, ) { return asyncHandler(async (req: Request, res: Response) => { - const channelId = requireRouteParam(req.params.channelId, "channelId"); + const channelId = requireParam(req.params.channelId, "route parameter", "channelId"); const query = messageQuerySchema.parse(req.query); logger.debug({ channelId, query }, "Handling get attachments by channel"); const result = await messagesService.getAttachmentsByChannel( diff --git a/services/backend/src/modules/messages/messages.repository.ts b/services/backend/src/modules/messages/messages.repository.ts index 391153d..559d61a 100644 --- a/services/backend/src/modules/messages/messages.repository.ts +++ b/services/backend/src/modules/messages/messages.repository.ts @@ -1,5 +1,5 @@ import { getPool } from "../../shared/database/index.js"; -import { createChildLogger } from "../../shared/logger/index.js"; +import { createChildLogger } from "@bete/shared/logger"; import type { MessageCreate, MessageQuery, diff --git a/services/backend/src/modules/messages/messages.routes.ts b/services/backend/src/modules/messages/messages.routes.ts index e533915..52251d8 100644 --- a/services/backend/src/modules/messages/messages.routes.ts +++ b/services/backend/src/modules/messages/messages.routes.ts @@ -1,7 +1,7 @@ import type { Request, Response, Router } from "express"; import express from "express"; import { getPool } from "../../shared/database/index.js"; -import { createChildLogger } from "../../shared/logger/index.js"; +import { createChildLogger } from "@bete/shared/logger"; import { asyncHandler } from "../../shared/middlewares/index.js"; import { handleGetAttachmentsByChannel, diff --git a/services/backend/src/modules/messages/messages.service.ts b/services/backend/src/modules/messages/messages.service.ts index abea4af..62298bf 100644 --- a/services/backend/src/modules/messages/messages.service.ts +++ b/services/backend/src/modules/messages/messages.service.ts @@ -1,5 +1,5 @@ -import { NotFoundError, ValidationError } from "../../shared/errors/index.js"; -import { createChildLogger } from "../../shared/logger/index.js"; +import { NotFoundError, ValidationError } from "@bete/shared/errors"; +import { createChildLogger } from "@bete/shared/logger"; import { messagesRepository } from "./messages.repository.js"; import type { MessageQuery } from "./messages.schema.js"; diff --git a/services/backend/src/modules/recordings/recordings.routes.ts b/services/backend/src/modules/recordings/recordings.routes.ts index 41e6ae5..bb6871d 100644 --- a/services/backend/src/modules/recordings/recordings.routes.ts +++ b/services/backend/src/modules/recordings/recordings.routes.ts @@ -1,6 +1,6 @@ import type { Request, Response, Router } from "express"; import express from "express"; -import { createChildLogger } from "../../shared/logger/index.js"; +import { createChildLogger } from "@bete/shared/logger"; import { asyncHandler } from "../../shared/middlewares/index.js"; import { recordingsService } from "./recordings.service.js"; diff --git a/services/backend/src/modules/recordings/recordings.service.ts b/services/backend/src/modules/recordings/recordings.service.ts index 2646855..84c2e83 100644 --- a/services/backend/src/modules/recordings/recordings.service.ts +++ b/services/backend/src/modules/recordings/recordings.service.ts @@ -1,6 +1,6 @@ import { sql } from "drizzle-orm"; import { getDatabase } from "../../shared/database/index.js"; -import { createChildLogger } from "../../shared/logger/index.js"; +import { createChildLogger } from "@bete/shared/logger"; const logger = createChildLogger("recordings.service"); diff --git a/services/backend/src/modules/ui-state/ui-state.routes.ts b/services/backend/src/modules/ui-state/ui-state.routes.ts index 7759b74..661e038 100644 --- a/services/backend/src/modules/ui-state/ui-state.routes.ts +++ b/services/backend/src/modules/ui-state/ui-state.routes.ts @@ -1,6 +1,6 @@ import type { Request, Response, Router } from "express"; import express from "express"; -import { createChildLogger } from "../../shared/logger/index.js"; +import { createChildLogger } from "@bete/shared/logger"; import { asyncHandler } from "../../shared/middlewares/index.js"; import { uiStateService } from "./ui-state.service.js"; diff --git a/services/backend/src/modules/ui-state/ui-state.service.ts b/services/backend/src/modules/ui-state/ui-state.service.ts index fcc45c4..3252e1b 100644 --- a/services/backend/src/modules/ui-state/ui-state.service.ts +++ b/services/backend/src/modules/ui-state/ui-state.service.ts @@ -1,6 +1,6 @@ import { sql } from "drizzle-orm"; import { getDatabase } from "../../shared/database/index.js"; -import { createChildLogger } from "../../shared/logger/index.js"; +import { createChildLogger } from "@bete/shared/logger"; const logger = createChildLogger("ui-state.service"); diff --git a/services/backend/src/modules/voice/voice.service.ts b/services/backend/src/modules/voice/voice.service.ts index c2c7eec..7920d29 100644 --- a/services/backend/src/modules/voice/voice.service.ts +++ b/services/backend/src/modules/voice/voice.service.ts @@ -1,7 +1,9 @@ -import Redis from "ioredis"; -import { config } from "../../shared/config/index.js"; import { getPool } from "../../shared/database/index.js"; -import { createChildLogger } from "../../shared/logger/index.js"; +import { + publishCommand, + readRedisStatus, +} from "../../shared/redis/index.js"; +import { createChildLogger } from "@bete/shared/logger"; const logger = createChildLogger("voice.service"); @@ -24,109 +26,14 @@ export interface VoiceStatus { activeChannelName: string | null; } -interface CommandReply { - id: string; - success: boolean; - data: unknown; - error?: string; -} - -// --- Redis command client --- -let commandRedis: Redis | null = null; -let statusRedis: Redis | null = null; - -function getCommandRedis(): Redis { - if (!commandRedis) { - commandRedis = config.REDIS_URL - ? new Redis(config.REDIS_URL, { keyPrefix: "" }) - : new Redis({ - host: config.REDIS_HOST, - port: config.REDIS_PORT, - keyPrefix: "", - }); - } - return commandRedis; -} - -function getStatusRedis(): Redis { - if (!statusRedis) { - statusRedis = config.REDIS_URL - ? new Redis(config.REDIS_URL, { keyPrefix: "" }) - : new Redis({ - host: config.REDIS_HOST, - port: config.REDIS_PORT, - keyPrefix: "", - }); - } - return statusRedis; -} - -async function sendCommand( - type: string, - payload: Record, - timeoutMs = 10000, -): Promise { - const redis = getCommandRedis(); - const id = `${type}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const replyChannel = `backend:command:reply:${id}`; - - return new Promise((resolve) => { - const timer = setTimeout(() => { - redis.unsubscribe(replyChannel).catch(() => {}); - resolve(null); - }, timeoutMs); - - redis.subscribe(replyChannel, (err) => { - if (err) { - clearTimeout(timer); - resolve(null); - return; - } - }); - - const handler = (_ch: string, msg: string) => { - if (_ch === replyChannel) { - clearTimeout(timer); - redis.unsubscribe(replyChannel).catch(() => {}); - try { - const reply: CommandReply = JSON.parse(msg); - resolve(reply.success ? (reply.data as T) : null); - } catch { - resolve(null); - } - } - }; - - redis.on("message", handler); - - redis - .publish( - "backend:command", - JSON.stringify({ id, type, payload, replyChannel }), - ) - .catch(() => { - clearTimeout(timer); - resolve(null); - }); - }); -} - -async function readStatus(key: string): Promise { - try { - const val = await getStatusRedis().get(key); - return val ? (JSON.parse(val) as T) : null; - } catch { - return null; - } -} - /** * Get guilds — query from discord-gateway via Redis command for real names. * Falls back to database (distinct guild_id from messages) if gateway unreachable. */ export async function getGuilds(): Promise { - const fromGateway = await sendCommand("guilds:list", {}); - if (fromGateway && fromGateway.length > 0) return fromGateway; + const reply = await publishCommand("guilds:list", {}); + if (reply?.success && reply.data && reply.data.length > 0) + return reply.data; // Fallback: Postgres with synthetic names logger.warn( @@ -149,10 +56,11 @@ export async function getGuilds(): Promise { * Falls back to database if gateway unreachable. */ export async function getTextChannels(guildId: string): Promise { - const fromGateway = await sendCommand("guilds:text-channels", { + const reply = await publishCommand("guilds:text-channels", { guildId, }); - if (fromGateway && fromGateway.length > 0) return fromGateway; + if (reply?.success && reply.data && reply.data.length > 0) + return reply.data; // Fallback: Postgres with synthetic names logger.warn( @@ -176,16 +84,16 @@ export async function getTextChannels(guildId: string): Promise { * Get voice channels — query from discord-gateway via Redis command. */ export async function getVoiceChannels(guildId: string): Promise { - const channels = await sendCommand("voice:channels", { guildId }); - return channels ?? []; + const reply = await publishCommand("voice:channels", { guildId }); + return reply?.success && reply.data ? reply.data : []; } /** * Get current voice connection status from Redis cache set by discord-gateway. */ export async function getVoiceStatus(): Promise { - const cached = await readStatus("voice:status"); - if (cached) return cached; + const cached = await readRedisStatus("voice:status"); + if (cached) return cached as unknown as VoiceStatus; return { connected: false, activeGuildId: null, @@ -201,38 +109,34 @@ export async function connectVoice( guildId: string, channelId: string, ): Promise { - const result = await sendCommand("voice:connect", { + const reply = await publishCommand("voice:connect", { guildId, channelId, }); - if (result) return result; + if (reply?.success && reply.data) return reply.data; // Fallback: read from Redis status key - const cached = await readStatus("voice:status"); - return ( - cached ?? { - connected: false, - activeGuildId: null, - activeChannelId: null, - activeChannelName: null, - } - ); + const cached = await readRedisStatus("voice:status"); + return (cached as unknown as VoiceStatus) ?? { + connected: false, + activeGuildId: null, + activeChannelId: null, + activeChannelName: null, + }; } /** * Disconnect from voice via Redis command to discord-gateway. */ export async function disconnectVoice(): Promise { - const result = await sendCommand("voice:disconnect", {}); - if (result) return result; + const reply = await publishCommand("voice:disconnect", {}); + if (reply?.success && reply.data) return reply.data; - const cached = await readStatus("voice:status"); - return ( - cached ?? { - connected: false, - activeGuildId: null, - activeChannelId: null, - activeChannelName: null, - } - ); + const cached = await readRedisStatus("voice:status"); + return (cached as unknown as VoiceStatus) ?? { + connected: false, + activeGuildId: null, + activeChannelId: null, + activeChannelName: null, + }; } diff --git a/services/backend/src/shared/database/index.ts b/services/backend/src/shared/database/index.ts index 5c1bc55..78b0fa9 100644 --- a/services/backend/src/shared/database/index.ts +++ b/services/backend/src/shared/database/index.ts @@ -1,7 +1,7 @@ import { drizzle } from "drizzle-orm/node-postgres"; import { Pool } from "pg"; import { config } from "../config/index.js"; -import { createChildLogger } from "../logger/index.js"; +import { createChildLogger } from "@bete/shared/logger"; const logger = createChildLogger("database"); diff --git a/services/backend/src/shared/errors/index.ts b/services/backend/src/shared/errors/index.ts deleted file mode 100644 index 5ecd437..0000000 --- a/services/backend/src/shared/errors/index.ts +++ /dev/null @@ -1,65 +0,0 @@ -export class AppError extends Error { - constructor( - message: string, - public code: string, - public statusCode: number = 500, - ) { - super(message); - this.name = "AppError"; - } -} - -export class ValidationError extends AppError { - constructor( - message: string, - public details?: Record, - ) { - super(message, "VALIDATION_ERROR", 400); - this.name = "ValidationError"; - } -} - -export class NotFoundError extends AppError { - constructor(message: string) { - super(message, "NOT_FOUND", 404); - this.name = "NotFoundError"; - } -} - -export class UnauthorizedError extends AppError { - constructor(message: string = "Unauthorized") { - super(message, "UNAUTHORIZED", 401); - this.name = "UnauthorizedError"; - } -} - -export class ForbiddenError extends AppError { - constructor(message: string = "Forbidden") { - super(message, "FORBIDDEN", 403); - this.name = "ForbiddenError"; - } -} - -export class ConflictError extends AppError { - constructor(message: string) { - super(message, "CONFLICT", 409); - this.name = "ConflictError"; - } -} - -export class DatabaseError extends AppError { - constructor( - message: string, - public originalError?: Error, - ) { - super(message, "DATABASE_ERROR", 500); - this.name = "DatabaseError"; - } -} - -export class ConfigError extends AppError { - constructor(message: string) { - super(message, "CONFIG_ERROR", 500); - this.name = "ConfigError"; - } -} diff --git a/services/backend/src/shared/logger/index.ts b/services/backend/src/shared/logger/index.ts deleted file mode 100644 index d2f9467..0000000 --- a/services/backend/src/shared/logger/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -import pino from "pino"; -import { config } from "../config/index.js"; - -const isDev = config.NODE_ENV === "development"; - -export const logger = pino({ - level: config.LOG_LEVEL, - transport: isDev - ? { - target: "pino-pretty", - options: { - colorize: true, - translateTime: "SYS:standard", - ignore: "pid,hostname", - }, - } - : undefined, -}); - -export function createChildLogger(context: string) { - return logger.child({ context }); -} - -export type Logger = ReturnType; diff --git a/services/backend/src/shared/middlewares/index.ts b/services/backend/src/shared/middlewares/index.ts index 7ec932f..38b0350 100644 --- a/services/backend/src/shared/middlewares/index.ts +++ b/services/backend/src/shared/middlewares/index.ts @@ -1,6 +1,10 @@ import type { NextFunction, Request, Response } from "express"; -import { AppError, UnauthorizedError } from "../errors/index.js"; -import { createChildLogger } from "../logger/index.js"; +import { + AppError, + UnauthorizedError, + ValidationError, +} from "@bete/shared/errors"; +import { createChildLogger } from "@bete/shared/logger"; const logger = createChildLogger("middleware"); @@ -46,5 +50,13 @@ export function asyncHandler( }; } -// Import ValidationError for type checking -import { ValidationError } from "../errors/index.js"; +/** + * Validate that a value is a non-empty string, or throw a descriptive error. + * Use for both route params and query string values. + */ +export function requireParam(value: unknown, kind: string, name: string): string { + if (typeof value !== "string" || value.length === 0) { + throw new Error(`Missing ${kind}: ${name}`); + } + return value; +} diff --git a/services/backend/src/shared/redis/index.ts b/services/backend/src/shared/redis/index.ts index daa8cc9..94dc3e4 100644 --- a/services/backend/src/shared/redis/index.ts +++ b/services/backend/src/shared/redis/index.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import Redis from "ioredis"; import { config } from "../config/index.js"; -import { createChildLogger } from "../logger/index.js"; +import { createChildLogger } from "@bete/shared/logger"; const logger = createChildLogger("redis.command-channel"); diff --git a/services/backend/src/ws/broadcast.ts b/services/backend/src/ws/broadcast.ts index d9d3242..82dc198 100644 --- a/services/backend/src/ws/broadcast.ts +++ b/services/backend/src/ws/broadcast.ts @@ -12,7 +12,6 @@ type BroadcastFn = (data: unknown) => void; type BroadcastRawFn = (type: string, data: unknown) => void; -// Extend globalThis with broadcast function types declare global { // biome-ignore lint/suspicious/noAssignInExpressions: intentional global broadcast registry var __broadcastFns: diff --git a/services/backend/src/ws/redis-bridge.ts b/services/backend/src/ws/redis-bridge.ts index 2794523..39b2dd2 100644 --- a/services/backend/src/ws/redis-bridge.ts +++ b/services/backend/src/ws/redis-bridge.ts @@ -1,6 +1,7 @@ import Redis from "ioredis"; import { config } from "../shared/config/index.js"; -import { createChildLogger } from "../shared/logger/index.js"; +import { getCommandPublisher } from "../shared/redis/index.js"; +import { createChildLogger } from "@bete/shared/logger"; import { broadcastRaw } from "./broadcast.js"; const logger = createChildLogger("ws.redis-bridge"); @@ -27,9 +28,8 @@ const SUBSCRIPTIONS: ChannelMapping[] = [ ]; let subscriber: Redis | null = null; -let publisher: Redis | null = null; -function createRedisInstance(): Redis { +function createSubscriber(): Redis { if (config.REDIS_URL) { return new Redis(config.REDIS_URL, { keyPrefix: "" }); } @@ -40,17 +40,6 @@ function createRedisInstance(): Redis { }); } -function createSubscriber(): Redis { - return createRedisInstance(); -} - -function getPublisher(): Redis { - if (!publisher) { - publisher = createRedisInstance(); - } - return publisher; -} - /** * Publish a command to the Discord Gateway via Redis. * The DG's commandHandler listens on "backend:command" channel. @@ -58,7 +47,7 @@ function getPublisher(): Redis { export async function publishCommand( payload: Record, ): Promise { - const pub = getPublisher(); + const pub = getCommandPublisher(); const envelope = { type: "command", data: payload, diff --git a/services/backend/src/ws/server.ts b/services/backend/src/ws/server.ts index e61c5c2..2294ea0 100644 --- a/services/backend/src/ws/server.ts +++ b/services/backend/src/ws/server.ts @@ -1,6 +1,6 @@ import type { Server } from "node:http"; import { WebSocket, WebSocketServer } from "ws"; -import { createChildLogger } from "../shared/logger/index.js"; +import { createChildLogger } from "@bete/shared/logger"; const logger = createChildLogger("ws.server"); @@ -10,7 +10,6 @@ interface BroadcastEvent { timestamp: string; } -// Extend globalThis with broadcast function types declare global { var __broadcastFns: | { diff --git a/services/discord-gateway/package.json b/services/discord-gateway/package.json index 3b23cfa..951b5ce 100644 --- a/services/discord-gateway/package.json +++ b/services/discord-gateway/package.json @@ -15,6 +15,7 @@ "test": "vitest run" }, "dependencies": { + "@bete/shared": "workspace:*", "@discordjs/opus": "^0.10.0", "@discordjs/voice": "^0.19.2", "@snazzah/davey": "^0.1.11", diff --git a/services/discord-gateway/src/modules/message-capture/broadcaster.ts b/services/discord-gateway/src/modules/message-capture/broadcaster.ts index a02d15a..280c8de 100644 --- a/services/discord-gateway/src/modules/message-capture/broadcaster.ts +++ b/services/discord-gateway/src/modules/message-capture/broadcaster.ts @@ -1,9 +1,9 @@ import type { WebSocket } from "ws"; import { createChildLogger } from "../../shared/logger/logger.js"; +import type { MediaState } from "../voice-recording/mediaTypes.js"; import type { AnalysisQueueStatus, AttachmentRecord, - MediaState, MessageRecord, ModerationWsEvent, } from "../message-capture/types.js"; diff --git a/services/discord-gateway/src/modules/message-capture/types.ts b/services/discord-gateway/src/modules/message-capture/types.ts index 79b38e2..c0d2f0f 100644 --- a/services/discord-gateway/src/modules/message-capture/types.ts +++ b/services/discord-gateway/src/modules/message-capture/types.ts @@ -171,33 +171,6 @@ export interface AnalysisResult { evidence?: string[]; } -export type MediaMode = "music" | "screen"; -export type MediaSourceKind = - | "url" - | "local" - | "youtube" - | "spotify" - | "search"; -export type MediaQueueItemStatus = "queued" | "playing" | "failed"; - -export interface MediaQueueItem { - id: string; - mode: MediaMode; - source: string; - title: string; - kind: MediaSourceKind; - requestedBy: string; - addedAt: number; - status: MediaQueueItemStatus; -} - -export interface MediaState { - playing: boolean; - musicVolume: number; - current: MediaQueueItem | null; - queue: MediaQueueItem[]; -} - export type ModerationWsEvent = | { type: "ui_state"; state: unknown } | { type: "user_state"; users: unknown[] } @@ -207,7 +180,7 @@ export type ModerationWsEvent = | { type: "message_analyzed"; data: MessageRecord } | { type: "attachment_created"; data: AttachmentRecord } | { type: "analysis_queue_status"; data: AnalysisQueueStatus } - | { type: "media_state"; state: MediaState } + | { type: "media_state"; state: unknown } | { type: "voice_recording_uploaded"; data: any }; export interface AnalysisQueueStatus { diff --git a/services/discord-gateway/src/modules/voice-recording/voiceController.ts b/services/discord-gateway/src/modules/voice-recording/voiceController.ts index ed331ea..cbc4722 100644 --- a/services/discord-gateway/src/modules/voice-recording/voiceController.ts +++ b/services/discord-gateway/src/modules/voice-recording/voiceController.ts @@ -1,6 +1,6 @@ import { getVoiceConnection, type VoiceConnection } from "@discordjs/voice"; import type { Client, Guild, VoiceChannel } from "discord.js-selfbot-v13"; -import { AppError } from "../../shared/errors/errors.js"; +import { AppError } from "@bete/shared/errors"; import { createChildLogger } from "../../shared/logger/logger.js"; import { discordPlayer } from "./player.js"; import { startRecording, stopRecording } from "./recorder.js"; diff --git a/services/frontend/src/entities/guild/types.ts b/services/frontend/src/entities/guild/types.ts index db2a911..c60825c 100644 --- a/services/frontend/src/entities/guild/types.ts +++ b/services/frontend/src/entities/guild/types.ts @@ -1,12 +1 @@ -export interface Guild { - id: string; - name: string; - icon: string | null; -} - -export interface Channel { - id: string; - name: string; - type?: string; - parentId?: string | null; -} +export { Guild, Channel } from "../../shared/api/client"; diff --git a/services/frontend/src/entities/media/types.ts b/services/frontend/src/entities/media/types.ts index b54573d..f6f6d35 100644 --- a/services/frontend/src/entities/media/types.ts +++ b/services/frontend/src/entities/media/types.ts @@ -1,17 +1 @@ -export type MediaMode = "music" | "screen"; - -export interface MediaItem { - id?: string; - source: string; - title: string; - mode?: MediaMode; - durationMs?: number | null; - thumbnailUrl?: string | null; -} - -export interface MediaState { - playing: boolean; - musicVolume: number; - current: MediaItem | null; - queue: MediaItem[]; -} +export { MediaMode, MediaItem, MediaState } from "../../shared/api/client"; diff --git a/services/frontend/src/entities/message/types.ts b/services/frontend/src/entities/message/types.ts index 0ffcf45..61c972a 100644 --- a/services/frontend/src/entities/message/types.ts +++ b/services/frontend/src/entities/message/types.ts @@ -14,6 +14,15 @@ export interface MessageMetadata { embeds?: Array<{ title?: string; image?: string; thumbnail?: string }>; } +export function parseMetadata(value: string | null): MessageMetadata { + if (!value) return {}; + try { + return JSON.parse(value) as MessageMetadata; + } catch { + return {}; + } +} + export interface MessageRecord { id: string; guild_id: string; diff --git a/services/frontend/src/entities/ui/types.ts b/services/frontend/src/entities/ui/types.ts index 9804c12..d79eb6e 100644 --- a/services/frontend/src/entities/ui/types.ts +++ b/services/frontend/src/entities/ui/types.ts @@ -1,14 +1 @@ -export type DashboardTab = "live" | "messages" | "analytics"; - -export interface UIState { - selectedGuild?: string; - selectedVoiceGuild?: string; - selectedVoiceChannel?: string; - selectedTextGuild?: string; - selectedTextChannel?: string; - selectedAnalyticsGuild?: string; - selectedAnalyticsChannel?: string; - activeTab?: DashboardTab; - isListening?: boolean; - isStreaming?: boolean; -} +export { DashboardTab, UIState } from "../../shared/api/client"; diff --git a/services/frontend/src/entities/voice/types.ts b/services/frontend/src/entities/voice/types.ts index 3d3a46b..91a83ed 100644 --- a/services/frontend/src/entities/voice/types.ts +++ b/services/frontend/src/entities/voice/types.ts @@ -1,14 +1 @@ -export interface VoiceStatus { - connected: boolean; - activeGuildId?: string | null; - activeChannelId?: string | null; - activeChannelName?: string | null; -} - -export interface ActiveSpeaker { - id?: string; - userId?: string; - username: string; - avatar: string; - speaking: boolean; -} +export { VoiceStatus, ActiveSpeaker } from "../../shared/api/client"; diff --git a/services/frontend/src/features/live/components/RecordingsSubPanel.tsx b/services/frontend/src/features/live/components/RecordingsSubPanel.tsx index 0b4ccda..5d073f8 100644 --- a/services/frontend/src/features/live/components/RecordingsSubPanel.tsx +++ b/services/frontend/src/features/live/components/RecordingsSubPanel.tsx @@ -3,6 +3,7 @@ import { Download, Mic } from "lucide-react"; import { useEffect, useState } from "react"; import { Badge, Button, Skeleton } from "../../../shared/ui"; +import { formatBytes, formatDate } from "../../../shared/lib/utils"; interface VoiceRecording { id: string; @@ -21,16 +22,6 @@ interface VoiceRecording { uploaded_at: number | null; } -function formatDate(value: number): string { - return new Date(value).toLocaleString(); -} - -function formatBytes(value: number): string { - if (value < 1024) return `${value} B`; - if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; - return `${(value / 1024 / 1024).toFixed(1)} MB`; -} - export function RecordingsSubPanel() { const [recordings, setRecordings] = useState([]); const [loading, setLoading] = useState(true); diff --git a/services/frontend/src/features/messages/components/ImageGrid.tsx b/services/frontend/src/features/messages/components/ImageGrid.tsx index ced3270..109d383 100644 --- a/services/frontend/src/features/messages/components/ImageGrid.tsx +++ b/services/frontend/src/features/messages/components/ImageGrid.tsx @@ -1,10 +1,5 @@ import type { MessageRecord } from "../../../shared/api/client"; - -interface MessageMetadata { - stickers?: Array<{ name?: string; url?: string }>; - attachments?: Array<{ name: string; url: string; contentType?: string }>; - embeds?: Array<{ title?: string; image?: string; thumbnail?: string }>; -} +import { parseMetadata } from "../../../entities/message/types"; interface ImageItem { url: string; @@ -13,15 +8,6 @@ interface ImageItem { message: MessageRecord; } -function parseMetadata(value: string | null): MessageMetadata { - if (!value) return {}; - try { - return JSON.parse(value) as MessageMetadata; - } catch { - return {}; - } -} - export function ImageGrid({ messages }: { messages: MessageRecord[] }) { const images: ImageItem[] = []; diff --git a/services/frontend/src/features/messages/components/MessageCard.tsx b/services/frontend/src/features/messages/components/MessageCard.tsx index 29bd3e9..9019a0b 100644 --- a/services/frontend/src/features/messages/components/MessageCard.tsx +++ b/services/frontend/src/features/messages/components/MessageCard.tsx @@ -14,6 +14,7 @@ import { Fragment, useMemo, useState } from "react"; import type { MessageRecord } from "../../../shared/api/client"; import { moderateMessage } from "../../../shared/api/client"; import { Badge, Button, Skeleton } from "../../../shared/ui"; +import { parseMetadata } from "../../../entities/message/types"; const CUSTOM_EMOJI_REGEX = /<(a)?:([a-zA-Z0-9_]+):(\d+)>/g; @@ -71,21 +72,6 @@ interface MessageCardProps { compact?: boolean; } -interface MessageMetadata { - stickers?: Array<{ name?: string; url?: string }>; - attachments?: Array<{ name: string; url: string; contentType?: string }>; - embeds?: Array<{ title?: string; image?: string; thumbnail?: string }>; -} - -function parseMetadata(value: string | null): MessageMetadata { - if (!value) return {}; - try { - return JSON.parse(value) as MessageMetadata; - } catch { - return {}; - } -} - function parseStringList(value?: string | null): string[] { if (!value) return []; try { diff --git a/services/frontend/src/shared/api/client.ts b/services/frontend/src/shared/api/client.ts index 5eeb491..c5ea715 100644 --- a/services/frontend/src/shared/api/client.ts +++ b/services/frontend/src/shared/api/client.ts @@ -112,6 +112,8 @@ export interface ActiveSpeaker { speaking: boolean; } +export type MediaMode = "music" | "screen"; + export interface MediaItem { id?: string; source: string; diff --git a/services/frontend/src/shared/lib/utils.ts b/services/frontend/src/shared/lib/utils.ts index 365058c..052d3ce 100644 --- a/services/frontend/src/shared/lib/utils.ts +++ b/services/frontend/src/shared/lib/utils.ts @@ -4,3 +4,15 @@ import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +export function formatBytes(bytes: number): string { + if (bytes === 0) return "0 Bytes"; + const k = 1024; + const sizes = ["Bytes", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${Math.round((bytes / Math.pow(k, i)) * 100) / 100} ${sizes[i]}`; +} + +export function formatDate(value: number): string { + return new Date(value).toLocaleString(); +}