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 in-memory.
  - `sendPushToUser()` honours `notificationsMuted` + per-channel prefs
    (orders/meetings/notes), prunes 404/410 endpoints, truncates payload
    bodies to ~3500 bytes so over-sized notes don't kill delivery.
  - `upsertSubscription()` deletes a stale row first when the same
    browser endpoint flips to a different user (account switch on shared
    device) so the previous user's notifications can't leak.
- Three new routes: `GET /api/push/vapid-public-key`, `POST /api/push/subscribe`,
  `POST /api/push/unsubscribe` (auth-gated, Zod-validated).
- Push hooked into the 4 existing emit sites: service-orders `notifyUser`,
  notes new-note + reply, executive-meeting broadcast.

Frontend
- `artifacts/tx-os/public/sw.js`: push + notificationclick handlers only
  (no asset caching). Focuses an existing tab or opens a new one at the
  payload URL.
- SW registration in `main.tsx` scoped to `BASE_URL`.
- `use-push-subscription` hook (enable/disable/refresh + status:
  unsupported / denied / default / subscribed).
- New `PushToggleRow` in `NotificationSettingsContent` with ar/en strings
  (notifSettings.push.*). iPad copy explains that the user must add to
  Home Screen first for iOS to allow Web Push.

Plumbing
- OpenAPI: 3 new operations under `notifications` tag; 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 generated
  key; subscribe endpoint 401s without auth as expected.
- Architect review surfaced two HIGH issues (account-switch leak,
  payload size); both fixed before completion.
