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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -972,6 +972,83 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SuccessResponse"
|
||||
|
||||
/push/vapid-public-key:
|
||||
get:
|
||||
operationId: getPushVapidPublicKey
|
||||
tags: [notifications]
|
||||
summary: Get the server's VAPID public key for Web Push subscriptions
|
||||
responses:
|
||||
"200":
|
||||
description: VAPID public key
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
publicKey:
|
||||
type: string
|
||||
required: [publicKey]
|
||||
"503":
|
||||
description: Web Push not configured on this server
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/push/subscribe:
|
||||
post:
|
||||
operationId: subscribePush
|
||||
tags: [notifications]
|
||||
summary: Register a Web Push subscription for the current user
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
endpoint:
|
||||
type: string
|
||||
keys:
|
||||
type: object
|
||||
properties:
|
||||
p256dh:
|
||||
type: string
|
||||
auth:
|
||||
type: string
|
||||
required: [p256dh, auth]
|
||||
required: [endpoint, keys]
|
||||
responses:
|
||||
"200":
|
||||
description: Subscribed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SuccessResponse"
|
||||
|
||||
/push/unsubscribe:
|
||||
post:
|
||||
operationId: unsubscribePush
|
||||
tags: [notifications]
|
||||
summary: Remove a Web Push subscription for the current user
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
endpoint:
|
||||
type: string
|
||||
required: [endpoint]
|
||||
responses:
|
||||
"200":
|
||||
description: Unsubscribed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SuccessResponse"
|
||||
|
||||
# Users (admin)
|
||||
/users:
|
||||
get:
|
||||
|
||||
@@ -1458,6 +1458,39 @@ export const MarkAllNotificationsReadResponse = zod.object({
|
||||
success: zod.boolean(),
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary Get the server's VAPID public key for Web Push subscriptions
|
||||
*/
|
||||
export const GetPushVapidPublicKeyResponse = zod.object({
|
||||
publicKey: zod.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary Register a Web Push subscription for the current user
|
||||
*/
|
||||
export const SubscribePushBody = zod.object({
|
||||
endpoint: zod.string(),
|
||||
keys: zod.object({
|
||||
p256dh: zod.string(),
|
||||
auth: zod.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const SubscribePushResponse = zod.object({
|
||||
success: zod.boolean(),
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary Remove a Web Push subscription for the current user
|
||||
*/
|
||||
export const UnsubscribePushBody = zod.object({
|
||||
endpoint: zod.string(),
|
||||
});
|
||||
|
||||
export const UnsubscribePushResponse = zod.object({
|
||||
success: zod.boolean(),
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary List all users (admin)
|
||||
*/
|
||||
|
||||
@@ -14,3 +14,4 @@ export * from "./audit-logs";
|
||||
export * from "./role-audit";
|
||||
export * from "./permission-audit";
|
||||
export * from "./executive-meetings";
|
||||
export * from "./push-subscriptions";
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
pgTable,
|
||||
serial,
|
||||
integer,
|
||||
text,
|
||||
varchar,
|
||||
timestamp,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { usersTable } from "./users";
|
||||
|
||||
export const pushSubscriptionsTable = pgTable(
|
||||
"push_subscriptions",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
userId: integer("user_id")
|
||||
.notNull()
|
||||
.references(() => usersTable.id, { onDelete: "cascade" }),
|
||||
endpoint: text("endpoint").notNull().unique(),
|
||||
p256dh: text("p256dh").notNull(),
|
||||
auth: text("auth").notNull(),
|
||||
userAgent: varchar("user_agent", { length: 400 }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
lastUsedAt: timestamp("last_used_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
userIdx: index("push_subscriptions_user_idx").on(t.userId),
|
||||
}),
|
||||
);
|
||||
|
||||
export type PushSubscriptionRow = typeof pushSubscriptionsTable.$inferSelect;
|
||||
Reference in New Issue
Block a user