KI-AGENT: Zentralen Push-Server Stack ergänzen
This commit is contained in:
100
push-server/apps/api/src/services/apns.ts
Normal file
100
push-server/apps/api/src/services/apns.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
104
push-server/apps/api/src/services/delivery.ts
Normal file
104
push-server/apps/api/src/services/delivery.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user