KI-AGENT: Zentralen Push-Server Stack ergänzen

This commit is contained in:
2026-05-22 16:53:27 +02:00
parent 19bab852de
commit 5a4de421ce
43 changed files with 17731 additions and 0 deletions

View File

@@ -0,0 +1,100 @@
import http2 from "node:http2";
import jwt from "jsonwebtoken";
import { env } from "../config/env.js";
export type ApnsPayload = {
aps: {
alert?: {
title?: string;
body?: string;
};
sound?: string;
badge?: number;
"content-available"?: 1;
"mutable-content"?: 1;
};
data?: Record<string, unknown>;
};
export type ApnsResult =
| { ok: true; providerMessageId?: string }
| { ok: false; code: string; message: string; permanent: boolean };
export class ApnsClient {
isConfigured(): boolean {
return Boolean(env.APNS_TEAM_ID && env.APNS_KEY_ID && env.APNS_PRIVATE_KEY && env.IOS_BUNDLE_ID);
}
async send(deviceToken: string, payload: ApnsPayload, options?: { collapseKey?: string; priority?: "normal" | "high"; ttlSeconds?: number }): Promise<ApnsResult> {
if (!this.isConfigured()) {
return { ok: false, code: "apns_not_configured", message: "APNs ist nicht vollständig konfiguriert.", permanent: false };
}
const host = env.APNS_PRODUCTION ? "https://api.push.apple.com" : "https://api.sandbox.push.apple.com";
const token = jwt.sign(
{ iss: env.APNS_TEAM_ID, iat: Math.floor(Date.now() / 1000) },
env.APNS_PRIVATE_KEY.replace(/\\n/g, "\n"),
{ algorithm: "ES256", header: { alg: "ES256", kid: env.APNS_KEY_ID } },
);
return await new Promise<ApnsResult>((resolve) => {
const client = http2.connect(host);
const headers: http2.OutgoingHttpHeaders = {
":method": "POST",
":path": `/3/device/${deviceToken}`,
authorization: `bearer ${token}`,
"apns-topic": env.IOS_BUNDLE_ID,
"apns-push-type": payload.aps["content-available"] === 1 && !payload.aps.alert ? "background" : "alert",
"apns-priority": options?.priority === "high" ? "10" : "5",
};
if (options?.collapseKey) headers["apns-collapse-id"] = options.collapseKey.slice(0, 64);
if (options?.ttlSeconds) {
headers["apns-expiration"] = String(Math.floor(Date.now() / 1000) + options.ttlSeconds);
}
const req = client.request(headers);
const chunks: Buffer[] = [];
let statusCode = 0;
let apnsId: string | undefined;
req.setEncoding("utf8");
req.on("response", (responseHeaders) => {
statusCode = Number(responseHeaders[":status"] || 0);
const rawApnsId = responseHeaders["apns-id"];
apnsId = Array.isArray(rawApnsId) ? rawApnsId[0] : rawApnsId;
});
req.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
req.on("error", (error) => {
client.close();
resolve({ ok: false, code: "apns_transport_error", message: error.message, permanent: false });
});
req.on("end", () => {
client.close();
if (statusCode >= 200 && statusCode < 300) {
resolve({ ok: true, providerMessageId: apnsId });
return;
}
const body = Buffer.concat(chunks).toString("utf8");
const reason = safeReason(body) || `HTTP ${statusCode}`;
resolve({
ok: false,
code: `apns_${reason.toLowerCase()}`,
message: reason,
permanent: ["BadDeviceToken", "Unregistered", "DeviceTokenNotForTopic"].includes(reason),
});
});
req.end(JSON.stringify(payload));
});
}
}
function safeReason(body: string): string | null {
try {
const parsed = JSON.parse(body) as { reason?: string };
return parsed.reason || null;
} catch {
return null;
}
}

View File

@@ -0,0 +1,104 @@
import { and, eq, inArray } from "drizzle-orm";
import { deliveryAttempts, deliveryJobs, pushDevices, type DeliveryJob, type PushDevice } from "@fedeo/push-db";
import { db } from "../db/client.js";
import { decryptSecret } from "../lib/crypto.js";
import { ApnsClient, type ApnsPayload } from "./apns.js";
export type PushCommand = {
priority: "normal" | "high";
ttlSeconds: number;
collapseKey?: string;
notification?: {
title?: string;
body?: string;
};
data?: Record<string, unknown>;
};
const apns = new ApnsClient();
export async function deliverJob(job: DeliveryJob, deviceIds: string[], command: PushCommand): Promise<void> {
const devices = await db
.select()
.from(pushDevices)
.where(and(inArray(pushDevices.centralDeviceId, deviceIds), eq(pushDevices.instanceId, job.instanceId), eq(pushDevices.status, "active")));
let sent = 0;
let failed = 0;
let lastErrorCode: string | null = null;
let lastErrorMessage: string | null = null;
for (const device of devices) {
const result = await deliverToDevice(device, command);
if (result.ok) {
sent += 1;
await db.insert(deliveryAttempts).values({
deliveryJobId: job.id,
deviceId: device.id,
provider: result.provider,
status: "sent",
providerMessageId: result.providerMessageId,
sentAt: new Date(),
});
} else {
failed += 1;
lastErrorCode = result.code;
lastErrorMessage = result.message;
await db.insert(deliveryAttempts).values({
deliveryJobId: job.id,
deviceId: device.id,
provider: result.provider,
status: "failed",
errorCode: result.code,
errorMessage: result.message,
});
if (result.disableDevice) {
await db.update(pushDevices).set({ status: "invalid", disabledAt: new Date(), updatedAt: new Date() }).where(eq(pushDevices.id, device.id));
}
}
}
const status = failed === 0 ? "completed" : sent > 0 ? "partial" : "failed";
await db.update(deliveryJobs).set({
status,
sentCount: sent,
failedCount: failed,
lastErrorCode,
lastErrorMessage,
completedAt: new Date(),
updatedAt: new Date(),
}).where(eq(deliveryJobs.id, job.id));
}
async function deliverToDevice(device: PushDevice, command: PushCommand): Promise<
| { ok: true; provider: "apns" | "web_push" | "fcm"; providerMessageId?: string }
| { ok: false; provider: "apns" | "web_push" | "fcm"; code: string; message: string; disableDevice: boolean }
> {
if (device.platform === "ios") {
if (!device.providerTokenEncrypted) {
return { ok: false, provider: "apns", code: "missing_provider_token", message: "Für das iOS-Gerät ist kein Provider Token gespeichert.", disableDevice: true };
}
const payload: ApnsPayload = {
aps: {
alert: command.notification ? { title: command.notification.title, body: command.notification.body } : undefined,
sound: command.notification ? "default" : undefined,
"content-available": command.notification ? undefined : 1,
},
data: command.data,
};
const result = await apns.send(decryptSecret(device.providerTokenEncrypted), payload, {
collapseKey: command.collapseKey,
priority: command.priority,
ttlSeconds: command.ttlSeconds,
});
if (result.ok) return { ok: true, provider: "apns", providerMessageId: result.providerMessageId };
return { ok: false, provider: "apns", code: result.code, message: result.message, disableDevice: result.permanent };
}
if (device.platform === "android") {
return { ok: false, provider: "fcm", code: "fcm_not_implemented", message: "FCM ist im Stack vorbereitet, aber noch nicht implementiert.", disableDevice: false };
}
return { ok: false, provider: "web_push", code: "web_push_not_implemented", message: "Web Push ist im Stack vorbereitet, aber noch nicht implementiert.", disableDevice: false };
}