diff --git a/clients/mobile/app.json b/clients/mobile/app.json index f169dd454..e62e935a4 100644 --- a/clients/mobile/app.json +++ b/clients/mobile/app.json @@ -40,7 +40,15 @@ } } ], - "expo-secure-store" + "expo-secure-store", + [ + "expo-notifications", + { + "icon": "./assets/images/icon.png", + "color": "#ffffff", + "sounds": [] + } + ] ], "experiments": { "typedRoutes": true, diff --git a/clients/mobile/app/_layout.tsx b/clients/mobile/app/_layout.tsx index 44af1c301..e720522b0 100644 --- a/clients/mobile/app/_layout.tsx +++ b/clients/mobile/app/_layout.tsx @@ -14,6 +14,7 @@ import { tokenCache } from "@clerk/clerk-expo/token-cache"; import { ClerkProvider, ClerkLoaded, useAuth } from "@clerk/clerk-expo"; import { useColorScheme } from "@/hooks/use-color-scheme"; import { setConfig } from "@shared"; +import { usePushNotifications } from "@/hooks/use-push-notifications"; // Client explicity created outside component to avoid recreation const queryClient = new QueryClient({ @@ -40,6 +41,13 @@ function AppConfigurator() { return null; } +// Registers the Expo push token with the backend and wires up tap-to-navigate. +// Must render inside QueryClientProvider so useMutation is available. +function PushNotificationRegistrar() { + usePushNotifications(); + return null; +} + export default function RootLayout() { const colorScheme = useColorScheme(); @@ -51,6 +59,7 @@ export default function RootLayout() { + ({ + setNotificationHandler: jest.fn(), + getPermissionsAsync: jest.fn(), + requestPermissionsAsync: jest.fn(), + getExpoPushTokenAsync: jest.fn(), + setNotificationChannelAsync: jest.fn(), + addNotificationResponseReceivedListener: jest.fn(), + AndroidImportance: { MAX: 5 }, +})); + +const mockRouterPush = jest.fn(); +jest.mock("expo-router", () => ({ + useRouter: () => ({ push: mockRouterPush }), +})); + +const mockRegisterToken = jest.fn(); +jest.mock("@shared/api/notifications", () => ({ + usePostDeviceToken: () => ({ mutate: mockRegisterToken }), +})); + +import { + usePushNotifications, + registerForPushNotificationsAsync, +} from "../use-push-notifications"; + +const Notifications = require("expo-notifications"); + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); +}; + +describe("registerForPushNotificationsAsync", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("requests permissions and registers an iOS token", async () => { + Notifications.getPermissionsAsync.mockResolvedValue({ status: "granted" }); + Notifications.getExpoPushTokenAsync.mockResolvedValue({ + data: "ExponentPushToken[xxx]", + }); + + await registerForPushNotificationsAsync(mockRegisterToken, { + isDevice: true, + platformOS: "ios", + projectId: "test-project-id", + }); + + expect(Notifications.getPermissionsAsync).toHaveBeenCalled(); + expect(Notifications.getExpoPushTokenAsync).toHaveBeenCalledWith({ + projectId: "test-project-id", + }); + expect(mockRegisterToken).toHaveBeenCalledWith({ + token: "ExponentPushToken[xxx]", + platform: "ios", + }); + }); + + it("calls requestPermissionsAsync when status is undetermined and grants", async () => { + Notifications.getPermissionsAsync.mockResolvedValue({ + status: "undetermined", + }); + Notifications.requestPermissionsAsync.mockResolvedValue({ + status: "granted", + }); + Notifications.getExpoPushTokenAsync.mockResolvedValue({ + data: "ExponentPushToken[yyy]", + }); + + await registerForPushNotificationsAsync(mockRegisterToken, { + isDevice: true, + platformOS: "ios", + projectId: "test-project-id", + }); + + expect(Notifications.requestPermissionsAsync).toHaveBeenCalled(); + expect(mockRegisterToken).toHaveBeenCalledWith({ + token: "ExponentPushToken[yyy]", + platform: "ios", + }); + }); + + it("does not register token when permissions are denied", async () => { + Notifications.getPermissionsAsync.mockResolvedValue({ + status: "undetermined", + }); + Notifications.requestPermissionsAsync.mockResolvedValue({ + status: "denied", + }); + + await registerForPushNotificationsAsync(mockRegisterToken, { + isDevice: true, + platformOS: "ios", + projectId: "test-project-id", + }); + + expect(mockRegisterToken).not.toHaveBeenCalled(); + }); + + it("does not request permissions on a non-physical device (simulator)", async () => { + await registerForPushNotificationsAsync(mockRegisterToken, { + isDevice: false, + platformOS: "ios", + projectId: "test-project-id", + }); + + expect(Notifications.getPermissionsAsync).not.toHaveBeenCalled(); + expect(mockRegisterToken).not.toHaveBeenCalled(); + }); + + it("creates an Android notification channel before requesting permissions", async () => { + Notifications.getPermissionsAsync.mockResolvedValue({ status: "granted" }); + Notifications.getExpoPushTokenAsync.mockResolvedValue({ + data: "ExponentPushToken[android]", + }); + + await registerForPushNotificationsAsync(mockRegisterToken, { + isDevice: true, + platformOS: "android", + projectId: "test-project-id", + }); + + expect(Notifications.setNotificationChannelAsync).toHaveBeenCalledWith( + "default", + expect.objectContaining({ importance: 5 }), + ); + expect(mockRegisterToken).toHaveBeenCalledWith({ + token: "ExponentPushToken[android]", + platform: "android", + }); + }); + + it("does not register token when projectId is missing", async () => { + Notifications.getPermissionsAsync.mockResolvedValue({ status: "granted" }); + + await registerForPushNotificationsAsync(mockRegisterToken, { + isDevice: true, + platformOS: "ios", + projectId: undefined, + }); + + expect(mockRegisterToken).not.toHaveBeenCalled(); + }); + + it("silently handles errors from getExpoPushTokenAsync", async () => { + Notifications.getPermissionsAsync.mockResolvedValue({ status: "granted" }); + Notifications.getExpoPushTokenAsync.mockRejectedValue( + new Error("token fetch failed"), + ); + + await expect( + registerForPushNotificationsAsync(mockRegisterToken, { + isDevice: true, + platformOS: "ios", + projectId: "test-project-id", + }), + ).resolves.toBeUndefined(); + + expect(mockRegisterToken).not.toHaveBeenCalled(); + }); +}); + +describe("usePushNotifications (notification response listener)", () => { + beforeEach(() => { + jest.clearAllMocks(); + Notifications.addNotificationResponseReceivedListener.mockReturnValue({ + remove: jest.fn(), + }); + }); + + it("navigates to tasks tab when a task_assigned notification is tapped", () => { + let capturedListener: ((response: unknown) => void) | null = null; + Notifications.addNotificationResponseReceivedListener.mockImplementation( + (fn: (response: unknown) => void) => { + capturedListener = fn; + return { remove: jest.fn() }; + }, + ); + + renderHook(() => usePushNotifications(), { wrapper: createWrapper() }); + + act(() => { + capturedListener?.({ + notification: { + request: { content: { data: { type: "task_assigned" } } }, + }, + }); + }); + + expect(mockRouterPush).toHaveBeenCalledWith("/(tabs)/tasks"); + }); + + it("navigates to tasks tab for high_priority_task notifications", () => { + let capturedListener: ((response: unknown) => void) | null = null; + Notifications.addNotificationResponseReceivedListener.mockImplementation( + (fn: (response: unknown) => void) => { + capturedListener = fn; + return { remove: jest.fn() }; + }, + ); + + renderHook(() => usePushNotifications(), { wrapper: createWrapper() }); + + act(() => { + capturedListener?.({ + notification: { + request: { content: { data: { type: "high_priority_task" } } }, + }, + }); + }); + + expect(mockRouterPush).toHaveBeenCalledWith("/(tabs)/tasks"); + }); + + it("does not navigate for unknown notification types", () => { + let capturedListener: ((response: unknown) => void) | null = null; + Notifications.addNotificationResponseReceivedListener.mockImplementation( + (fn: (response: unknown) => void) => { + capturedListener = fn; + return { remove: jest.fn() }; + }, + ); + + renderHook(() => usePushNotifications(), { wrapper: createWrapper() }); + + act(() => { + capturedListener?.({ + notification: { + request: { content: { data: { type: "some_other_event" } } }, + }, + }); + }); + + expect(mockRouterPush).not.toHaveBeenCalled(); + }); + + it("removes the response listener on unmount", () => { + const mockRemove = jest.fn(); + Notifications.addNotificationResponseReceivedListener.mockReturnValue({ + remove: mockRemove, + }); + + const { unmount } = renderHook(() => usePushNotifications(), { + wrapper: createWrapper(), + }); + unmount(); + + expect(mockRemove).toHaveBeenCalled(); + }); +}); diff --git a/clients/mobile/hooks/use-push-notifications.ts b/clients/mobile/hooks/use-push-notifications.ts new file mode 100644 index 000000000..5fce3693e --- /dev/null +++ b/clients/mobile/hooks/use-push-notifications.ts @@ -0,0 +1,95 @@ +import { useEffect, useRef } from "react"; +import { Platform } from "react-native"; +import * as Device from "expo-device"; +import * as Notifications from "expo-notifications"; +import Constants from "expo-constants"; +import { useRouter } from "expo-router"; +import { usePostDeviceToken } from "@shared/api/notifications"; +import type { RegisterDeviceTokenInput } from "@shared/types/notifications"; + +Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowAlert: true, + shouldShowBanner: true, + shouldShowList: true, + shouldPlaySound: true, + shouldSetBadge: true, + }), +}); + +export const usePushNotifications = () => { + const router = useRouter(); + const { mutate: registerToken } = usePostDeviceToken(); + const responseListener = useRef(null); + + useEffect(() => { + registerForPushNotificationsAsync(registerToken); + + responseListener.current = + Notifications.addNotificationResponseReceivedListener((response) => { + const data = response.notification.request.content.data; + const type = data?.type as string | undefined; + if (type === "task_assigned" || type === "high_priority_task") { + router.push("/(tabs)/tasks"); + } + }); + + return () => { + responseListener.current?.remove(); + }; + }, [registerToken, router]); +}; + +/** + * Requests push notification permissions, retrieves the Expo push token, + * and registers it with the backend. Exported for unit testing. + */ +export async function registerForPushNotificationsAsync( + registerToken: (input: RegisterDeviceTokenInput) => void, + deps = { + isDevice: Device.isDevice, + platformOS: Platform.OS, + projectId: Constants.expoConfig?.extra?.eas?.projectId as + | string + | undefined, + }, +): Promise { + if (!deps.isDevice) { + return; + } + + if (deps.platformOS === "android") { + await Notifications.setNotificationChannelAsync("default", { + name: "default", + importance: Notifications.AndroidImportance.MAX, + vibrationPattern: [0, 250, 250, 250], + lightColor: "#FF231F7C", + }); + } + + const { status: existingStatus } = await Notifications.getPermissionsAsync(); + let finalStatus = existingStatus; + + if (existingStatus !== "granted") { + const { status } = await Notifications.requestPermissionsAsync(); + finalStatus = status; + } + + if (finalStatus !== "granted") { + return; + } + + if (!deps.projectId) { + return; + } + + try { + const { data: token } = await Notifications.getExpoPushTokenAsync({ + projectId: deps.projectId, + }); + const platform = deps.platformOS as "ios" | "android"; + registerToken({ token, platform }); + } catch { + // Push notifications are non-critical; silently skip on failure. + } +} diff --git a/clients/mobile/package-lock.json b/clients/mobile/package-lock.json index 110d04881..80d3f006c 100644 --- a/clients/mobile/package-lock.json +++ b/clients/mobile/package-lock.json @@ -19,10 +19,12 @@ "expo": "~54.0.31", "expo-auth-session": "^7.0.10", "expo-constants": "~18.0.13", + "expo-device": "~8.0.10", "expo-font": "~14.0.10", "expo-haptics": "~15.0.8", "expo-image": "~3.0.11", "expo-linking": "~8.0.11", + "expo-notifications": "~0.32.16", "expo-router": "~6.0.21", "expo-secure-store": "~15.0.8", "expo-splash-screen": "~31.0.13", @@ -2453,6 +2455,12 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@ide/backoff": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@ide/backoff/-/backoff-1.0.0.tgz", + "integrity": "sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g==", + "license": "MIT" + }, "node_modules/@isaacs/balanced-match": { "version": "4.0.1", "license": "MIT", @@ -5244,6 +5252,19 @@ "version": "2.0.6", "license": "MIT" }, + "node_modules/assert": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", + "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "is-nan": "^1.3.2", + "object-is": "^1.1.5", + "object.assign": "^4.1.4", + "util": "^0.12.5" + } + }, "node_modules/async-function": { "version": "1.0.0", "dev": true, @@ -5263,7 +5284,6 @@ }, "node_modules/available-typed-arrays": { "version": "1.0.7", - "dev": true, "license": "MIT", "dependencies": { "possible-typed-array-names": "^1.0.0" @@ -5471,6 +5491,12 @@ "@babel/core": "^7.0.0" } }, + "node_modules/badgin": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz", + "integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==", + "license": "MIT" + }, "node_modules/balanced-match": { "version": "1.0.2", "license": "MIT" @@ -5686,7 +5712,6 @@ }, "node_modules/call-bind": { "version": "1.0.8", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.0", @@ -5703,7 +5728,6 @@ }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -5715,7 +5739,6 @@ }, "node_modules/call-bound": { "version": "1.0.4", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -6449,7 +6472,6 @@ }, "node_modules/define-data-property": { "version": "1.1.4", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", @@ -6472,7 +6494,6 @@ }, "node_modules/define-properties": { "version": "1.2.1", - "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.0.1", @@ -6683,7 +6704,6 @@ }, "node_modules/dunder-proto": { "version": "1.0.1", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -6829,7 +6849,6 @@ }, "node_modules/es-define-property": { "version": "1.0.1", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6837,7 +6856,6 @@ }, "node_modules/es-errors": { "version": "1.3.0", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6871,7 +6889,6 @@ }, "node_modules/es-object-atoms": { "version": "1.1.1", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -7506,6 +7523,15 @@ } } }, + "node_modules/expo-application": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-7.0.8.tgz", + "integrity": "sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-asset": { "version": "12.0.12", "license": "MIT", @@ -7537,15 +7563,6 @@ "react-native": "*" } }, - "node_modules/expo-auth-session/node_modules/expo-application": { - "version": "7.0.8", - "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-7.0.8.tgz", - "integrity": "sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q==", - "license": "MIT", - "peerDependencies": { - "expo": "*" - } - }, "node_modules/expo-auth-session/node_modules/expo-crypto": { "version": "15.0.8", "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-15.0.8.tgz", @@ -7570,6 +7587,44 @@ "react-native": "*" } }, + "node_modules/expo-device": { + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/expo-device/-/expo-device-8.0.10.tgz", + "integrity": "sha512-jd5BxjaF7382JkDMaC+P04aXXknB2UhWaVx5WiQKA05ugm/8GH5uaz9P9ckWdMKZGQVVEOC8MHaUADoT26KmFA==", + "license": "MIT", + "dependencies": { + "ua-parser-js": "^0.7.33" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-device/node_modules/ua-parser-js": { + "version": "0.7.41", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.41.tgz", + "integrity": "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "MIT", + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/expo-file-system": { "version": "19.0.21", "license": "MIT", @@ -7657,6 +7712,26 @@ "react-native": "*" } }, + "node_modules/expo-notifications": { + "version": "0.32.16", + "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.32.16.tgz", + "integrity": "sha512-QQD/UA6v7LgvwIJ+tS7tSvqJZkdp0nCSj9MxsDk/jU1GttYdK49/5L2LvE/4U0H7sNBz1NZAyhDZozg8xgBLXw==", + "license": "MIT", + "dependencies": { + "@expo/image-utils": "^0.8.8", + "@ide/backoff": "^1.0.0", + "abort-controller": "^3.0.0", + "assert": "^2.0.0", + "badgin": "^1.1.5", + "expo-application": "~7.0.8", + "expo-constants": "~18.0.13" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-router": { "version": "6.0.21", "license": "MIT", @@ -8314,7 +8389,6 @@ }, "node_modules/for-each": { "version": "0.3.5", - "dev": true, "license": "MIT", "dependencies": { "is-callable": "^1.2.7" @@ -8406,7 +8480,6 @@ }, "node_modules/generator-function": { "version": "2.0.1", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8428,7 +8501,6 @@ }, "node_modules/get-intrinsic": { "version": "1.3.0", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -8465,7 +8537,6 @@ }, "node_modules/get-proto": { "version": "1.0.1", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -8603,7 +8674,6 @@ }, "node_modules/gopd": { "version": "1.2.0", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8636,7 +8706,6 @@ }, "node_modules/has-property-descriptors": { "version": "1.0.2", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" @@ -8661,7 +8730,6 @@ }, "node_modules/has-symbols": { "version": "1.1.0", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8672,7 +8740,6 @@ }, "node_modules/has-tostringtag": { "version": "1.0.2", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -8980,6 +9047,22 @@ "loose-envify": "^1.0.0" } }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "dev": true, @@ -9079,7 +9162,6 @@ }, "node_modules/is-callable": { "version": "1.2.7", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9184,7 +9266,6 @@ }, "node_modules/is-generator-function": { "version": "1.1.2", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.4", @@ -9222,6 +9303,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-nan": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", + "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-negative-zero": { "version": "2.0.3", "dev": true, @@ -9272,7 +9369,6 @@ }, "node_modules/is-regex": { "version": "1.2.1", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -9356,7 +9452,6 @@ }, "node_modules/is-typed-array": { "version": "1.1.15", - "dev": true, "license": "MIT", "dependencies": { "which-typed-array": "^1.1.16" @@ -11149,7 +11244,6 @@ }, "node_modules/math-intrinsics": { "version": "1.1.0", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -11755,9 +11849,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/object-keys": { "version": "1.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -11765,7 +11874,6 @@ }, "node_modules/object.assign": { "version": "4.1.7", - "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -12302,7 +12410,6 @@ }, "node_modules/possible-typed-array-names": { "version": "1.1.0", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -13877,7 +13984,6 @@ }, "node_modules/safe-regex-test": { "version": "1.1.0", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -14021,7 +14127,6 @@ }, "node_modules/set-function-length": { "version": "1.2.2", - "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", @@ -15253,6 +15358,19 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "dev": true, @@ -15750,7 +15868,6 @@ }, "node_modules/which-typed-array": { "version": "1.1.19", - "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", diff --git a/clients/mobile/package.json b/clients/mobile/package.json index f07dd9ab5..2b7411f9e 100644 --- a/clients/mobile/package.json +++ b/clients/mobile/package.json @@ -26,10 +26,12 @@ "expo": "~54.0.31", "expo-auth-session": "^7.0.10", "expo-constants": "~18.0.13", + "expo-device": "~8.0.10", "expo-font": "~14.0.10", "expo-haptics": "~15.0.8", "expo-image": "~3.0.11", "expo-linking": "~8.0.11", + "expo-notifications": "~0.32.16", "expo-router": "~6.0.21", "expo-secure-store": "~15.0.8", "expo-splash-screen": "~31.0.13", diff --git a/clients/shared/src/api/notifications.ts b/clients/shared/src/api/notifications.ts new file mode 100644 index 000000000..2212e48c7 --- /dev/null +++ b/clients/shared/src/api/notifications.ts @@ -0,0 +1,44 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { Notification, RegisterDeviceTokenInput } from "../types/notifications"; +import { useAPIClient } from "./client"; + +export const NOTIFICATIONS_QUERY_KEY = ["notifications"] as const; + +export const useGetNotifications = () => { + const api = useAPIClient(); + return useQuery({ + queryKey: NOTIFICATIONS_QUERY_KEY, + queryFn: () => api.get("/notifications"), + staleTime: 30_000, + }); +}; + +export const useMarkNotificationRead = () => { + const api = useAPIClient(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => api.put(`/notifications/${id}/read`, {}), + onSettled: () => { + queryClient.invalidateQueries({ queryKey: NOTIFICATIONS_QUERY_KEY }); + }, + }); +}; + +export const useMarkAllNotificationsRead = () => { + const api = useAPIClient(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => api.put("/notifications/read-all", {}), + onSettled: () => { + queryClient.invalidateQueries({ queryKey: NOTIFICATIONS_QUERY_KEY }); + }, + }); +}; + +export const usePostDeviceToken = () => { + const api = useAPIClient(); + return useMutation({ + mutationFn: (input: RegisterDeviceTokenInput) => + api.post("/device-tokens", input), + }); +}; diff --git a/clients/shared/src/index.ts b/clients/shared/src/index.ts index c19029715..87e3eef02 100644 --- a/clients/shared/src/index.ts +++ b/clients/shared/src/index.ts @@ -61,3 +61,18 @@ export type { RoomWithOptionalGuestBooking, FilterRoomsRequest, } from "./api/generated/models"; + +// Notification types and hooks +export type { + Notification, + NotificationType, + RegisterDeviceTokenInput, +} from "./types/notifications"; + +export { + NOTIFICATIONS_QUERY_KEY, + useGetNotifications, + useMarkNotificationRead, + useMarkAllNotificationsRead, + usePostDeviceToken, +} from "./api/notifications"; diff --git a/clients/shared/src/types/notifications.ts b/clients/shared/src/types/notifications.ts new file mode 100644 index 000000000..7c45841a7 --- /dev/null +++ b/clients/shared/src/types/notifications.ts @@ -0,0 +1,17 @@ +export type NotificationType = "task_assigned" | "high_priority_task"; + +export type Notification = { + id: string; + user_id: string; + type: NotificationType; + title: string; + body: string; + data?: Record; + read_at?: string; + created_at: string; +}; + +export type RegisterDeviceTokenInput = { + token: string; + platform: "ios" | "android"; +};