This commit is contained in:
riyadhafraa
2026-05-16 15:26:18 +00:00
parent d91a68bc68
commit 243ccb3e00
21 changed files with 1266 additions and 0 deletions
@@ -1303,6 +1303,24 @@ export type DeleteServiceParams = {
force?: boolean;
};
export type GetPushVapidPublicKey200 = {
publicKey: string;
};
export type SubscribePushBodyKeys = {
p256dh: string;
auth: string;
};
export type SubscribePushBody = {
endpoint: string;
keys: SubscribePushBodyKeys;
};
export type UnsubscribePushBody = {
endpoint: string;
};
export type ListUsersParams = {
/**
* Free-text search across username, email, and display names
+250
View File
@@ -64,6 +64,7 @@ import type {
GetAdminUserDependentOrdersParams,
GetAppPermissionAuditParams,
GetGroupPermissionAuditParams,
GetPushVapidPublicKey200,
GetRolePermissionAuditParams,
GetUserPermissionAuditParams,
Group,
@@ -106,7 +107,9 @@ import type {
ServiceDeletionConflict,
ServiceDependentOrdersPage,
ServiceOrder,
SubscribePushBody,
SuccessResponse,
UnsubscribePushBody,
UpdateAppBody,
UpdateAppSettingsBody,
UpdateClockHour12Body,
@@ -3573,6 +3576,253 @@ export const useMarkAllNotificationsRead = <
return useMutation(getMarkAllNotificationsReadMutationOptions(options));
};
/**
* @summary Get the server's VAPID public key for Web Push subscriptions
*/
export const getGetPushVapidPublicKeyUrl = () => {
return `/api/push/vapid-public-key`;
};
export const getPushVapidPublicKey = async (
options?: RequestInit,
): Promise<GetPushVapidPublicKey200> => {
return customFetch<GetPushVapidPublicKey200>(getGetPushVapidPublicKeyUrl(), {
...options,
method: "GET",
});
};
export const getGetPushVapidPublicKeyQueryKey = () => {
return [`/api/push/vapid-public-key`] as const;
};
export const getGetPushVapidPublicKeyQueryOptions = <
TData = Awaited<ReturnType<typeof getPushVapidPublicKey>>,
TError = ErrorType<ErrorResponse>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getPushVapidPublicKey>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
}) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetPushVapidPublicKeyQueryKey();
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getPushVapidPublicKey>>
> = ({ signal }) => getPushVapidPublicKey({ signal, ...requestOptions });
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getPushVapidPublicKey>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetPushVapidPublicKeyQueryResult = NonNullable<
Awaited<ReturnType<typeof getPushVapidPublicKey>>
>;
export type GetPushVapidPublicKeyQueryError = ErrorType<ErrorResponse>;
/**
* @summary Get the server's VAPID public key for Web Push subscriptions
*/
export function useGetPushVapidPublicKey<
TData = Awaited<ReturnType<typeof getPushVapidPublicKey>>,
TError = ErrorType<ErrorResponse>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getPushVapidPublicKey>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetPushVapidPublicKeyQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Register a Web Push subscription for the current user
*/
export const getSubscribePushUrl = () => {
return `/api/push/subscribe`;
};
export const subscribePush = async (
subscribePushBody: SubscribePushBody,
options?: RequestInit,
): Promise<SuccessResponse> => {
return customFetch<SuccessResponse>(getSubscribePushUrl(), {
...options,
method: "POST",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(subscribePushBody),
});
};
export const getSubscribePushMutationOptions = <
TError = ErrorType<unknown>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof subscribePush>>,
TError,
{ data: BodyType<SubscribePushBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof subscribePush>>,
TError,
{ data: BodyType<SubscribePushBody> },
TContext
> => {
const mutationKey = ["subscribePush"];
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 subscribePush>>,
{ data: BodyType<SubscribePushBody> }
> = (props) => {
const { data } = props ?? {};
return subscribePush(data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type SubscribePushMutationResult = NonNullable<
Awaited<ReturnType<typeof subscribePush>>
>;
export type SubscribePushMutationBody = BodyType<SubscribePushBody>;
export type SubscribePushMutationError = ErrorType<unknown>;
/**
* @summary Register a Web Push subscription for the current user
*/
export const useSubscribePush = <
TError = ErrorType<unknown>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof subscribePush>>,
TError,
{ data: BodyType<SubscribePushBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof subscribePush>>,
TError,
{ data: BodyType<SubscribePushBody> },
TContext
> => {
return useMutation(getSubscribePushMutationOptions(options));
};
/**
* @summary Remove a Web Push subscription for the current user
*/
export const getUnsubscribePushUrl = () => {
return `/api/push/unsubscribe`;
};
export const unsubscribePush = async (
unsubscribePushBody: UnsubscribePushBody,
options?: RequestInit,
): Promise<SuccessResponse> => {
return customFetch<SuccessResponse>(getUnsubscribePushUrl(), {
...options,
method: "POST",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(unsubscribePushBody),
});
};
export const getUnsubscribePushMutationOptions = <
TError = ErrorType<unknown>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof unsubscribePush>>,
TError,
{ data: BodyType<UnsubscribePushBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof unsubscribePush>>,
TError,
{ data: BodyType<UnsubscribePushBody> },
TContext
> => {
const mutationKey = ["unsubscribePush"];
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 unsubscribePush>>,
{ data: BodyType<UnsubscribePushBody> }
> = (props) => {
const { data } = props ?? {};
return unsubscribePush(data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type UnsubscribePushMutationResult = NonNullable<
Awaited<ReturnType<typeof unsubscribePush>>
>;
export type UnsubscribePushMutationBody = BodyType<UnsubscribePushBody>;
export type UnsubscribePushMutationError = ErrorType<unknown>;
/**
* @summary Remove a Web Push subscription for the current user
*/
export const useUnsubscribePush = <
TError = ErrorType<unknown>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof unsubscribePush>>,
TError,
{ data: BodyType<UnsubscribePushBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof unsubscribePush>>,
TError,
{ data: BodyType<UnsubscribePushBody> },
TContext
> => {
return useMutation(getUnsubscribePushMutationOptions(options));
};
/**
* @summary List all users (admin)
*/