diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index dad2d8f..f47c053 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -55,6 +55,11 @@ const configSchema = z.object({ /** Strong, rotated ops token for /ops/v1/summary. Set via SSM in production. */ OPS_TOKEN: z.string().min(1).default("dev-ops-token-change-me"), + /** EOC base URL for outbound ops-summary push. Empty = push disabled. */ + EOC_URL: z.string().url().optional(), + /** EOC ingest bearer token for this app. Set via SSM in production. */ + EOC_PUSH_TOKEN: z.string().min(1).optional(), + APP_STORE_MODE: z.enum(["production", "sandbox"]).default("sandbox"), EMAIL_MODE: z.enum(["log", "smtp"]).default("log"), SMTP_HOST: z.string().default(""), diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index d4575f5..3b12c67 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -22,6 +22,7 @@ import { } from "./processors/maintenance.js"; import { processSafetyCanary } from "./processors/safety-canary.js"; import { refreshOpsSummaryCache } from "./processors/ops-summary.js"; +import { pushOpsSummaryToEoc } from "./processors/eoc-push.js"; import { DEAD_LETTER_QUEUE_NAME, @@ -145,6 +146,12 @@ const worker = new Worker( return; } + case "PUSH_OPS_SUMMARY_TO_EOC": { + const result = await pushOpsSummaryToEoc(ctx.redis!); + log(`EOC push: ${result.ok ? "OK" : "FEL"}${result.error ? ` (${result.error})` : ""}`); + return; + } + // Deterministiska/planerade jobb som inte kräver egen processor ännu case "NORMALIZE_PRODUCTS": case "DEDUPLICATE_INVENTORY": @@ -293,6 +300,15 @@ async function registerRepeatableJobs() { opts: baseOpts, }, ); + await queue.upsertJobScheduler( + "scheduler-eoc-push", + { every: 2 * 60 * 1000 }, // var 2:e minut + { + name: "PUSH_OPS_SUMMARY_TO_EOC", + data: { jobType: "PUSH_OPS_SUMMARY_TO_EOC" }, + opts: baseOpts, + }, + ); } // --- Minimal healthcheck-server så att flera instanser kan övervakas --- diff --git a/apps/worker/src/processors/eoc-push.ts b/apps/worker/src/processors/eoc-push.ts new file mode 100644 index 0000000..34c808d --- /dev/null +++ b/apps/worker/src/processors/eoc-push.ts @@ -0,0 +1,71 @@ +import type { Redis } from "ioredis"; +import { CACHE_KEY } from "./ops-summary.js"; + +export interface EocPushResult { + ok: boolean; + status: number | null; + error: string | null; +} + +const EOC_PUSH_TIMEOUT_MS = 15_000; + +function ingestUrl(base: string): string { + const trimmed = base.trim().replace(/\/+$/, ""); + return `${trimmed}/api/v1/apps/cibello/ingest`; +} + +/** + * Push the cached ops summary to EOC. Never throws; failures are returned + * as `ok: false` and must be logged by the caller. + */ +export async function pushOpsSummaryToEoc(redis: Redis): Promise { + const eocUrl = process.env.EOC_URL?.trim(); + const token = process.env.EOC_PUSH_TOKEN?.trim(); + + if (!eocUrl || !token) { + return { ok: false, status: null, error: "EOC_URL or EOC_PUSH_TOKEN not configured" }; + } + + const json = await redis.get(CACHE_KEY); + if (!json) { + return { ok: false, status: null, error: "ops summary cache miss" }; + } + + // Guard against accidentally sending non-object payloads. + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + return { ok: false, status: null, error: "ops summary cache contains invalid JSON" }; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return { ok: false, status: null, error: "ops summary cache is not a JSON object" }; + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), EOC_PUSH_TIMEOUT_MS); + + try { + const res = await fetch(ingestUrl(eocUrl), { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: json, + signal: controller.signal, + }); + clearTimeout(timeout); + + if (res.status === 204) { + return { ok: true, status: 204, error: null }; + } + + const text = await res.text().catch(() => ""); + return { ok: false, status: res.status, error: text.slice(0, 500) }; + } catch (err) { + clearTimeout(timeout); + const message = err instanceof Error ? err.message : String(err); + return { ok: false, status: null, error: message }; + } +} diff --git a/apps/worker/test/eoc-push.test.ts b/apps/worker/test/eoc-push.test.ts new file mode 100644 index 0000000..637988f --- /dev/null +++ b/apps/worker/test/eoc-push.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, beforeAll, afterAll } from "vitest"; +import IORedis from "ioredis"; +import { pushOpsSummaryToEoc } from "../src/processors/eoc-push.js"; + +const redis = new IORedis(process.env.REDIS_URL ?? "redis://localhost:6379", { + maxRetriesPerRequest: null, +}); + +describe("pushOpsSummaryToEoc", () => { + beforeAll(async () => { + await redis.set( + "ops:summary:cache", + JSON.stringify({ app: { app: "cibello", generated_at: new Date().toISOString() }, as_of: new Date().toISOString() }), + "EX", + 60, + ); + }); + + afterAll(async () => { + await redis.del("ops:summary:cache"); + await redis.quit(); + }); + + it("returns disabled result when EOC_URL is missing", async () => { + delete process.env.EOC_URL; + process.env.EOC_PUSH_TOKEN = "tok"; + const result = await pushOpsSummaryToEoc(redis); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/EOC_URL or EOC_PUSH_TOKEN not configured/); + }); + + it("returns disabled result when EOC_PUSH_TOKEN is missing", async () => { + process.env.EOC_URL = "https://eoc.example.com"; + delete process.env.EOC_PUSH_TOKEN; + const result = await pushOpsSummaryToEoc(redis); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/EOC_URL or EOC_PUSH_TOKEN not configured/); + }); + + it("returns cache-miss when cache is empty", async () => { + process.env.EOC_URL = "https://eoc.example.com"; + process.env.EOC_PUSH_TOKEN = "tok"; + await redis.del("ops:summary:cache"); + const result = await pushOpsSummaryToEoc(redis); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/cache miss/); + }); +});