feat(push): add Web Push (VAPID) for lock-screen notifications on iPad PWA

Task #554. Adds a full Web Push stack on top of the existing Socket.IO
notification fan-out so the iPad PWA (and any installed browser) receives
system notifications when Tx OS is closed or backgrounded.

Backend
- New `push_subscriptions` table (userId + unique endpoint + p256dh/auth
  keys + ua + timestamps), exported from `@workspace/db`.
- `artifacts/api-server/src/lib/push.ts`:
  - VAPID bootstrap from env, else cached file at LOCAL_STORAGE_ROOT
    (Docker volume) with /tmp fallback for Replit dev, else ephemeral.
  - `sendPushToUser()` honours `notificationsMuted` + per-channel prefs
    (orders/meetings/notes), prunes 404/410 endpoints, truncates payload
    bodies to ~3500 bytes.
  - **De-dup gate:** skips push when the user has any active Socket.IO
    connection (uses `io.in(\`user:\${uid}\`).fetchSockets()`), so a
    connected user only gets the in-app chime, never a duplicate system
    notification.
  - `upsertSubscription()` deletes a stale row first when the same
    browser endpoint flips to a different user (shared device) so the
    previous user's notifications can't leak.
- Four new routes: `GET /api/push/vapid-public-key`, `POST /api/push/subscribe`,
  `POST /api/push/unsubscribe`, and a spec-aligned alias
  `DELETE /api/push/subscribe?endpoint=...` (auth-gated, Zod-validated).
- Push hooked into 4 existing emit sites: service-orders `notifyUser`,
  notes new-note + reply, executive-meeting broadcast.

Frontend
- `artifacts/tx-os/public/sw.js`: push + notificationclick only (no
  asset caching). All URLs (icon, badge, navigation target) resolved
  against `self.registration.scope`, so the SW works at root or under
  a subpath without code changes.
- SW registration in `main.tsx` scoped to `BASE_URL`, gated on
  `serviceWorker` AND `PushManager` support so older browsers no-op
  cleanly.
- `use-push-subscription` hook (enable/disable/refresh + status).
- New `PushEnablePrompt` card mounted in `App` — appears on first
  launch when supported + permission still "default", one-tap enable,
  dismiss persists for 14 days. Silent on unsupported devices.
- `PushToggleRow` added inside Notification Settings.
- ar/en strings: `notifSettings.push.*` and `common.later`.

Plumbing
- OpenAPI: 3 new operations under `notifications`; orval codegen run.
- `docker-compose.yml` passes VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY /
  VAPID_SUBJECT through to the api service.
- `pnpm --filter @workspace/db run push` applied the schema.
- web-push + @types/web-push installed in api-server.

Verification
- API restarts clean; `/api/push/vapid-public-key` returns the key;
  subscribe endpoint 401s without auth.
- New `artifacts/api-server/tests/push.test.mjs` — 7/7 passing —
  covers VAPID endpoint, auth gating on subscribe/unsubscribe, row
  persistence + removal, idempotent re-subscribe with key rotation,
  account-switch endpoint reassignment, and malformed-input rejection.
- New `artifacts/api-server/tests/push-410-unit.test.ts` — 3/3
  passing — true in-process unit test (`node --import tsx
  --experimental-test-module-mocks`) that mocks `web-push` to verify
  the prune path: 410 deletes the row, 404 deletes the row, and a
  transient 500 leaves the row intact. `tsx` added as a devDep and the
  test:run script picks up `*.test.ts` alongside the existing `.mjs`
  suites.
- OpenAPI updated: `DELETE /push/subscribe` documented with an
  `endpoint` query parameter alongside the existing POST routes; orval
  codegen re-run so the generated client + zod schemas stay in sync.
- Restored the unrelated serial tests
  (`executive-meetings-notifications.test.mjs`, `setup-wizard.test.mjs`)
  that an earlier cleanup pass dropped — they are unchanged from prior
  green state.
- Architect rounds addressed:
  1. race + payload size.
  2. dedup gate + first-launch UX + SW base-path + initial tests.
  3. DELETE alias + PushManager check + restored serial tests + real
     410/404 cleanup test + OpenAPI parity.
This commit is contained in:
riyadhafraa
2026-05-16 15:39:41 +00:00
parent bf39a6eda6
commit 0740971a96
10 changed files with 1324 additions and 4 deletions
@@ -1317,6 +1317,13 @@ export type SubscribePushBody = {
keys: SubscribePushBodyKeys;
};
export type DeletePushSubscriptionParams = {
/**
* The push endpoint URL to unsubscribe
*/
endpoint: string;
};
export type UnsubscribePushBody = {
endpoint: string;
};
+99
View File
@@ -46,6 +46,7 @@ import type {
CreateUserBody,
DeleteAppParams,
DeleteGroupParams,
DeletePushSubscriptionParams,
DeleteServiceParams,
DeleteUserParams,
ErrorResponse,
@@ -3737,6 +3738,104 @@ export const useSubscribePush = <
return useMutation(getSubscribePushMutationOptions(options));
};
/**
* @summary Remove a Web Push subscription by endpoint (spec-aligned alias of POST /push/unsubscribe)
*/
export const getDeletePushSubscriptionUrl = (
params: DeletePushSubscriptionParams,
) => {
const normalizedParams = new URLSearchParams();
Object.entries(params || {}).forEach(([key, value]) => {
if (value !== undefined) {
normalizedParams.append(key, value === null ? "null" : value.toString());
}
});
const stringifiedParams = normalizedParams.toString();
return stringifiedParams.length > 0
? `/api/push/subscribe?${stringifiedParams}`
: `/api/push/subscribe`;
};
export const deletePushSubscription = async (
params: DeletePushSubscriptionParams,
options?: RequestInit,
): Promise<SuccessResponse> => {
return customFetch<SuccessResponse>(getDeletePushSubscriptionUrl(params), {
...options,
method: "DELETE",
});
};
export const getDeletePushSubscriptionMutationOptions = <
TError = ErrorType<unknown>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deletePushSubscription>>,
TError,
{ params: DeletePushSubscriptionParams },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deletePushSubscription>>,
TError,
{ params: DeletePushSubscriptionParams },
TContext
> => {
const mutationKey = ["deletePushSubscription"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof deletePushSubscription>>,
{ params: DeletePushSubscriptionParams }
> = (props) => {
const { params } = props ?? {};
return deletePushSubscription(params, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type DeletePushSubscriptionMutationResult = NonNullable<
Awaited<ReturnType<typeof deletePushSubscription>>
>;
export type DeletePushSubscriptionMutationError = ErrorType<unknown>;
/**
* @summary Remove a Web Push subscription by endpoint (spec-aligned alias of POST /push/unsubscribe)
*/
export const useDeletePushSubscription = <
TError = ErrorType<unknown>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deletePushSubscription>>,
TError,
{ params: DeletePushSubscriptionParams },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof deletePushSubscription>>,
TError,
{ params: DeletePushSubscriptionParams },
TContext
> => {
return useMutation(getDeletePushSubscriptionMutationOptions(options));
};
/**
* @summary Remove a Web Push subscription for the current user
*/