feat(notification): employee push subscriptions, rls migrations, and web-push dispatcher
This commit is contained in:
parent
f74aa6d7f5
commit
fb1270a0a8
6 changed files with 755 additions and 0 deletions
2
migrations/0010_employee_push_notifications.down.sql
Normal file
2
migrations/0010_employee_push_notifications.down.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
DROP TABLE IF EXISTS staff_notification_deliveries;
|
||||||
|
DROP TABLE IF EXISTS staff_push_subscriptions;
|
||||||
58
migrations/0010_employee_push_notifications.up.sql
Normal file
58
migrations/0010_employee_push_notifications.up.sql
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
-- Push subscriptions and delivery tracking for mobile employee notifications.
|
||||||
|
-- Protected by PostgreSQL Row Level Security (RLS) and tenant isolation.
|
||||||
|
|
||||||
|
CREATE TABLE staff_push_subscriptions (
|
||||||
|
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
|
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
|
employee_id VARCHAR(26) NOT NULL,
|
||||||
|
endpoint TEXT NOT NULL,
|
||||||
|
p256dh TEXT NOT NULL,
|
||||||
|
auth TEXT NOT NULL,
|
||||||
|
user_agent TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (tenant_id, id),
|
||||||
|
UNIQUE (tenant_id, endpoint),
|
||||||
|
FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX staff_push_subscriptions_employee_idx
|
||||||
|
ON staff_push_subscriptions (tenant_id, employee_id);
|
||||||
|
|
||||||
|
ALTER TABLE staff_push_subscriptions ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE staff_push_subscriptions FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY staff_push_subscriptions_tenant_isolation
|
||||||
|
ON staff_push_subscriptions
|
||||||
|
USING (tenant_id = current_setting('app.tenant_id', true))
|
||||||
|
WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
|
||||||
|
|
||||||
|
CREATE TABLE staff_notification_deliveries (
|
||||||
|
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
|
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
|
employee_id VARCHAR(26) NOT NULL,
|
||||||
|
outbox_id VARCHAR(26) NOT NULL CHECK (outbox_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
|
endpoint TEXT NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'delivered', 'failed')),
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_error TEXT,
|
||||||
|
delivered_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (tenant_id, id),
|
||||||
|
UNIQUE (tenant_id, outbox_id, endpoint),
|
||||||
|
FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (outbox_id) REFERENCES staff_outbox (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX staff_notification_deliveries_pending_idx
|
||||||
|
ON staff_notification_deliveries (tenant_id, status) WHERE status = 'pending';
|
||||||
|
|
||||||
|
ALTER TABLE staff_notification_deliveries ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE staff_notification_deliveries FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY staff_notification_deliveries_tenant_isolation
|
||||||
|
ON staff_notification_deliveries
|
||||||
|
USING (tenant_id = current_setting('app.tenant_id', true))
|
||||||
|
WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON staff_push_subscriptions TO krona_staff_app;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON staff_notification_deliveries TO krona_staff_app;
|
||||||
|
|
@ -11,6 +11,8 @@ import { registerPortalRoutes } from './portal.js';
|
||||||
import {
|
import {
|
||||||
portalCssEnhanced as portalCss,
|
portalCssEnhanced as portalCss,
|
||||||
portalHtml,
|
portalHtml,
|
||||||
|
portalIcon192,
|
||||||
|
portalIcon512,
|
||||||
portalJsEnhanced as portalJs,
|
portalJsEnhanced as portalJs,
|
||||||
portalManifest,
|
portalManifest,
|
||||||
portalServiceWorkerEnhanced as portalServiceWorker,
|
portalServiceWorkerEnhanced as portalServiceWorker,
|
||||||
|
|
@ -137,6 +139,8 @@ export async function buildApp({
|
||||||
app.get('/staff/app.js', async (_request, reply) => reply.type('application/javascript; charset=utf-8').send(portalJs));
|
app.get('/staff/app.js', async (_request, reply) => reply.type('application/javascript; charset=utf-8').send(portalJs));
|
||||||
app.get('/staff/sw.js', async (_request, reply) => reply.header('service-worker-allowed', '/staff').type('application/javascript; charset=utf-8').send(portalServiceWorker));
|
app.get('/staff/sw.js', async (_request, reply) => reply.header('service-worker-allowed', '/staff').type('application/javascript; charset=utf-8').send(portalServiceWorker));
|
||||||
app.get('/staff/manifest.webmanifest', async (_request, reply) => reply.type('application/manifest+json').send(portalManifest));
|
app.get('/staff/manifest.webmanifest', async (_request, reply) => reply.type('application/manifest+json').send(portalManifest));
|
||||||
|
app.get('/staff/icon-192.svg', async (_request, reply) => reply.type('image/svg+xml').send(portalIcon192));
|
||||||
|
app.get('/staff/icon-512.svg', async (_request, reply) => reply.type('image/svg+xml').send(portalIcon512));
|
||||||
|
|
||||||
app.addHook('onClose', async () => database.close());
|
app.addHook('onClose', async () => database.close());
|
||||||
return app;
|
return app;
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@ const configSchema = z.object({
|
||||||
PLATFORM_JWKS_URL: optionalUrl(),
|
PLATFORM_JWKS_URL: optionalUrl(),
|
||||||
STANDALONE_TENANT_ID: z.string().regex(/^[0-9A-HJKMNP-TV-Z]{26}$/),
|
STANDALONE_TENANT_ID: z.string().regex(/^[0-9A-HJKMNP-TV-Z]{26}$/),
|
||||||
STANDALONE_ACTOR_ID: z.string().regex(/^[0-9A-HJKMNP-TV-Z]{26}$/),
|
STANDALONE_ACTOR_ID: z.string().regex(/^[0-9A-HJKMNP-TV-Z]{26}$/),
|
||||||
|
VAPID_PUBLIC_KEY: emptyStringAsUndefined(z.string().optional()),
|
||||||
|
VAPID_PRIVATE_KEY: emptyStringAsUndefined(z.string().optional()),
|
||||||
|
VAPID_SUBJECT: emptyStringAsUndefined(z.string().optional()),
|
||||||
});
|
});
|
||||||
|
|
||||||
function emptyStringAsUndefined<T extends z.ZodTypeAny>(schema: T) {
|
function emptyStringAsUndefined<T extends z.ZodTypeAny>(schema: T) {
|
||||||
|
|
@ -42,6 +45,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||||
if (Boolean(config.STANDALONE_MANAGER_TOKEN) !== Boolean(config.STANDALONE_TERMINAL_TOKEN)) {
|
if (Boolean(config.STANDALONE_MANAGER_TOKEN) !== Boolean(config.STANDALONE_TERMINAL_TOKEN)) {
|
||||||
throw new Error('Both standalone manager and terminal tokens must be configured together');
|
throw new Error('Both standalone manager and terminal tokens must be configured together');
|
||||||
}
|
}
|
||||||
|
if (Boolean(config.VAPID_PUBLIC_KEY) !== Boolean(config.VAPID_PRIVATE_KEY)) {
|
||||||
|
throw new Error('VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be configured together');
|
||||||
|
}
|
||||||
if (config.NODE_ENV === 'production' && config.STAFF_RUNTIME_MODE === 'standalone') {
|
if (config.NODE_ENV === 'production' && config.STAFF_RUNTIME_MODE === 'standalone') {
|
||||||
throw new Error('STAFF_RUNTIME_MODE=standalone is not allowed in production');
|
throw new Error('STAFF_RUNTIME_MODE=standalone is not allowed in production');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
416
src/domain/notification/push-dispatcher.ts
Normal file
416
src/domain/notification/push-dispatcher.ts
Normal file
|
|
@ -0,0 +1,416 @@
|
||||||
|
import type { Database } from '../../infrastructure/database.js';
|
||||||
|
import { newUlid, parseUlid } from '../../core/ids.js';
|
||||||
|
import {
|
||||||
|
type PushSubscriptionTarget,
|
||||||
|
type VapidKeys,
|
||||||
|
type WebPushOptions,
|
||||||
|
sendWebPush,
|
||||||
|
} from './web-push.js';
|
||||||
|
|
||||||
|
export interface PushSubscriptionInput {
|
||||||
|
endpoint: string;
|
||||||
|
p256dh: string;
|
||||||
|
auth: string;
|
||||||
|
user_agent?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PushDispatcherOptions {
|
||||||
|
database: Database;
|
||||||
|
vapidKeys: VapidKeys;
|
||||||
|
systemActorId: string;
|
||||||
|
fetchFn?: typeof fetch | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DispatchDeliverySummary {
|
||||||
|
endpoint: string;
|
||||||
|
status: 'delivered' | 'failed' | 'skipped';
|
||||||
|
error?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DispatchReport {
|
||||||
|
outboxId: string;
|
||||||
|
subscriptionsFound: number;
|
||||||
|
deliveries: DispatchDeliverySummary[];
|
||||||
|
published: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OutboxRow {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
event_type: string;
|
||||||
|
subject_id: string;
|
||||||
|
payload: unknown;
|
||||||
|
published_at: string | null;
|
||||||
|
attempts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubscriptionRow {
|
||||||
|
id: string;
|
||||||
|
endpoint: string;
|
||||||
|
p256dh: string;
|
||||||
|
auth: string;
|
||||||
|
user_agent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeliveryRow {
|
||||||
|
id: string;
|
||||||
|
status: 'pending' | 'delivered' | 'failed';
|
||||||
|
attempts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PushDispatcher {
|
||||||
|
private readonly database: Database;
|
||||||
|
private readonly vapidKeys: VapidKeys;
|
||||||
|
private readonly systemActorId: string;
|
||||||
|
private readonly fetchFn?: typeof fetch | undefined;
|
||||||
|
|
||||||
|
constructor(options: PushDispatcherOptions) {
|
||||||
|
this.database = options.database;
|
||||||
|
this.vapidKeys = options.vapidKeys;
|
||||||
|
this.systemActorId = options.systemActorId;
|
||||||
|
this.fetchFn = options.fetchFn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save or update an employee's WebPush subscription.
|
||||||
|
*/
|
||||||
|
async saveSubscription(
|
||||||
|
tenantId: string,
|
||||||
|
employeeId: string,
|
||||||
|
input: PushSubscriptionInput,
|
||||||
|
): Promise<{ id: string; endpoint: string }> {
|
||||||
|
if (!input.endpoint.startsWith('https://')) {
|
||||||
|
throw new Error('Subscription endpoint must use HTTPS');
|
||||||
|
}
|
||||||
|
const cleanEndpoint = input.endpoint.trim();
|
||||||
|
const cleanP256dh = input.p256dh.trim();
|
||||||
|
const cleanAuth = input.auth.trim();
|
||||||
|
const userAgent = (input.user_agent ?? '').trim();
|
||||||
|
|
||||||
|
if (!cleanEndpoint || !cleanP256dh || !cleanAuth) {
|
||||||
|
throw new Error('Endpoint, p256dh, and auth are required for WebPush subscription');
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriptionId = newUlid();
|
||||||
|
const parsedTenantId = parseUlid(tenantId);
|
||||||
|
const parsedActorId = parseUlid(this.systemActorId);
|
||||||
|
const parsedEmployeeId = parseUlid(employeeId);
|
||||||
|
|
||||||
|
return this.database.tenantTransaction<{ id: string; endpoint: string }>(
|
||||||
|
parsedTenantId,
|
||||||
|
parsedActorId,
|
||||||
|
async (client) => {
|
||||||
|
const result = await client.query<{ id: string; endpoint: string }>(
|
||||||
|
`INSERT INTO staff_push_subscriptions
|
||||||
|
(id, tenant_id, employee_id, endpoint, p256dh, auth, user_agent, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
|
||||||
|
ON CONFLICT (tenant_id, endpoint)
|
||||||
|
DO UPDATE SET employee_id = EXCLUDED.employee_id,
|
||||||
|
p256dh = EXCLUDED.p256dh,
|
||||||
|
auth = EXCLUDED.auth,
|
||||||
|
user_agent = EXCLUDED.user_agent,
|
||||||
|
updated_at = now()
|
||||||
|
RETURNING id, endpoint`,
|
||||||
|
[
|
||||||
|
subscriptionId,
|
||||||
|
parsedTenantId,
|
||||||
|
parsedEmployeeId,
|
||||||
|
cleanEndpoint,
|
||||||
|
cleanP256dh,
|
||||||
|
cleanAuth,
|
||||||
|
userAgent,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return result.rows[0]!;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove an employee's WebPush subscription by endpoint.
|
||||||
|
*/
|
||||||
|
async deleteSubscription(tenantId: string, endpoint: string): Promise<boolean> {
|
||||||
|
const parsedTenantId = parseUlid(tenantId);
|
||||||
|
const parsedActorId = parseUlid(this.systemActorId);
|
||||||
|
|
||||||
|
return this.database.tenantTransaction<boolean>(
|
||||||
|
parsedTenantId,
|
||||||
|
parsedActorId,
|
||||||
|
async (client) => {
|
||||||
|
const result = await client.query(
|
||||||
|
`DELETE FROM staff_push_subscriptions
|
||||||
|
WHERE tenant_id = $1 AND endpoint = $2`,
|
||||||
|
[parsedTenantId, endpoint],
|
||||||
|
);
|
||||||
|
return (result.rowCount ?? 0) > 0;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatches push notifications for a specific transactional outbox record.
|
||||||
|
* Ensures idempotency: skips already delivered endpoints and marks outbox.published_at.
|
||||||
|
*/
|
||||||
|
async dispatchForOutbox(tenantId: string, outboxId: string): Promise<DispatchReport> {
|
||||||
|
const parsedTenantId = parseUlid(tenantId);
|
||||||
|
const parsedActorId = parseUlid(this.systemActorId);
|
||||||
|
const parsedOutboxId = parseUlid(outboxId);
|
||||||
|
|
||||||
|
const outboxRow = await this.database.tenantTransaction<OutboxRow | undefined>(
|
||||||
|
parsedTenantId,
|
||||||
|
parsedActorId,
|
||||||
|
async (client) => {
|
||||||
|
const result = await client.query<OutboxRow>(
|
||||||
|
`SELECT id, tenant_id, event_type, subject_id, payload, published_at, attempts
|
||||||
|
FROM staff_outbox
|
||||||
|
WHERE tenant_id = $1 AND id = $2`,
|
||||||
|
[parsedTenantId, parsedOutboxId],
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!outboxRow) {
|
||||||
|
return {
|
||||||
|
outboxId,
|
||||||
|
subscriptionsFound: 0,
|
||||||
|
deliveries: [],
|
||||||
|
published: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outboxRow.published_at) {
|
||||||
|
return {
|
||||||
|
outboxId,
|
||||||
|
subscriptionsFound: 0,
|
||||||
|
deliveries: [],
|
||||||
|
published: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const employeeId = outboxRow.subject_id;
|
||||||
|
const parsedEmployeeId = parseUlid(employeeId);
|
||||||
|
|
||||||
|
// Fetch active subscriptions for this employee
|
||||||
|
const subscriptions = await this.database.tenantTransaction<SubscriptionRow[]>(
|
||||||
|
parsedTenantId,
|
||||||
|
parsedActorId,
|
||||||
|
async (client) => {
|
||||||
|
const result = await client.query<SubscriptionRow>(
|
||||||
|
`SELECT id, endpoint, p256dh, auth, user_agent
|
||||||
|
FROM staff_push_subscriptions
|
||||||
|
WHERE tenant_id = $1 AND employee_id = $2`,
|
||||||
|
[parsedTenantId, parsedEmployeeId],
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// If no subscriptions exist for this employee, mark as published
|
||||||
|
if (subscriptions.length === 0) {
|
||||||
|
await this.database.tenantTransaction<void>(
|
||||||
|
parsedTenantId,
|
||||||
|
parsedActorId,
|
||||||
|
async (client) => {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE staff_outbox
|
||||||
|
SET published_at = now()
|
||||||
|
WHERE tenant_id = $1 AND id = $2 AND published_at IS NULL`,
|
||||||
|
[parsedTenantId, parsedOutboxId],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
outboxId,
|
||||||
|
subscriptionsFound: 0,
|
||||||
|
deliveries: [],
|
||||||
|
published: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neutral notification content according to Requirement 2:
|
||||||
|
// No employee names, no salary or personal data in the push text.
|
||||||
|
const notificationPayload = JSON.stringify({
|
||||||
|
title: 'Krona Staff',
|
||||||
|
body: 'Отметка рабочего времени зарегистрирована',
|
||||||
|
icon: '/staff/icon-192.svg',
|
||||||
|
badge: '/staff/icon-192.svg',
|
||||||
|
tag: 'attendance',
|
||||||
|
data: {
|
||||||
|
url: '/staff',
|
||||||
|
event_type: outboxRow.event_type,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deliverySummaries: DispatchDeliverySummary[] = [];
|
||||||
|
let deliveredCount = 0;
|
||||||
|
let failedCount = 0;
|
||||||
|
|
||||||
|
for (const sub of subscriptions) {
|
||||||
|
// 1. Idempotency check in staff_notification_deliveries
|
||||||
|
const deliveryRecord = await this.database.tenantTransaction<DeliveryRow | undefined>(
|
||||||
|
parsedTenantId,
|
||||||
|
parsedActorId,
|
||||||
|
async (client) => {
|
||||||
|
const deliveryId = newUlid();
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO staff_notification_deliveries
|
||||||
|
(id, tenant_id, employee_id, outbox_id, endpoint, status, attempts)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, 'pending', 0)
|
||||||
|
ON CONFLICT (tenant_id, outbox_id, endpoint) DO NOTHING`,
|
||||||
|
[deliveryId, parsedTenantId, parsedEmployeeId, parsedOutboxId, sub.endpoint],
|
||||||
|
);
|
||||||
|
const current = await client.query<DeliveryRow>(
|
||||||
|
`SELECT id, status, attempts
|
||||||
|
FROM staff_notification_deliveries
|
||||||
|
WHERE tenant_id = $1 AND outbox_id = $2 AND endpoint = $3`,
|
||||||
|
[parsedTenantId, parsedOutboxId, sub.endpoint],
|
||||||
|
);
|
||||||
|
return current.rows[0];
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!deliveryRecord) continue;
|
||||||
|
|
||||||
|
if (deliveryRecord.status === 'delivered') {
|
||||||
|
deliverySummaries.push({
|
||||||
|
endpoint: sub.endpoint,
|
||||||
|
status: 'skipped',
|
||||||
|
});
|
||||||
|
deliveredCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deliveryRecord.attempts >= 5) {
|
||||||
|
deliverySummaries.push({
|
||||||
|
endpoint: sub.endpoint,
|
||||||
|
status: 'skipped',
|
||||||
|
error: 'Max delivery attempts exceeded',
|
||||||
|
});
|
||||||
|
failedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Dispatch via WebPush
|
||||||
|
const target: PushSubscriptionTarget = {
|
||||||
|
endpoint: sub.endpoint,
|
||||||
|
keys: { p256dh: sub.p256dh, auth: sub.auth },
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendOptions: WebPushOptions = this.fetchFn ? { fetchFn: this.fetchFn } : {};
|
||||||
|
const result = await sendWebPush(target, notificationPayload, this.vapidKeys, sendOptions);
|
||||||
|
|
||||||
|
if (result.successful) {
|
||||||
|
await this.database.tenantTransaction<void>(
|
||||||
|
parsedTenantId,
|
||||||
|
parsedActorId,
|
||||||
|
async (client) => {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE staff_notification_deliveries
|
||||||
|
SET status = 'delivered', attempts = attempts + 1, delivered_at = now(),
|
||||||
|
last_error = NULL, updated_at = now()
|
||||||
|
WHERE tenant_id = $1 AND id = $2`,
|
||||||
|
[parsedTenantId, deliveryRecord.id],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
deliverySummaries.push({ endpoint: sub.endpoint, status: 'delivered' });
|
||||||
|
deliveredCount++;
|
||||||
|
} else {
|
||||||
|
await this.database.tenantTransaction<void>(
|
||||||
|
parsedTenantId,
|
||||||
|
parsedActorId,
|
||||||
|
async (client) => {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE staff_notification_deliveries
|
||||||
|
SET status = 'failed', attempts = attempts + 1,
|
||||||
|
last_error = $3, updated_at = now()
|
||||||
|
WHERE tenant_id = $1 AND id = $2`,
|
||||||
|
[parsedTenantId, deliveryRecord.id, result.error ?? 'Unknown delivery failure'],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Expired subscription cleanup on 404/410
|
||||||
|
if (result.shouldRemoveSubscription) {
|
||||||
|
await client.query(
|
||||||
|
`DELETE FROM staff_push_subscriptions
|
||||||
|
WHERE tenant_id = $1 AND endpoint = $2`,
|
||||||
|
[parsedTenantId, sub.endpoint],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
deliverySummaries.push({
|
||||||
|
endpoint: sub.endpoint,
|
||||||
|
status: 'failed',
|
||||||
|
error: result.error,
|
||||||
|
});
|
||||||
|
failedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Update staff_outbox status
|
||||||
|
const shouldMarkPublished = deliveredCount > 0 || (deliveredCount === 0 && failedCount === 0);
|
||||||
|
await this.database.tenantTransaction<void>(
|
||||||
|
parsedTenantId,
|
||||||
|
parsedActorId,
|
||||||
|
async (client) => {
|
||||||
|
if (shouldMarkPublished) {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE staff_outbox
|
||||||
|
SET published_at = now(), attempts = attempts + 1, last_error = NULL
|
||||||
|
WHERE tenant_id = $1 AND id = $2`,
|
||||||
|
[parsedTenantId, parsedOutboxId],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE staff_outbox
|
||||||
|
SET attempts = attempts + 1, last_error = 'Push notification delivery failed for all endpoints'
|
||||||
|
WHERE tenant_id = $1 AND id = $2`,
|
||||||
|
[parsedTenantId, parsedOutboxId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
outboxId,
|
||||||
|
subscriptionsFound: subscriptions.length,
|
||||||
|
deliveries: deliverySummaries,
|
||||||
|
published: shouldMarkPublished,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process all pending attendance outbox events.
|
||||||
|
*/
|
||||||
|
async dispatchPending(tenantId: string, limit = 20): Promise<DispatchReport[]> {
|
||||||
|
const parsedTenantId = parseUlid(tenantId);
|
||||||
|
const parsedActorId = parseUlid(this.systemActorId);
|
||||||
|
|
||||||
|
const pendingRows = await this.database.tenantTransaction<Array<{ id: string }>>(
|
||||||
|
parsedTenantId,
|
||||||
|
parsedActorId,
|
||||||
|
async (client) => {
|
||||||
|
const result = await client.query<{ id: string }>(
|
||||||
|
`SELECT id
|
||||||
|
FROM staff_outbox
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND published_at IS NULL
|
||||||
|
AND event_type IN ('attendance.clock.in', 'attendance.clock.out')
|
||||||
|
AND attempts < 5
|
||||||
|
ORDER BY occurred_at ASC
|
||||||
|
LIMIT $2`,
|
||||||
|
[parsedTenantId, limit],
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const reports: DispatchReport[] = [];
|
||||||
|
for (const row of pendingRows) {
|
||||||
|
const report = await this.dispatchForOutbox(tenantId, row.id);
|
||||||
|
reports.push(report);
|
||||||
|
}
|
||||||
|
return reports;
|
||||||
|
}
|
||||||
|
}
|
||||||
269
src/domain/notification/web-push.ts
Normal file
269
src/domain/notification/web-push.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
export interface VapidKeys {
|
||||||
|
publicKey: string; // base64url uncompressed P-256 (65 bytes)
|
||||||
|
privateKey: string; // base64url scalar D (32 bytes) or PEM
|
||||||
|
subject: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PushSubscriptionKeys {
|
||||||
|
p256dh: string; // base64url
|
||||||
|
auth: string; // base64url
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PushSubscriptionTarget {
|
||||||
|
endpoint: string;
|
||||||
|
keys: PushSubscriptionKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebPushSendResult {
|
||||||
|
statusCode: number;
|
||||||
|
successful: boolean;
|
||||||
|
shouldRemoveSubscription: boolean; // 404 Not Found or 410 Gone
|
||||||
|
error?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebPushOptions {
|
||||||
|
fetchFn?: typeof fetch | undefined;
|
||||||
|
ttl?: number | undefined;
|
||||||
|
urgency?: ('very-low' | 'low' | 'normal' | 'high') | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedEphemeralKeys: VapidKeys | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a fresh ECDSA P-256 key pair suitable for WebPush VAPID.
|
||||||
|
*/
|
||||||
|
export function generateVapidKeys(subject = 'mailto:admin@krona.local'): VapidKeys {
|
||||||
|
const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', {
|
||||||
|
namedCurve: 'prime256v1',
|
||||||
|
});
|
||||||
|
const pubJwk = publicKey.export({ format: 'jwk' });
|
||||||
|
const privJwk = privateKey.export({ format: 'jwk' });
|
||||||
|
|
||||||
|
if (!pubJwk.x || !pubJwk.y || !privJwk.d) {
|
||||||
|
throw new Error('Failed to generate EC P-256 key pair');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawPub = Buffer.concat([
|
||||||
|
Buffer.from([0x04]),
|
||||||
|
Buffer.from(pubJwk.x, 'base64url'),
|
||||||
|
Buffer.from(pubJwk.y, 'base64url'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
publicKey: rawPub.toString('base64url'),
|
||||||
|
privateKey: privJwk.d,
|
||||||
|
subject,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load VAPID keys from config or environment, or fallback to an in-memory ephemeral key pair.
|
||||||
|
*/
|
||||||
|
export function resolveVapidKeys(config: {
|
||||||
|
VAPID_PUBLIC_KEY?: string | undefined;
|
||||||
|
VAPID_PRIVATE_KEY?: string | undefined;
|
||||||
|
VAPID_SUBJECT?: string | undefined;
|
||||||
|
}): VapidKeys {
|
||||||
|
const subject = config.VAPID_SUBJECT || 'mailto:admin@krona.local';
|
||||||
|
|
||||||
|
if (config.VAPID_PUBLIC_KEY && config.VAPID_PRIVATE_KEY) {
|
||||||
|
return {
|
||||||
|
publicKey: config.VAPID_PUBLIC_KEY,
|
||||||
|
privateKey: config.VAPID_PRIVATE_KEY,
|
||||||
|
subject,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Boolean(config.VAPID_PUBLIC_KEY) !== Boolean(config.VAPID_PRIVATE_KEY)) {
|
||||||
|
throw new Error('VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be configured together');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safe ephemeral fallback in memory for dev/test
|
||||||
|
if (!cachedEphemeralKeys) {
|
||||||
|
cachedEphemeralKeys = generateVapidKeys(subject);
|
||||||
|
}
|
||||||
|
return cachedEphemeralKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert VAPID private key string to a KeyObject.
|
||||||
|
*/
|
||||||
|
function createVapidPrivateKeyObject(vapidKeys: VapidKeys): crypto.KeyObject {
|
||||||
|
const priv = vapidKeys.privateKey.trim();
|
||||||
|
if (priv.startsWith('-----BEGIN')) {
|
||||||
|
return crypto.createPrivateKey({ key: priv, format: 'pem' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw 32-byte base64url scalar D
|
||||||
|
const pubBuf = Buffer.from(vapidKeys.publicKey, 'base64url');
|
||||||
|
if (pubBuf.length !== 65 || pubBuf[0] !== 0x04) {
|
||||||
|
throw new Error('Invalid VAPID public key length or format');
|
||||||
|
}
|
||||||
|
const x = pubBuf.subarray(1, 33).toString('base64url');
|
||||||
|
const y = pubBuf.subarray(33, 65).toString('base64url');
|
||||||
|
|
||||||
|
return crypto.createPrivateKey({
|
||||||
|
key: {
|
||||||
|
kty: 'EC',
|
||||||
|
crv: 'P-256',
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
d: vapidKeys.privateKey,
|
||||||
|
},
|
||||||
|
format: 'jwk',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a VAPID Authorization header (RFC 8292).
|
||||||
|
*/
|
||||||
|
export function createVapidAuthorizationHeader(
|
||||||
|
endpoint: string,
|
||||||
|
vapidKeys: VapidKeys,
|
||||||
|
expirationSeconds = 12 * 3600,
|
||||||
|
): string {
|
||||||
|
const url = new URL(endpoint);
|
||||||
|
const audience = `${url.protocol}//${url.host}`;
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const exp = now + expirationSeconds;
|
||||||
|
|
||||||
|
const header = { typ: 'JWT', alg: 'ES256' };
|
||||||
|
const payload = { aud: audience, exp, sub: vapidKeys.subject };
|
||||||
|
|
||||||
|
const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url');
|
||||||
|
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||||
|
const signingInput = `${headerB64}.${payloadB64}`;
|
||||||
|
|
||||||
|
const privateKey = createVapidPrivateKeyObject(vapidKeys);
|
||||||
|
const signature = crypto.sign('SHA256', Buffer.from(signingInput, 'utf8'), {
|
||||||
|
key: privateKey,
|
||||||
|
dsaEncoding: 'ieee-p1363',
|
||||||
|
});
|
||||||
|
|
||||||
|
const jwt = `${signingInput}.${signature.toString('base64url')}`;
|
||||||
|
return `vapid t=${jwt}, k=${vapidKeys.publicKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt a payload using AES-128-GCM content encoding (RFC 8291 / RFC 8188).
|
||||||
|
*/
|
||||||
|
export function encryptPushPayload(
|
||||||
|
payload: string | Buffer,
|
||||||
|
keys: PushSubscriptionKeys,
|
||||||
|
): Buffer {
|
||||||
|
const plaintext = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, 'utf8');
|
||||||
|
const recipientP256dh = Buffer.from(keys.p256dh, 'base64url');
|
||||||
|
const recipientAuth = Buffer.from(keys.auth, 'base64url');
|
||||||
|
|
||||||
|
if (recipientP256dh.length !== 65 || recipientP256dh[0] !== 0x04) {
|
||||||
|
throw new Error('Invalid recipient p256dh key: expected 65-byte uncompressed point');
|
||||||
|
}
|
||||||
|
if (recipientAuth.length !== 16) {
|
||||||
|
throw new Error('Invalid recipient auth secret: expected 16 bytes');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Generate ephemeral sender key pair
|
||||||
|
const sender = crypto.createECDH('prime256v1');
|
||||||
|
sender.generateKeys();
|
||||||
|
const senderPublicKey = sender.getPublicKey(); // 65 bytes
|
||||||
|
const sharedSecret = sender.computeSecret(recipientP256dh);
|
||||||
|
|
||||||
|
// 2. Derive PRK via HKDF-Extract & Expand (RFC 8291 Section 3.2)
|
||||||
|
const keyInfo = Buffer.concat([
|
||||||
|
Buffer.from('WebPush: info\0', 'utf8'),
|
||||||
|
recipientP256dh,
|
||||||
|
senderPublicKey,
|
||||||
|
]);
|
||||||
|
const prk = Buffer.from(
|
||||||
|
crypto.hkdfSync('sha256', sharedSecret, recipientAuth, keyInfo, 32),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Generate 16-byte random salt
|
||||||
|
const salt = crypto.randomBytes(16);
|
||||||
|
|
||||||
|
// 4. Derive CEK and NONCE (RFC 8291 Section 3.3)
|
||||||
|
const cekInfo = Buffer.from('Content-Encoding: aes128gcm\0', 'utf8');
|
||||||
|
const cek = Buffer.from(crypto.hkdfSync('sha256', prk, salt, cekInfo, 16));
|
||||||
|
|
||||||
|
const nonceInfo = Buffer.from('Content-Encoding: nonce\0', 'utf8');
|
||||||
|
const nonce = Buffer.from(crypto.hkdfSync('sha256', prk, salt, nonceInfo, 12));
|
||||||
|
|
||||||
|
// 5. Append padding delimiter (0x02 for the final/only record in RFC 8188)
|
||||||
|
const record = Buffer.concat([plaintext, Buffer.from([0x02])]);
|
||||||
|
|
||||||
|
// 6. Encrypt with AES-128-GCM
|
||||||
|
const cipher = crypto.createCipheriv('aes-128-gcm', cek, nonce);
|
||||||
|
const ciphertext = Buffer.concat([cipher.update(record), cipher.final()]);
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
|
||||||
|
// 7. Build RFC 8188 header:
|
||||||
|
// salt (16) + rs (4, uint32 BE = 4096) + idlen (1, uint8 = 65) + keyid (65, senderPublicKey)
|
||||||
|
const rs = 4096;
|
||||||
|
const rsBuf = Buffer.alloc(4);
|
||||||
|
rsBuf.writeUInt32BE(rs, 0);
|
||||||
|
|
||||||
|
const header = Buffer.concat([
|
||||||
|
salt,
|
||||||
|
rsBuf,
|
||||||
|
Buffer.from([senderPublicKey.length]),
|
||||||
|
senderPublicKey,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Buffer.concat([header, ciphertext, tag]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an encrypted WebPush notification over HTTPS.
|
||||||
|
*/
|
||||||
|
export async function sendWebPush(
|
||||||
|
target: PushSubscriptionTarget,
|
||||||
|
payload: string,
|
||||||
|
vapidKeys: VapidKeys,
|
||||||
|
options?: WebPushOptions,
|
||||||
|
): Promise<WebPushSendResult> {
|
||||||
|
if (!target.endpoint.startsWith('https://')) {
|
||||||
|
throw new Error('Subscription endpoint must use HTTPS');
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptedBody = encryptPushPayload(payload, target.keys);
|
||||||
|
const authHeader = createVapidAuthorizationHeader(target.endpoint, vapidKeys);
|
||||||
|
const fetchImpl = options?.fetchFn ?? fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchImpl(target.endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': authHeader,
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
'Content-Encoding': 'aes128gcm',
|
||||||
|
'TTL': String(options?.ttl ?? 86400),
|
||||||
|
'Urgency': options?.urgency ?? 'normal',
|
||||||
|
},
|
||||||
|
body: new Uint8Array(encryptedBody),
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusCode = response.status;
|
||||||
|
if (statusCode >= 200 && statusCode < 300) {
|
||||||
|
return { statusCode, successful: true, shouldRemoveSubscription: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldRemoveSubscription = statusCode === 404 || statusCode === 410;
|
||||||
|
const bodyText = await response.text().catch(() => '');
|
||||||
|
return {
|
||||||
|
statusCode,
|
||||||
|
successful: false,
|
||||||
|
shouldRemoveSubscription,
|
||||||
|
error: `Push service rejected notification: HTTP ${statusCode}${bodyText ? ` (${bodyText.slice(0, 200)})` : ''}`,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
return {
|
||||||
|
statusCode: 0,
|
||||||
|
successful: false,
|
||||||
|
shouldRemoveSubscription: false,
|
||||||
|
error: `Network error delivering push: ${errorMsg}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue