import { config as loadDotenv } from "dotenv"; import { existsSync } from "node:fs"; import path from "node:path"; // Ladda .env från paketet ELLER monorepo-roten (pnpm --filter sätter cwd till paketet). for (const candidate of [".env", "../.env", "../../.env"]) { const p = path.resolve(process.cwd(), candidate); if (existsSync(p)) { loadDotenv({ path: p }); break; } } import { createDatabase, type Database } from "@app/database"; import { createAamosClient, type AamosClient, type BudgetStore } from "@app/ai-contracts"; import { createStorageService, type StorageService } from "@app/storage"; import type { Redis } from "ioredis"; export interface WorkerContext { db: Database; aamos: AamosClient; redis?: Redis; /** Delad dagsbudget-store så att memory-jobbet kan spegla skanningens budgetkoll. */ budgetStore?: BudgetStore; /** Lagringstjänst för att läsa/skriva bilder (S3 eller mock). */ storage: StorageService; /** Bygger läs-URL för lagrade bilder (presignerad S3 i aws-läge, mock-URL lokalt). */ readUrl: (key: string) => Promise; apiBaseUrl: string; close: () => Promise; } class RedisBudgetStore implements BudgetStore { constructor( private readonly redis: Redis, private readonly key: string, ) {} private alarmKey(): string { return this.key.replace(/^gemini:daily:budget:/, "gemini:daily:budget:alarm:"); } async getDailySpendUsd(): Promise { const val = await this.redis.get(this.key); return val ? Number(val) : 0; } async incrementDailySpendUsd(amountUsd: number): Promise { const newVal = await this.redis.incrbyfloat(this.key, amountUsd); // Expire at next midnight UTC to keep daily window. const ttl = this.secondsUntilMidnightUtc(); await this.redis.expire(this.key, ttl); return Number(newVal); } async raiseBudgetAlarm(): Promise { const ttl = this.secondsUntilMidnightUtc(); await this.redis.set(this.alarmKey(), "1", "EX", ttl); } private secondsUntilMidnightUtc(): number { const now = new Date(); const midnight = new Date( Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1), ); return Math.max(1, Math.floor((midnight.getTime() - now.getTime()) / 1000)); } } function requireEnv(name: string): string { const value = process.env[name]; if (!value) throw new Error(`Missing ${name}. Worker must be configured explicitly.`); return value; } export function createContext(redis?: Redis): WorkerContext { const databaseUrl = requireEnv("DATABASE_URL"); const { db, pool } = createDatabase(databaseUrl); const budgetStore = redis ? new RedisBudgetStore(redis, `gemini:daily:budget:${new Date().toISOString().slice(0, 10)}`) : undefined; const aamos = createAamosClient(undefined, { budgetStore }); const apiBaseUrl = process.env.API_BASE_URL ?? "http://localhost:4000"; const signingSecret = requireEnv("ENTITLEMENT_SIGNING_SECRET"); const s3Mode = process.env.S3_MODE === "aws" ? "aws" : "mock"; const storage = createStorageService({ mode: s3Mode, baseUrl: apiBaseUrl, signingSecret, s3Bucket: process.env.S3_BUCKET ?? "", s3Region: process.env.S3_REGION ?? "", s3Endpoint: process.env.S3_ENDPOINT || undefined, s3AccessKeyId: process.env.S3_ACCESS_KEY_ID || undefined, s3SecretAccessKey: process.env.S3_SECRET_ACCESS_KEY || undefined, }); return { db, aamos, redis, budgetStore, storage, apiBaseUrl, readUrl: (key: string) => storage.getReadUrl(key), close: async () => { await pool.end(); }, }; }