fix(push): isolate employee data and preserve platform outbox
Some checks are pending
ci / verify (push) Waiting to run
Some checks are pending
ci / verify (push) Waiting to run
This commit is contained in:
parent
2f1bdf549d
commit
3e2054480c
18 changed files with 115 additions and 90 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
openapi: 3.1.0
|
openapi: 3.1.0
|
||||||
info:
|
info:
|
||||||
title: Krona Staff API
|
title: Krona Staff API
|
||||||
version: 0.3.1
|
version: 0.3.2
|
||||||
description: API посещаемости Krona Staff для кабинета сотрудника, терминала и табеля руководителя.
|
description: API посещаемости Krona Staff для кабинета сотрудника, терминала и табеля руководителя.
|
||||||
license:
|
license:
|
||||||
name: Proprietary
|
name: Proprietary
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@
|
||||||
<PlatformTarget>x64</PlatformTarget>
|
<PlatformTarget>x64</PlatformTarget>
|
||||||
<RuntimeIdentifier Condition="'$(RuntimeIdentifier)' == ''">win-x64</RuntimeIdentifier>
|
<RuntimeIdentifier Condition="'$(RuntimeIdentifier)' == ''">win-x64</RuntimeIdentifier>
|
||||||
<RootNamespace>KronaStaffService</RootNamespace>
|
<RootNamespace>KronaStaffService</RootNamespace>
|
||||||
<Version>0.3.1</Version>
|
<Version>0.3.2</Version>
|
||||||
<AssemblyVersion>0.3.1.0</AssemblyVersion>
|
<AssemblyVersion>0.3.2.0</AssemblyVersion>
|
||||||
<FileVersion>0.3.1.0</FileVersion>
|
<FileVersion>0.3.2.0</FileVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -258,7 +258,7 @@ public sealed class KronaStaffWorker : BackgroundService
|
||||||
return new IpcServiceStatus
|
return new IpcServiceStatus
|
||||||
{
|
{
|
||||||
ServiceRunning = true,
|
ServiceRunning = true,
|
||||||
ServiceVersion = "0.3.1",
|
ServiceVersion = "0.3.2",
|
||||||
ScannerInSession0Supported = _scannerSession0Supported,
|
ScannerInSession0Supported = _scannerSession0Supported,
|
||||||
ScannerDiagnostic = _scannerDiagnostic,
|
ScannerDiagnostic = _scannerDiagnostic,
|
||||||
PendingSyncCount = pendingCount,
|
PendingSyncCount = pendingCount,
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,10 @@
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<PlatformTarget>x64</PlatformTarget>
|
<PlatformTarget>x64</PlatformTarget>
|
||||||
<Version>0.3.1</Version>
|
<Version>0.3.2</Version>
|
||||||
<AssemblyVersion>0.3.1.0</AssemblyVersion>
|
<AssemblyVersion>0.3.2.0</AssemblyVersion>
|
||||||
<FileVersion>0.3.1.0</FileVersion>
|
<FileVersion>0.3.2.0</FileVersion>
|
||||||
<InformationalVersion>0.3.1</InformationalVersion>
|
<InformationalVersion>0.3.2</InformationalVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ public sealed class IpcResponse
|
||||||
public sealed class IpcServiceStatus
|
public sealed class IpcServiceStatus
|
||||||
{
|
{
|
||||||
public bool ServiceRunning { get; set; } = true;
|
public bool ServiceRunning { get; set; } = true;
|
||||||
public string ServiceVersion { get; set; } = "0.3.1";
|
public string ServiceVersion { get; set; } = "0.3.2";
|
||||||
public bool ScannerInSession0Supported { get; set; }
|
public bool ScannerInSession0Supported { get; set; }
|
||||||
public string ScannerDiagnostic { get; set; } = "";
|
public string ScannerDiagnostic { get; set; } = "";
|
||||||
public int PendingSyncCount { get; set; }
|
public int PendingSyncCount { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ services:
|
||||||
STANDALONE_TENANT_ID: ${STANDALONE_TENANT_ID}
|
STANDALONE_TENANT_ID: ${STANDALONE_TENANT_ID}
|
||||||
STANDALONE_ACTOR_ID: ${STANDALONE_ACTOR_ID}
|
STANDALONE_ACTOR_ID: ${STANDALONE_ACTOR_ID}
|
||||||
STANDALONE_TERMINAL_ACTOR_ID: ${STANDALONE_TERMINAL_ACTOR_ID}
|
STANDALONE_TERMINAL_ACTOR_ID: ${STANDALONE_TERMINAL_ACTOR_ID}
|
||||||
|
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
||||||
|
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
||||||
|
VAPID_SUBJECT: ${VAPID_SUBJECT}
|
||||||
DATABASE_URL: postgres://krona_staff_app:${POSTGRES_APP_PASSWORD}@postgres:5432/krona_staff
|
DATABASE_URL: postgres://krona_staff_app:${POSTGRES_APP_PASSWORD}@postgres:5432/krona_staff
|
||||||
HTTPS_PFX_PATH: /run/secrets/staff.pfx
|
HTTPS_PFX_PATH: /run/secrets/staff.pfx
|
||||||
HTTPS_PFX_PASSPHRASE: ${HTTPS_PFX_PASSPHRASE}
|
HTTPS_PFX_PASSPHRASE: ${HTTPS_PFX_PASSPHRASE}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,14 @@ if [ ! -f .env ]; then
|
||||||
printf '%s\n' "$terminal_token" > secrets/terminal.token
|
printf '%s\n' "$terminal_token" > secrets/terminal.token
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if ! grep -q '^VAPID_PUBLIC_KEY=' .env; then
|
||||||
|
docker run --rm \
|
||||||
|
-v "$(cd ../.. && pwd):/app:ro" \
|
||||||
|
-w /app \
|
||||||
|
node:22-alpine@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32 \
|
||||||
|
node scripts/generate-vapid-keys.mjs >> .env
|
||||||
|
fi
|
||||||
|
|
||||||
. ./.env
|
. ./.env
|
||||||
if [ ! -f secrets/staff.pfx ]; then
|
if [ ! -f secrets/staff.pfx ]; then
|
||||||
openssl req -x509 -newkey rsa:3072 -sha256 -days 365 -nodes \
|
openssl req -x509 -newkey rsa:3072 -sha256 -days 365 -nodes \
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ POST /api/v1/staff/attendance/events
|
||||||
| `Authorization` | String | Yes | Terminal Bearer API Key | `Bearer trm_sec_991823ab...` |
|
| `Authorization` | String | Yes | Terminal Bearer API Key | `Bearer trm_sec_991823ab...` |
|
||||||
| `Idempotency-Key` | String | Yes | Unique idempotency key per event batch or event | `6ba7b810-9dad-11d1-80b4-00c04fd430c8` |
|
| `Idempotency-Key` | String | Yes | Unique idempotency key per event batch or event | `6ba7b810-9dad-11d1-80b4-00c04fd430c8` |
|
||||||
| `X-Request-Id` | String | Yes | Unique trace ID for telemetry and crash debugging | `req_01HV8G7P89J21...` |
|
| `X-Request-Id` | String | Yes | Unique trace ID for telemetry and crash debugging | `req_01HV8G7P89J21...` |
|
||||||
| `User-Agent` | String | Yes | Terminal client identification | `KronaStaffTerminal/0.3.1 (Windows NT 10.0; Win64; x64)` |
|
| `User-Agent` | String | Yes | Terminal client identification | `KronaStaffTerminal/0.3.2 (Windows NT 10.0; Win64; x64)` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ SetCompressor /SOLID lzma
|
||||||
!include "x64.nsh"
|
!include "x64.nsh"
|
||||||
|
|
||||||
!ifndef APP_VERSION
|
!ifndef APP_VERSION
|
||||||
!define APP_VERSION "0.3.1"
|
!define APP_VERSION "0.3.2"
|
||||||
!endif
|
!endif
|
||||||
!ifndef PUBLISH_DIR
|
!ifndef PUBLISH_DIR
|
||||||
!error "PUBLISH_DIR is required"
|
!error "PUBLISH_DIR is required"
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"$schema": "./schemas/module.schema.json",
|
"$schema": "./schemas/module.schema.json",
|
||||||
"id": "ru.krona.staff",
|
"id": "ru.krona.staff",
|
||||||
"name": "Krona Staff",
|
"name": "Krona Staff",
|
||||||
"version": "0.3.1",
|
"version": "0.3.2",
|
||||||
"min_platform_version": "3.2.0",
|
"min_platform_version": "3.2.0",
|
||||||
"dependencies": ["ru.krona.core"],
|
"dependencies": ["ru.krona.core"],
|
||||||
"optional_dependencies": [],
|
"optional_dependencies": [],
|
||||||
|
|
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "@krona/staff",
|
"name": "@krona/staff",
|
||||||
"version": "0.3.1",
|
"version": "0.3.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@krona/staff",
|
"name": "@krona/staff",
|
||||||
"version": "0.3.1",
|
"version": "0.3.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/helmet": "^13.0.2",
|
"@fastify/helmet": "^13.0.2",
|
||||||
"@fastify/sensible": "^6.0.3",
|
"@fastify/sensible": "^6.0.3",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@krona/staff",
|
"name": "@krona/staff",
|
||||||
"version": "0.3.1",
|
"version": "0.3.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
|
||||||
21
scripts/generate-vapid-keys.mjs
Normal file
21
scripts/generate-vapid-keys.mjs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', {
|
||||||
|
namedCurve: 'prime256v1',
|
||||||
|
});
|
||||||
|
const publicJwk = publicKey.export({ format: 'jwk' });
|
||||||
|
const privateJwk = privateKey.export({ format: 'jwk' });
|
||||||
|
|
||||||
|
if (!publicJwk.x || !publicJwk.y || !privateJwk.d) {
|
||||||
|
throw new Error('Failed to generate VAPID P-256 key pair');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawPublicKey = Buffer.concat([
|
||||||
|
Buffer.from([0x04]),
|
||||||
|
Buffer.from(publicJwk.x, 'base64url'),
|
||||||
|
Buffer.from(publicJwk.y, 'base64url'),
|
||||||
|
]).toString('base64url');
|
||||||
|
|
||||||
|
process.stdout.write(
|
||||||
|
`VAPID_PUBLIC_KEY=${rawPublicKey}\nVAPID_PRIVATE_KEY=${privateJwk.d}\nVAPID_SUBJECT=mailto:admin@krona.local\n`,
|
||||||
|
);
|
||||||
|
|
@ -40,8 +40,6 @@ interface OutboxRow {
|
||||||
event_type: string;
|
event_type: string;
|
||||||
subject_id: string;
|
subject_id: string;
|
||||||
payload: unknown;
|
payload: unknown;
|
||||||
published_at: string | null;
|
|
||||||
attempts: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SubscriptionRow {
|
interface SubscriptionRow {
|
||||||
|
|
@ -129,9 +127,14 @@ export class PushDispatcher {
|
||||||
/**
|
/**
|
||||||
* Remove an employee's WebPush subscription by endpoint.
|
* Remove an employee's WebPush subscription by endpoint.
|
||||||
*/
|
*/
|
||||||
async deleteSubscription(tenantId: string, endpoint: string): Promise<boolean> {
|
async deleteSubscription(
|
||||||
|
tenantId: string,
|
||||||
|
employeeId: string,
|
||||||
|
endpoint: string,
|
||||||
|
): Promise<boolean> {
|
||||||
const parsedTenantId = parseUlid(tenantId);
|
const parsedTenantId = parseUlid(tenantId);
|
||||||
const parsedActorId = parseUlid(this.systemActorId);
|
const parsedActorId = parseUlid(this.systemActorId);
|
||||||
|
const parsedEmployeeId = parseUlid(employeeId);
|
||||||
|
|
||||||
return this.database.tenantTransaction<boolean>(
|
return this.database.tenantTransaction<boolean>(
|
||||||
parsedTenantId,
|
parsedTenantId,
|
||||||
|
|
@ -139,8 +142,8 @@ export class PushDispatcher {
|
||||||
async (client) => {
|
async (client) => {
|
||||||
const result = await client.query(
|
const result = await client.query(
|
||||||
`DELETE FROM staff_push_subscriptions
|
`DELETE FROM staff_push_subscriptions
|
||||||
WHERE tenant_id = $1 AND endpoint = $2`,
|
WHERE tenant_id = $1 AND employee_id = $2 AND endpoint = $3`,
|
||||||
[parsedTenantId, endpoint],
|
[parsedTenantId, parsedEmployeeId, endpoint],
|
||||||
);
|
);
|
||||||
return (result.rowCount ?? 0) > 0;
|
return (result.rowCount ?? 0) > 0;
|
||||||
},
|
},
|
||||||
|
|
@ -149,7 +152,7 @@ export class PushDispatcher {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dispatches push notifications for a specific transactional outbox record.
|
* Dispatches push notifications for a specific transactional outbox record.
|
||||||
* Ensures idempotency: skips already delivered endpoints and marks outbox.published_at.
|
* Ensures notification idempotency without changing the platform outbox publication state.
|
||||||
*/
|
*/
|
||||||
async dispatchForOutbox(tenantId: string, outboxId: string): Promise<DispatchReport> {
|
async dispatchForOutbox(tenantId: string, outboxId: string): Promise<DispatchReport> {
|
||||||
const parsedTenantId = parseUlid(tenantId);
|
const parsedTenantId = parseUlid(tenantId);
|
||||||
|
|
@ -161,7 +164,7 @@ export class PushDispatcher {
|
||||||
parsedActorId,
|
parsedActorId,
|
||||||
async (client) => {
|
async (client) => {
|
||||||
const result = await client.query<OutboxRow>(
|
const result = await client.query<OutboxRow>(
|
||||||
`SELECT id, tenant_id, event_type, subject_id, payload, published_at, attempts
|
`SELECT id, tenant_id, event_type, subject_id, payload
|
||||||
FROM staff_outbox
|
FROM staff_outbox
|
||||||
WHERE tenant_id = $1 AND id = $2`,
|
WHERE tenant_id = $1 AND id = $2`,
|
||||||
[parsedTenantId, parsedOutboxId],
|
[parsedTenantId, parsedOutboxId],
|
||||||
|
|
@ -179,15 +182,6 @@ export class PushDispatcher {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (outboxRow.published_at) {
|
|
||||||
return {
|
|
||||||
outboxId,
|
|
||||||
subscriptionsFound: 0,
|
|
||||||
deliveries: [],
|
|
||||||
published: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const employeeId = outboxRow.subject_id;
|
const employeeId = outboxRow.subject_id;
|
||||||
const parsedEmployeeId = parseUlid(employeeId);
|
const parsedEmployeeId = parseUlid(employeeId);
|
||||||
|
|
||||||
|
|
@ -206,20 +200,8 @@ export class PushDispatcher {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// If no subscriptions exist for this employee, mark as published
|
// There is nothing to notify. The platform outbox remains available to its publisher.
|
||||||
if (subscriptions.length === 0) {
|
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 {
|
return {
|
||||||
outboxId,
|
outboxId,
|
||||||
subscriptionsFound: 0,
|
subscriptionsFound: 0,
|
||||||
|
|
@ -348,29 +330,9 @@ export class PushDispatcher {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Update staff_outbox status
|
// The delivery table owns notification progress. staff_outbox.published_at is reserved
|
||||||
|
// for the platform event publisher and must not be consumed by the push adapter.
|
||||||
const shouldMarkPublished = deliveredCount > 0 || (deliveredCount === 0 && failedCount === 0);
|
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 {
|
return {
|
||||||
outboxId,
|
outboxId,
|
||||||
|
|
@ -392,13 +354,18 @@ export class PushDispatcher {
|
||||||
parsedActorId,
|
parsedActorId,
|
||||||
async (client) => {
|
async (client) => {
|
||||||
const result = await client.query<{ id: string }>(
|
const result = await client.query<{ id: string }>(
|
||||||
`SELECT id
|
`SELECT DISTINCT o.id
|
||||||
FROM staff_outbox
|
FROM staff_outbox o
|
||||||
WHERE tenant_id = $1
|
JOIN staff_push_subscriptions s
|
||||||
AND published_at IS NULL
|
ON s.tenant_id = o.tenant_id AND s.employee_id = o.subject_id
|
||||||
AND event_type IN ('attendance.clock.in', 'attendance.clock.out')
|
LEFT JOIN staff_notification_deliveries d
|
||||||
AND attempts < 5
|
ON d.tenant_id = o.tenant_id
|
||||||
ORDER BY occurred_at ASC
|
AND d.outbox_id = o.id
|
||||||
|
AND d.endpoint = s.endpoint
|
||||||
|
WHERE o.tenant_id = $1
|
||||||
|
AND o.event_type IN ('attendance.clock.in', 'attendance.clock.out')
|
||||||
|
AND (d.id IS NULL OR (d.status = 'failed' AND d.attempts < 5))
|
||||||
|
ORDER BY o.id ASC
|
||||||
LIMIT $2`,
|
LIMIT $2`,
|
||||||
[parsedTenantId, limit],
|
[parsedTenantId, limit],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -448,10 +448,10 @@ self.addEventListener('activate',e=>e.waitUntil(caches.keys().then(keys=>Promise
|
||||||
self.addEventListener('fetch',e=>{
|
self.addEventListener('fetch',e=>{
|
||||||
if(e.request.method==='GET'){
|
if(e.request.method==='GET'){
|
||||||
const url=new URL(e.request.url);
|
const url=new URL(e.request.url);
|
||||||
if(url.pathname.startsWith('/api/v1/staff/')){
|
// Authenticated API responses can contain attendance and payroll data. They must never
|
||||||
e.respondWith(fetch(e.request).then(r=>{if(r.ok){const copy=r.clone();caches.open(CACHE).then(c=>c.put(e.request,copy));}return r;}).catch(()=>caches.match(e.request)));
|
// enter the shared service-worker cache; offline personal data needs a user-bound,
|
||||||
return;
|
// encrypted store with an explicit retention policy.
|
||||||
}
|
if(url.pathname.startsWith('/api/v1/staff/'))return;
|
||||||
if(url.pathname.startsWith('/staff')){
|
if(url.pathname.startsWith('/staff')){
|
||||||
e.respondWith(fetch(e.request).then(r=>{const copy=r.clone();caches.open(CACHE).then(c=>c.put(e.request,copy));return r;}).catch(()=>caches.match(e.request)));
|
e.respondWith(fetch(e.request).then(r=>{const copy=r.clone();caches.open(CACHE).then(c=>c.put(e.request,copy));return r;}).catch(()=>caches.match(e.request)));
|
||||||
}
|
}
|
||||||
|
|
@ -469,4 +469,3 @@ self.addEventListener('notificationclick',e=>{
|
||||||
const url=(e.notification.data&&e.notification.data.url)||'/staff';
|
const url=(e.notification.data&&e.notification.data.url)||'/staff';
|
||||||
e.waitUntil(clients.matchAll({type:'window',includeUncontrolled:true}).then(wList=>{for(const c of wList){if(c.url.includes('/staff')&&'focus' in c)return c.focus();}if(clients.openWindow)return clients.openWindow(url);}));
|
e.waitUntil(clients.matchAll({type:'window',includeUncontrolled:true}).then(wList=>{for(const c of wList){if(c.url.includes('/staff')&&'focus' in c)return c.focus();}if(clients.openWindow)return clients.openWindow(url);}));
|
||||||
});`;
|
});`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -208,7 +208,27 @@ export async function registerPortalRoutes(
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
assertCookieMutationOrigin(request);
|
assertCookieMutationOrigin(request);
|
||||||
const context = await resolveContext(identity, request);
|
const context = await resolveContext(identity, request);
|
||||||
await pushDispatcher.deleteSubscription(context.tenantId, request.body.endpoint);
|
const employee = await database.tenantTransaction(
|
||||||
|
context.tenantId,
|
||||||
|
context.actorId,
|
||||||
|
async (client) => {
|
||||||
|
const res = await client.query<{ id: string }>(
|
||||||
|
`SELECT id FROM staff_employees
|
||||||
|
WHERE tenant_id = $1 AND (core_user_id = $2 OR id = $2) AND status = 'active'
|
||||||
|
LIMIT 1`,
|
||||||
|
[context.tenantId, context.actorId],
|
||||||
|
);
|
||||||
|
return res.rows[0];
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!employee) {
|
||||||
|
return reply.notFound('Сотрудник для текущего пользователя не найден.');
|
||||||
|
}
|
||||||
|
await pushDispatcher.deleteSubscription(
|
||||||
|
context.tenantId,
|
||||||
|
employee.id,
|
||||||
|
request.body.endpoint,
|
||||||
|
);
|
||||||
return reply.code(204).send();
|
return reply.code(204).send();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
export const SERVICE_NAME = 'krona-staff';
|
export const SERVICE_NAME = 'krona-staff';
|
||||||
export const SERVICE_VERSION = '0.3.1';
|
export const SERVICE_VERSION = '0.3.2';
|
||||||
|
|
|
||||||
|
|
@ -288,7 +288,7 @@ describe('PushDispatcher idempotent outbox handling', () => {
|
||||||
).rejects.toThrow(/Subscription endpoint must use HTTPS/);
|
).rejects.toThrow(/Subscription endpoint must use HTTPS/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks outbox published when employee has no subscriptions', async () => {
|
it('leaves the platform outbox untouched when employee has no subscriptions', async () => {
|
||||||
const { database, queries } = mockDb([
|
const { database, queries } = mockDb([
|
||||||
// 1. SELECT staff_outbox
|
// 1. SELECT staff_outbox
|
||||||
[
|
[
|
||||||
|
|
@ -304,8 +304,6 @@ describe('PushDispatcher idempotent outbox handling', () => {
|
||||||
],
|
],
|
||||||
// 2. SELECT staff_push_subscriptions (empty)
|
// 2. SELECT staff_push_subscriptions (empty)
|
||||||
[],
|
[],
|
||||||
// 3. UPDATE staff_outbox SET published_at = now()
|
|
||||||
[],
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const dispatcher = new PushDispatcher({ database, vapidKeys, systemActorId });
|
const dispatcher = new PushDispatcher({ database, vapidKeys, systemActorId });
|
||||||
|
|
@ -313,8 +311,19 @@ describe('PushDispatcher idempotent outbox handling', () => {
|
||||||
|
|
||||||
expect(report.published).toBe(true);
|
expect(report.published).toBe(true);
|
||||||
expect(report.subscriptionsFound).toBe(0);
|
expect(report.subscriptionsFound).toBe(0);
|
||||||
expect(queries[2]?.sql).toContain('UPDATE staff_outbox');
|
expect(queries).toHaveLength(2);
|
||||||
expect(queries[2]?.sql).toContain('published_at = now()');
|
expect(queries.some((q) => q.sql.includes('UPDATE staff_outbox'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes only a subscription owned by the current employee', async () => {
|
||||||
|
const endpoint = 'https://push.example.com/sub/current-device';
|
||||||
|
const { database, queries } = mockDb([[{ id: newUlid() }]]);
|
||||||
|
const dispatcher = new PushDispatcher({ database, vapidKeys, systemActorId });
|
||||||
|
|
||||||
|
await dispatcher.deleteSubscription(tenantId, employeeId, endpoint);
|
||||||
|
|
||||||
|
expect(queries[0]?.sql).toContain('employee_id = $2');
|
||||||
|
expect(queries[0]?.params).toEqual([tenantId, employeeId, endpoint]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('delivers push idempotently and avoids duplicate delivery', async () => {
|
it('delivers push idempotently and avoids duplicate delivery', async () => {
|
||||||
|
|
@ -351,8 +360,6 @@ describe('PushDispatcher idempotent outbox handling', () => {
|
||||||
[{ id: newUlid(), status: 'pending', attempts: 0 }],
|
[{ id: newUlid(), status: 'pending', attempts: 0 }],
|
||||||
// 5. UPDATE delivery to delivered
|
// 5. UPDATE delivery to delivered
|
||||||
[],
|
[],
|
||||||
// 6. UPDATE outbox to published
|
|
||||||
[],
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const dispatcher = new PushDispatcher({
|
const dispatcher = new PushDispatcher({
|
||||||
|
|
@ -369,9 +376,9 @@ describe('PushDispatcher idempotent outbox handling', () => {
|
||||||
expect(report.deliveries[0]?.status).toBe('delivered');
|
expect(report.deliveries[0]?.status).toBe('delivered');
|
||||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
// Verify outbox and delivery updates
|
// Push delivery is tracked independently from platform event publication.
|
||||||
expect(queries.some((q) => q.sql.includes('staff_notification_deliveries') && q.sql.includes('delivered'))).toBe(true);
|
expect(queries.some((q) => q.sql.includes('staff_notification_deliveries') && q.sql.includes('delivered'))).toBe(true);
|
||||||
expect(queries.some((q) => q.sql.includes('staff_outbox') && q.sql.includes('published_at = now()'))).toBe(true);
|
expect(queries.some((q) => q.sql.includes('UPDATE staff_outbox'))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips sending if endpoint was already delivered (idempotency check)', async () => {
|
it('skips sending if endpoint was already delivered (idempotency check)', async () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue