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:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user