diff --git a/apps/mobile/README.md b/apps/mobile/README.md index 3c3d03eca8b..c3b509d9a07 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -146,3 +146,11 @@ npx sentry-expo-upload-sourcemaps dist ## Voice backend behavior The iOS client records short audio locally and sends it only to the Multica backend speech proxy. It does not receive ASR/TTS provider keys. If the backend has speech disabled or the provider is unavailable, speech calls return typed recoverable errors such as `provider_missing`, `rate_limited`, `quota_exceeded`, or `provider_timeout`; the app should let the user continue by typing the message. + +## Push notifications + +The iOS app uses `expo-notifications` to request APNs-backed Expo push permission after a signed-in user has an active workspace. Device tokens are registered against the current user + workspace through `/api/mobile/push-tokens`; sign-out unregisters the current token before clearing auth. + +Server delivery is driven by existing inbox events and notification preferences. `system_notifications=muted` disables lock-screen delivery while keeping in-app inbox/realtime behavior intact. Without `EXPO_ACCESS_TOKEN`, the backend records deliveries as dry-run `skipped` rows and does not call Expo. Set `EXPO_ACCESS_TOKEN` for real delivery; `MULTICA_EXPO_PUSH_URL` can override the Expo endpoint for tests, and `MULTICA_PUSH_DRY_RUN=true` forces dry-run. + +Manual iOS validation still requires a signed device build: allow/deny permission, receive a background notification for assignment/mention/comment/agent completion/failure/block, tap it, and confirm the app routes to the target workspace issue. If a workspace is unavailable, the app falls back to workspace selection. diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index ec4a7861cf2..d99f7842c0a 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -79,6 +79,12 @@ export default ({ config }: ConfigContext): ExpoConfig => { microphonePermission: false, }, ], + [ + "expo-notifications", + { + sounds: [], + }, + ], [ "expo-build-properties", { diff --git a/apps/mobile/app/(app)/[workspace]/more/settings.tsx b/apps/mobile/app/(app)/[workspace]/more/settings.tsx index b06e7087928..6ce9bb6c792 100644 --- a/apps/mobile/app/(app)/[workspace]/more/settings.tsx +++ b/apps/mobile/app/(app)/[workspace]/more/settings.tsx @@ -29,6 +29,7 @@ import { useColorScheme, type ThemePreference, } from "@/lib/use-color-scheme"; +import { unregisterCurrentMobilePushToken } from "@/lib/mobile-push"; import { THEME } from "@/lib/theme"; import { cn } from "@/lib/utils"; @@ -75,6 +76,7 @@ export default function SettingsPage() { text: "Sign out", style: "destructive", onPress: async () => { + await unregisterCurrentMobilePushToken(); await clearWorkspace(); await logout(); }, diff --git a/apps/mobile/app/(app)/[workspace]/more/settings/notifications.tsx b/apps/mobile/app/(app)/[workspace]/more/settings/notifications.tsx index 3652160941c..ceab2497057 100644 --- a/apps/mobile/app/(app)/[workspace]/more/settings/notifications.tsx +++ b/apps/mobile/app/(app)/[workspace]/more/settings/notifications.tsx @@ -16,9 +16,14 @@ import type { import { Text } from "@/components/ui/text"; import { Switch } from "@/components/ui/switch"; import { Separator } from "@/components/ui/separator"; +import { Button } from "@/components/ui/button"; import { useWorkspaceStore } from "@/data/workspace-store"; import { notificationPreferenceOptions } from "@/data/queries/notification-preferences"; import { useUpdateNotificationPreferences } from "@/data/mutations/notification-preferences"; +import { + useMobilePushActions, + useMobilePushStatus, +} from "@/lib/mobile-push"; const INBOX_GROUPS: Array<{ key: Exclude; @@ -58,6 +63,8 @@ export default function NotificationsSettingsScreen() { notificationPreferenceOptions(wsId), ); const mutation = useUpdateNotificationPreferences(); + const pushStatus = useMobilePushStatus(); + const pushActions = useMobilePushActions(); const preferences: NotificationPreferences = data?.preferences ?? {}; @@ -73,6 +80,8 @@ export default function NotificationsSettingsScreen() { }; const systemEnabled = preferences.system_notifications !== "muted"; + const canUseSystemNotifications = + pushStatus.permission === "granted" && systemEnabled; if (isLoading) { return ( @@ -127,23 +136,33 @@ export default function NotificationsSettingsScreen() {
- - - - System notifications - - - Account changes, security alerts, product updates. - + + + + + Push notifications + + + Assignments, mentions, comments, and agent status updates. + + + + onToggle("system_notifications", checked) + } + /> - - onToggle("system_notifications", checked) - } +
@@ -151,6 +170,53 @@ export default function NotificationsSettingsScreen() { ); } +function PermissionStatus({ + enabled, + permission, + error, + registering, + onRequest, + onOpenSettings, +}: { + enabled: boolean; + permission: string; + error: string | null; + registering: boolean; + onRequest: () => void; + onOpenSettings: () => void; +}) { + const message = + permission === "granted" + ? enabled + ? "iOS push is enabled for this workspace." + : "iOS permission is enabled. Turn on the switch to receive push." + : permission === "denied" + ? "iOS notification permission is off. Re-enable it in Settings." + : permission === "undetermined" + ? "Allow notifications to receive lock screen updates." + : permission === "unsupported" + ? "Push notifications are available on iOS builds." + : "Checking notification permission."; + + return ( + + + {error ?? message} + + {permission === "undetermined" || permission === "error" ? ( + + ) : null} + {permission === "denied" ? ( + + ) : null} + + ); +} + function Section({ title, description, diff --git a/apps/mobile/app/(app)/select-workspace.tsx b/apps/mobile/app/(app)/select-workspace.tsx index d1f77ef388c..53049dbc064 100644 --- a/apps/mobile/app/(app)/select-workspace.tsx +++ b/apps/mobile/app/(app)/select-workspace.tsx @@ -8,6 +8,7 @@ import { CardPressable } from "@/components/ui/card"; import { workspaceListOptions } from "@/data/queries/workspaces"; import { useAuthStore } from "@/data/auth-store"; import { useWorkspaceStore } from "@/data/workspace-store"; +import { unregisterCurrentMobilePushToken } from "@/lib/mobile-push"; export default function SelectWorkspace() { const user = useAuthStore((s) => s.user); @@ -79,7 +80,13 @@ export default function SelectWorkspace() { - diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index b05d62a8cf1..26d59cab1d8 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -16,6 +16,10 @@ import { useAuthStore } from "@/data/auth-store"; import { useWorkspaceStore } from "@/data/workspace-store"; import { initCrashReporting, withCrashReporting } from "@/lib/crash-reporting"; import { LightboxProvider, prewarmHighlighter } from "@/lib/markdown"; +import { + unregisterCurrentMobilePushToken, + useMobilePushLifecycle, +} from "@/lib/mobile-push"; import { NAV_THEME } from "@/lib/theme"; import { useCrashContext } from "@/lib/use-crash-context"; import { useColorScheme } from "@/lib/use-color-scheme"; @@ -43,6 +47,7 @@ function AuthInitializer({ children }: { children: React.ReactNode }) { if (signingOutRef.current) return; signingOutRef.current = true; void (async () => { + await unregisterCurrentMobilePushToken(); await useAuthStore.getState().logout(); await useWorkspaceStore.getState().clear(); qc.clear(); @@ -66,6 +71,11 @@ function CrashContextSync() { return null; } +function MobilePushInitializer() { + useMobilePushLifecycle(); + return null; +} + function RootLayout() { const { colorScheme, isDarkColorScheme } = useColorScheme(); return ( @@ -76,6 +86,7 @@ function RootLayout() { + diff --git a/apps/mobile/data/api.ts b/apps/mobile/data/api.ts index 56ee0e13131..7ad32889519 100644 --- a/apps/mobile/data/api.ts +++ b/apps/mobile/data/api.ts @@ -149,6 +149,15 @@ export interface SpeechSynthesizeResponse { content_type: string; } +export interface MobilePushTokenRequest { + provider: "expo"; + token: string; + device_id?: string | null; + platform: "ios"; + app_version?: string | null; + environment: string; +} + /** Web mirrors this from `packages/core/constants/upload.ts`. Mobile keeps * its own copy per the `mirror, don't import` rule in apps/mobile/CLAUDE.md. */ const MAX_FILE_SIZE = 100 * 1024 * 1024; @@ -424,6 +433,21 @@ class ApiClient { ); } + // --- Mobile push --- + async registerMobilePushToken(data: MobilePushTokenRequest): Promise { + await this.fetch("/api/mobile/push-tokens", { + method: "POST", + body: JSON.stringify(data), + }); + } + + async unregisterMobilePushToken(data: MobilePushTokenRequest): Promise { + await this.fetch("/api/mobile/push-tokens", { + method: "DELETE", + body: JSON.stringify(data), + }); + } + // --- Workspaces --- async listWorkspaces(opts?: { signal?: AbortSignal; diff --git a/apps/mobile/lib/mobile-push.ts b/apps/mobile/lib/mobile-push.ts new file mode 100644 index 00000000000..89da5d386af --- /dev/null +++ b/apps/mobile/lib/mobile-push.ts @@ -0,0 +1,214 @@ +import { useCallback, useEffect, useSyncExternalStore } from "react"; +import { AppState, Linking, Platform } from "react-native"; +import Constants from "expo-constants"; +import * as Notifications from "expo-notifications"; +import { router } from "expo-router"; +import { api, type MobilePushTokenRequest } from "@/data/api"; +import { useAuthStore } from "@/data/auth-store"; +import { useWorkspaceStore } from "@/data/workspace-store"; + +type PushPermissionState = + | "unknown" + | "unsupported" + | "granted" + | "denied" + | "undetermined" + | "error"; + +interface PushState { + permission: PushPermissionState; + token: string | null; + error: string | null; + registering: boolean; +} + +const state: PushState = { + permission: "unknown", + token: null, + error: null, + registering: false, +}; + +const listeners = new Set<() => void>(); + +function emit() { + for (const listener of listeners) listener(); +} + +function patchState(next: Partial) { + Object.assign(state, next); + emit(); +} + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); +} + +function snapshot() { + return state; +} + +Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldPlaySound: false, + shouldSetBadge: true, + shouldShowBanner: true, + shouldShowList: true, + }), +}); + +export function useMobilePushStatus() { + return useSyncExternalStore(subscribe, snapshot, snapshot); +} + +export function useMobilePushActions() { + const refresh = useCallback(() => refreshPushPermissionStatus(), []); + const request = useCallback(() => registerForMobilePush({ requestPermission: true }), []); + const openSettings = useCallback(() => Linking.openSettings(), []); + return { refresh, request, openSettings }; +} + +export function useMobilePushLifecycle() { + const user = useAuthStore((s) => s.user); + const workspaceId = useWorkspaceStore((s) => s.currentWorkspaceId); + + useEffect(() => { + void refreshPushPermissionStatus(); + }, []); + + useEffect(() => { + if (!user || !workspaceId) return; + void registerForMobilePush({ requestPermission: true }); + }, [user, workspaceId]); + + useEffect(() => { + const subscription = AppState.addEventListener("change", (next) => { + if (next === "active") { + void refreshPushPermissionStatus(); + if (useAuthStore.getState().user && useWorkspaceStore.getState().currentWorkspaceId) { + void registerForMobilePush({ requestPermission: false }); + } + } + }); + return () => subscription.remove(); + }, []); + + useEffect(() => { + const responseSubscription = Notifications.addNotificationResponseReceivedListener((response) => { + void routeFromNotificationData(response.notification.request.content.data); + }); + void Notifications.getLastNotificationResponseAsync().then((response) => { + if (response) { + void routeFromNotificationData(response.notification.request.content.data); + } + }); + return () => responseSubscription.remove(); + }, []); +} + +export async function unregisterCurrentMobilePushToken() { + if (!state.token) return; + if (!useWorkspaceStore.getState().currentWorkspaceId) return; + try { + await api.unregisterMobilePushToken(buildTokenRequest(state.token)); + } catch (err) { + console.warn("[push] unregister failed", err); + } finally { + patchState({ token: null }); + } +} + +async function refreshPushPermissionStatus() { + if (Platform.OS !== "ios") { + patchState({ permission: "unsupported" }); + return; + } + try { + const permissions = await Notifications.getPermissionsAsync(); + patchState({ permission: permissionState(permissions), error: null }); + } catch (err) { + patchState({ + permission: "error", + error: err instanceof Error ? err.message : "Unable to read notification permission.", + }); + } +} + +async function registerForMobilePush(opts: { requestPermission: boolean }) { + if (Platform.OS !== "ios" || state.registering) return; + if (!useAuthStore.getState().user || !useWorkspaceStore.getState().currentWorkspaceId) return; + + patchState({ registering: true, error: null }); + try { + let permissions = await Notifications.getPermissionsAsync(); + if (!permissionGranted(permissions) && opts.requestPermission && permissions.status === "undetermined") { + permissions = await Notifications.requestPermissionsAsync(); + } + const nextPermission = permissionState(permissions); + if (!permissionGranted(permissions)) { + patchState({ permission: nextPermission, registering: false }); + return; + } + + const projectId = Constants.expoConfig?.extra?.eas?.projectId; + const token = (await Notifications.getExpoPushTokenAsync(projectId ? { projectId } : undefined)).data; + await api.registerMobilePushToken(buildTokenRequest(token)); + patchState({ permission: "granted", token, registering: false, error: null }); + } catch (err) { + patchState({ + permission: "error", + registering: false, + error: err instanceof Error ? err.message : "Unable to register push notifications.", + }); + } +} + +function buildTokenRequest(token: string): MobilePushTokenRequest { + return { + provider: "expo", + token, + device_id: null, + platform: "ios", + app_version: Constants.expoConfig?.version ?? null, + environment: String(Constants.expoConfig?.extra?.APP_ENV ?? "development"), + }; +} + +function permissionGranted(permissions: Notifications.NotificationPermissionsStatus) { + return ( + permissions.granted || + permissions.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL || + permissions.ios?.status === Notifications.IosAuthorizationStatus.AUTHORIZED + ); +} + +function permissionState(permissions: Notifications.NotificationPermissionsStatus): PushPermissionState { + if (permissionGranted(permissions)) return "granted"; + if (permissions.status === "denied") return "denied"; + if (permissions.status === "undetermined") return "undetermined"; + return "unknown"; +} + +async function routeFromNotificationData(data: Notifications.NotificationContent["data"]) { + if (!data) return; + const issueId = typeof data.issue_id === "string" ? data.issue_id : ""; + const workspaceId = typeof data.workspace_id === "string" ? data.workspace_id : ""; + if (!issueId || !workspaceId) return; + + try { + const workspaces = await api.listWorkspaces(); + const workspace = workspaces.find((w) => w.id === workspaceId); + if (!workspace) { + router.replace("/select-workspace"); + return; + } + await useWorkspaceStore.getState().setCurrentWorkspace(workspace.id, workspace.slug); + const commentId = typeof data.comment_id === "string" ? data.comment_id : ""; + const suffix = commentId ? `?h=${encodeURIComponent(commentId)}` : ""; + router.push(`/${workspace.slug}/issue/${issueId}${suffix}`); + } catch (err) { + console.warn("[push] route failed", err); + router.replace("/select-workspace"); + } +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index fab490412ec..762344b6d83 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -54,6 +54,7 @@ "expo-image": "^55.0.10", "expo-image-picker": "~55.0.20", "expo-linking": "~55.0.0", + "expo-notifications": "~55.0.23", "expo-router": "~55.0.14", "expo-secure-store": "~55.0.13", "expo-status-bar": "~55.0.0", diff --git a/packages/views/locales/ja/onboarding.json b/packages/views/locales/ja/onboarding.json index 47d339e13dd..1f34dd095f8 100644 --- a/packages/views/locales/ja/onboarding.json +++ b/packages/views/locales/ja/onboarding.json @@ -170,6 +170,11 @@ "label": "アシスタント", "blurb": "汎用型。タスクが不明確なときに頼れるデフォルトです。", "instructions": "あなたは汎用のチームメイトです。軽めのコーディング、執筆、リサーチ、計画など多様なタスクをこなし、スコープについては実用的に判断してください。タスクが曖昧なときは、取りかかる前に確認の質問を 1 つしてください。網羅的な出力より、短く役立つ出力を基本としてください。" + }, + "prompt_engineer": { + "label": "プロンプトエンジニア", + "blurb": "プロンプト、ハンドオフ、エージェントワークフローの安定性を改善します。", + "instructions": "あなたはエージェントチームのプロンプトエンジニアです。プロンプトの品質、タスクハンドオフ指示、エージェントワークフローの明瞭さ、繰り返し実行時の出力安定性を改善してください。提案や編集前に、イシュースレッド、エージェントの動作、テンプレート、既存の指示を確認してください。境界:Planning の製品トレードオフ、Coding のエンジニアリング実装、Review のマージゲートを置き換えないでください。変更を提案する際は、具体的で、テスト可能で、担当エージェントが採用しやすいものにしてください。" } }, "about_eyebrow": "エージェントとは", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6fba8c89fce..7c2fec8cd18 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -417,6 +417,9 @@ importers: expo-linking: specifier: ~55.0.0 version: 55.0.15(expo@55.0.24)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + expo-notifications: + specifier: ~55.0.23 + version: 55.0.23(expo@55.0.24)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) expo-router: specifier: ~55.0.14 version: 55.0.14(28857138dad78e037ca803b5df54c4f8) @@ -5089,6 +5092,9 @@ packages: peerDependencies: '@babel/core': ^7.0.0 + badgin@1.2.3: + resolution: {integrity: sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==} + bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -6353,6 +6359,11 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + expo-application@55.0.15: + resolution: {integrity: sha512-eWf5OGrat1r/TYr0daL684C6pJYiN2k30NmcISsD9fbtZLfA6Sbo/JebfYzP3OjO/T/i8j/9Pf3ePqUYPLfZnw==} + peerDependencies: + expo: '*' + expo-asset@55.0.17: resolution: {integrity: sha512-pK9HHJuFqjE8kDUcbMFsZj3Cz8WdXpvZHZmYl7ouFQp59P83BvHln6VnqPDGlO+/4929G0Lm8ZUzbONuNRhi9w==} peerDependencies: @@ -6491,6 +6502,13 @@ packages: react-native-worklets: optional: true + expo-notifications@55.0.23: + resolution: {integrity: sha512-oWEsBZSedRFu+ErcJaIev7QQhCE/gTRO6KURAkFpMflMgI3yjT4O/qixXh4tHmEk5zfoOpR2u79AzvVcchfwww==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + expo-router@55.0.14: resolution: {integrity: sha512-rOn/wosp2hAPM+O2o41hnarbP5Zqv9UkHWa31KoSoiOme1tpmZd2yc93XtRAtzP0P5E5xzqq7a2rbEAarpP5XA==} peerDependencies: @@ -15522,6 +15540,8 @@ snapshots: babel-plugin-jest-hoist: 29.6.3 babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) + badgin@1.2.3: {} + bail@2.0.2: {} balanced-match@1.0.2: {} @@ -17137,6 +17157,10 @@ snapshots: expect-type@1.3.0: {} + expo-application@55.0.15(expo@55.0.24): + dependencies: + expo: 55.0.24(@babel/core@7.29.0)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-asset@55.0.17(expo@55.0.24)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3): dependencies: '@expo/image-utils': 0.8.14(typescript@5.9.3) @@ -17285,6 +17309,20 @@ snapshots: optionalDependencies: react-native-worklets: 0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + expo-notifications@55.0.23(expo@55.0.24)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3): + dependencies: + '@expo/image-utils': 0.8.14(typescript@5.9.3) + abort-controller: 3.0.0 + badgin: 1.2.3 + expo: 55.0.24(@babel/core@7.29.0)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-application: 55.0.15(expo@55.0.24) + expo-constants: 55.0.16(expo@55.0.24)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0)) + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0) + transitivePeerDependencies: + - supports-color + - typescript + expo-router@55.0.14(28857138dad78e037ca803b5df54c4f8): dependencies: '@expo/log-box': 55.0.12(@expo/dom-webview@55.0.6)(expo@55.0.24)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) diff --git a/server/cmd/server/notification_listeners.go b/server/cmd/server/notification_listeners.go index 5761ee7a1a8..9346ae90248 100644 --- a/server/cmd/server/notification_listeners.go +++ b/server/cmd/server/notification_listeners.go @@ -882,7 +882,75 @@ func registerNotificationListeners(bus *events.Bus, queries *db.Queries) { ) }) - // task:completed — no inbox notification (completion is visible from status change) + // task:completed — notify all subscribers except the agent. + bus.Subscribe(protocol.EventTaskCompleted, func(e events.Event) { + payload, ok := e.Payload.(map[string]any) + if !ok { + return + } + agentID, _ := payload["agent_id"].(string) + issueID, _ := payload["issue_id"].(string) + if issueID == "" { + return + } + + issue, err := queries.GetIssue(ctx, parseUUID(issueID)) + if err != nil { + slog.Error("task:completed notification: failed to get issue", "issue_id", issueID, "error", err) + return + } + + exclude := map[string]bool{} + if agentID != "" { + exclude[agentID] = true + } + + notifySubscribers(ctx, queries, bus, issueID, issue.Status, e.WorkspaceID, + events.Event{ + Type: e.Type, + WorkspaceID: e.WorkspaceID, + ActorType: "agent", + ActorID: agentID, + }, + exclude, "task_completed", "info", + issue.Title, "", + emptyDetails) + }) + + // task:waiting_local_directory — notify all subscribers except the agent. + bus.Subscribe(protocol.EventTaskWaitingLocalDirectory, func(e events.Event) { + payload, ok := e.Payload.(map[string]any) + if !ok { + return + } + agentID, _ := payload["agent_id"].(string) + issueID, _ := payload["issue_id"].(string) + if issueID == "" { + return + } + + issue, err := queries.GetIssue(ctx, parseUUID(issueID)) + if err != nil { + slog.Error("task:waiting_local_directory notification: failed to get issue", "issue_id", issueID, "error", err) + return + } + + exclude := map[string]bool{} + if agentID != "" { + exclude[agentID] = true + } + + notifySubscribers(ctx, queries, bus, issueID, issue.Status, e.WorkspaceID, + events.Event{ + Type: e.Type, + WorkspaceID: e.WorkspaceID, + ActorType: "agent", + ActorID: agentID, + }, + exclude, "agent_blocked", "action_required", + issue.Title, "", + emptyDetails) + }) // task:failed — notify all subscribers except the agent bus.Subscribe(protocol.EventTaskFailed, func(e events.Event) { diff --git a/server/cmd/server/notification_listeners_test.go b/server/cmd/server/notification_listeners_test.go index 3c6fe2815d7..de1af043b83 100644 --- a/server/cmd/server/notification_listeners_test.go +++ b/server/cmd/server/notification_listeners_test.go @@ -588,8 +588,8 @@ func TestNotification_AssigneeChanged(t *testing.T) { } } -// TestNotification_TaskCompleted verifies that task:completed events do NOT -// create inbox notifications (completion is visible from the status change). +// TestNotification_TaskCompleted verifies that subscribers get a +// "task_completed" notification when a task finishes, excluding the agent. func TestNotification_TaskCompleted(t *testing.T) { queries := db.New(testPool) bus := newNotificationBus(t, queries) @@ -620,10 +620,12 @@ func TestNotification_TaskCompleted(t *testing.T) { }, }) - // No inbox notification should be created for task:completed creatorItems := inboxItemsForRecipient(t, queries, testUserID) - if len(creatorItems) != 0 { - t.Fatalf("expected 0 inbox items for creator on task:completed, got %d", len(creatorItems)) + if len(creatorItems) != 1 { + t.Fatalf("expected 1 inbox item for creator on task:completed, got %d", len(creatorItems)) + } + if creatorItems[0].Type != "task_completed" { + t.Fatalf("expected type task_completed, got %q", creatorItems[0].Type) } } diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index ce2912ac2a1..8d8391b42e1 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -173,6 +173,7 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus if opts.HeartbeatScheduler != nil { h.HeartbeatScheduler = opts.HeartbeatScheduler } + service.NewMobilePushServiceFromEnv(queries).Register(bus) // Auth caches: PAT cache is shared between the regular Auth middleware, // the DaemonAuth fallback (mul_) path, and the revoke handler // (invalidate). DaemonTokenCache backs the DaemonAuth mdt_ path. Both @@ -761,6 +762,12 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus r.Get("/", h.GetNotificationPreferences) r.Put("/", h.UpdateNotificationPreferences) }) + + // Mobile push tokens + r.Route("/api/mobile/push-tokens", func(r chi.Router) { + r.Post("/", h.RegisterMobilePushToken) + r.Delete("/", h.UnregisterMobilePushToken) + }) }) }) diff --git a/server/internal/handler/mobile_push.go b/server/internal/handler/mobile_push.go new file mode 100644 index 00000000000..8a914b3345f --- /dev/null +++ b/server/internal/handler/mobile_push.go @@ -0,0 +1,173 @@ +package handler + +import ( + "encoding/json" + "errors" + "log/slog" + "net/http" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/multica-ai/multica/server/internal/logger" + "github.com/multica-ai/multica/server/internal/middleware" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +type mobilePushTokenRequest struct { + Provider string `json:"provider"` + Token string `json:"token"` + DeviceID *string `json:"device_id"` + Platform string `json:"platform"` + AppVersion *string `json:"app_version"` + Environment string `json:"environment"` +} + +type mobilePushTokenResponse struct { + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + Provider string `json:"provider"` + Platform string `json:"platform"` + Environment string `json:"environment"` + Enabled bool `json:"enabled"` +} + +func mobilePushTokenToResponse(t db.MobilePushDeviceToken) mobilePushTokenResponse { + return mobilePushTokenResponse{ + ID: uuidToString(t.ID), + WorkspaceID: uuidToString(t.WorkspaceID), + Provider: t.Provider, + Platform: t.Platform, + Environment: t.Environment, + Enabled: t.Enabled, + } +} + +func (h *Handler) RegisterMobilePushToken(w http.ResponseWriter, r *http.Request) { + userID, ok := requireUserID(w, r) + if !ok { + return + } + workspaceID := ctxWorkspaceID(r.Context()) + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") + if !ok { + return + } + + var req mobilePushTokenRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + provider := strings.TrimSpace(req.Provider) + if provider == "" { + provider = "expo" + } + if provider != "expo" { + writeError(w, http.StatusBadRequest, "unsupported push provider") + return + } + + token := strings.TrimSpace(req.Token) + if !strings.HasPrefix(token, "ExponentPushToken[") && !strings.HasPrefix(token, "ExpoPushToken[") { + writeError(w, http.StatusBadRequest, "invalid expo push token") + return + } + + platform := strings.TrimSpace(req.Platform) + if platform == "" { + _, _, clientOS := middleware.ClientMetadataFromContext(r.Context()) + platform = clientOS + } + if platform == "" { + platform = "ios" + } + + environment := strings.TrimSpace(req.Environment) + if environment == "" { + environment = "development" + } + + appVersion := req.AppVersion + if appVersion == nil { + _, version, _ := middleware.ClientMetadataFromContext(r.Context()) + if version != "" { + appVersion = &version + } + } + + item, err := h.Queries.UpsertMobilePushDeviceToken(r.Context(), db.UpsertMobilePushDeviceTokenParams{ + UserID: parseUUID(userID), + WorkspaceID: wsUUID, + Provider: provider, + Token: token, + DeviceID: ptrToText(trimStringPtr(req.DeviceID)), + Platform: platform, + AppVersion: ptrToText(trimStringPtr(appVersion)), + Environment: environment, + }) + if err != nil { + slog.Warn("UpsertMobilePushDeviceToken failed", append(logger.RequestAttrs(r), "error", err)...) + writeError(w, http.StatusInternalServerError, "failed to register push token") + return + } + + writeJSON(w, http.StatusOK, mobilePushTokenToResponse(item)) +} + +func (h *Handler) UnregisterMobilePushToken(w http.ResponseWriter, r *http.Request) { + userID, ok := requireUserID(w, r) + if !ok { + return + } + workspaceID := ctxWorkspaceID(r.Context()) + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") + if !ok { + return + } + + var req mobilePushTokenRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + provider := strings.TrimSpace(req.Provider) + if provider == "" { + provider = "expo" + } + token := strings.TrimSpace(req.Token) + if provider != "expo" || token == "" { + writeError(w, http.StatusBadRequest, "provider and token are required") + return + } + + item, err := h.Queries.DisableMobilePushDeviceToken(r.Context(), db.DisableMobilePushDeviceTokenParams{ + UserID: parseUUID(userID), + WorkspaceID: wsUUID, + Provider: provider, + Token: token, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) + return + } + slog.Warn("DisableMobilePushDeviceToken failed", append(logger.RequestAttrs(r), "error", err)...) + writeError(w, http.StatusInternalServerError, "failed to unregister push token") + return + } + + writeJSON(w, http.StatusOK, mobilePushTokenToResponse(item)) +} + +func trimStringPtr(s *string) *string { + if s == nil { + return nil + } + trimmed := strings.TrimSpace(*s) + if trimmed == "" { + return nil + } + return &trimmed +} diff --git a/server/internal/service/mobile_push.go b/server/internal/service/mobile_push.go new file mode 100644 index 00000000000..b539f74348f --- /dev/null +++ b/server/internal/service/mobile_push.go @@ -0,0 +1,311 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "strings" + "time" + + "github.com/multica-ai/multica/server/internal/events" + "github.com/multica-ai/multica/server/internal/util" + db "github.com/multica-ai/multica/server/pkg/db/generated" + "github.com/multica-ai/multica/server/pkg/protocol" +) + +const expoPushURL = "https://exp.host/--/api/v2/push/send" + +type MobilePushConfig struct { + AccessToken string + Endpoint string + DryRun bool + Timeout time.Duration +} + +type MobilePushService struct { + queries *db.Queries + client *http.Client + config MobilePushConfig +} + +func NewMobilePushServiceFromEnv(queries *db.Queries) *MobilePushService { + timeout := 10 * time.Second + cfg := MobilePushConfig{ + AccessToken: strings.TrimSpace(os.Getenv("EXPO_ACCESS_TOKEN")), + Endpoint: strings.TrimSpace(os.Getenv("MULTICA_EXPO_PUSH_URL")), + DryRun: os.Getenv("MULTICA_PUSH_DRY_RUN") == "true", + Timeout: timeout, + } + if cfg.Endpoint == "" { + cfg.Endpoint = expoPushURL + } + if cfg.AccessToken == "" { + cfg.DryRun = true + } + return NewMobilePushService(queries, cfg, nil) +} + +func NewMobilePushService(queries *db.Queries, cfg MobilePushConfig, client *http.Client) *MobilePushService { + if cfg.Endpoint == "" { + cfg.Endpoint = expoPushURL + } + if cfg.Timeout <= 0 { + cfg.Timeout = 10 * time.Second + } + if client == nil { + client = &http.Client{Timeout: cfg.Timeout} + } + return &MobilePushService{queries: queries, client: client, config: cfg} +} + +func (s *MobilePushService) Register(bus *events.Bus) { + if s == nil || bus == nil { + return + } + bus.Subscribe(protocol.EventInboxNew, func(e events.Event) { + go s.handleInboxNew(context.Background(), e) + }) +} + +func (s *MobilePushService) handleInboxNew(ctx context.Context, e events.Event) { + payload, ok := e.Payload.(map[string]any) + if !ok { + return + } + rawItem, ok := payload["item"].(map[string]any) + if !ok { + return + } + item, ok := pushInboxItemFromPayload(rawItem) + if !ok { + return + } + if item.RecipientType != "member" || item.RecipientID == "" || item.WorkspaceID == "" { + return + } + if !isMobilePushInboxType(item.Type) { + return + } + + tokens, err := s.queries.ListEnabledMobilePushTokensForInboxItem(ctx, db.ListEnabledMobilePushTokensForInboxItemParams{ + WorkspaceID: util.MustParseUUID(item.WorkspaceID), + UserID: util.MustParseUUID(item.RecipientID), + }) + if err != nil { + slog.Warn("mobile push: list tokens failed", "error", err, "inbox_item_id", item.ID) + return + } + for _, token := range tokens { + status, providerID, sendErr := s.send(ctx, token, item) + errText := "" + if sendErr != nil { + errText = sendErr.Error() + } + _, err := s.queries.CreateMobilePushDelivery(ctx, db.CreateMobilePushDeliveryParams{ + InboxItemID: util.MustParseUUID(item.ID), + DeviceTokenID: token.ID, + Provider: "expo", + Status: status, + ProviderMessageID: util.StrToText(providerID), + Error: util.StrToText(errText), + }) + if err != nil { + slog.Warn("mobile push: create delivery failed", "error", err, "inbox_item_id", item.ID) + } + if errors.Is(sendErr, errExpoDeviceNotRegistered) { + if err := s.queries.DisableMobilePushDeviceTokenByID(ctx, token.ID); err != nil { + slog.Warn("mobile push: disable invalid token failed", "error", err, "token_id", util.UUIDToString(token.ID)) + } + } + } +} + +type pushInboxItem struct { + ID string + WorkspaceID string + RecipientType string + RecipientID string + Type string + Severity string + IssueID string + Title string + Body string + Details map[string]string +} + +func pushInboxItemFromPayload(raw map[string]any) (pushInboxItem, bool) { + item := pushInboxItem{ + ID: stringFromAny(raw["id"]), + WorkspaceID: stringFromAny(raw["workspace_id"]), + RecipientType: stringFromAny(raw["recipient_type"]), + RecipientID: stringFromAny(raw["recipient_id"]), + Type: stringFromAny(raw["type"]), + Severity: stringFromAny(raw["severity"]), + IssueID: stringFromAny(raw["issue_id"]), + Title: stringFromAny(raw["title"]), + Body: stringFromAny(raw["body"]), + Details: map[string]string{}, + } + if details, ok := raw["details"].(map[string]any); ok { + for k, v := range details { + if s := stringFromAny(v); s != "" { + item.Details[k] = s + } + } + } else if details, ok := raw["details"].(json.RawMessage); ok && len(details) > 0 { + var parsed map[string]string + if err := json.Unmarshal(details, &parsed); err == nil { + item.Details = parsed + } + } + return item, item.ID != "" +} + +func stringFromAny(v any) string { + switch value := v.(type) { + case string: + return value + case *string: + if value == nil { + return "" + } + return *value + default: + return "" + } +} + +func isMobilePushInboxType(t string) bool { + switch t { + case "issue_assigned", "assignee_changed", "mentioned", "new_comment", "task_completed", "task_failed", "agent_blocked", "agent_completed": + return true + default: + return false + } +} + +func (s *MobilePushService) send(ctx context.Context, token db.MobilePushDeviceToken, item pushInboxItem) (string, string, error) { + if s.config.DryRun { + slog.Info("mobile push dry-run", + "inbox_item_id", item.ID, + "device_token_id", util.UUIDToString(token.ID), + "type", item.Type) + return "skipped", "", nil + } + + body := expoPushRequest{ + To: token.Token, + Sound: "default", + Title: pushTitle(item), + Body: pushBody(item), + Priority: "high", + Data: map[string]string{ + "workspace_id": item.WorkspaceID, + "issue_id": item.IssueID, + "inbox_item_id": item.ID, + "type": item.Type, + }, + } + if commentID := item.Details["comment_id"]; commentID != "" { + body.Data["comment_id"] = commentID + } + + payload, err := json.Marshal(body) + if err != nil { + return "failed", "", err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.config.Endpoint, bytes.NewReader(payload)) + if err != nil { + return "failed", "", err + } + req.Header.Set("Content-Type", "application/json") + if s.config.AccessToken != "" { + req.Header.Set("Authorization", "Bearer "+s.config.AccessToken) + } + res, err := s.client.Do(req) + if err != nil { + return "failed", "", err + } + defer res.Body.Close() + resBody, _ := io.ReadAll(io.LimitReader(res.Body, 16*1024)) + if res.StatusCode < 200 || res.StatusCode >= 300 { + return "failed", "", fmt.Errorf("expo push returned %d", res.StatusCode) + } + + var parsed expoPushResponse + if err := json.Unmarshal(resBody, &parsed); err != nil { + return "failed", "", err + } + if parsed.Data.Status == "ok" { + return "sent", parsed.Data.ID, nil + } + errMsg := parsed.Data.Message + if errMsg == "" { + errMsg = parsed.Data.Details.Error + } + if parsed.Data.Details.Error == "DeviceNotRegistered" { + return "failed", parsed.Data.ID, errExpoDeviceNotRegistered + } + if errMsg == "" { + errMsg = "expo push failed" + } + return "failed", parsed.Data.ID, errors.New(errMsg) +} + +type expoPushRequest struct { + To string `json:"to"` + Sound string `json:"sound"` + Title string `json:"title"` + Body string `json:"body"` + Priority string `json:"priority"` + Data map[string]string `json:"data"` +} + +type expoPushResponse struct { + Data struct { + Status string `json:"status"` + ID string `json:"id"` + Message string `json:"message"` + Details struct { + Error string `json:"error"` + } `json:"details"` + } `json:"data"` +} + +var errExpoDeviceNotRegistered = errors.New("expo device not registered") + +func pushTitle(item pushInboxItem) string { + switch item.Type { + case "issue_assigned": + return "Assigned to you" + case "mentioned": + return "Mentioned you" + case "new_comment": + return "New comment" + case "task_failed": + return "Agent needs attention" + case "task_completed", "agent_completed": + return "Agent finished" + case "agent_blocked": + return "Agent is blocked" + default: + return "Multica" + } +} + +func pushBody(item pushInboxItem) string { + body := strings.TrimSpace(item.Title) + if body == "" { + body = "Open Multica to view the update." + } + if len(body) > 160 { + body = body[:157] + "..." + } + return body +} diff --git a/server/migrations/113_mobile_push_tokens.down.sql b/server/migrations/113_mobile_push_tokens.down.sql new file mode 100644 index 00000000000..641828bb842 --- /dev/null +++ b/server/migrations/113_mobile_push_tokens.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS mobile_push_delivery; +DROP TABLE IF EXISTS mobile_push_device_token; diff --git a/server/migrations/113_mobile_push_tokens.up.sql b/server/migrations/113_mobile_push_tokens.up.sql new file mode 100644 index 00000000000..608e808badd --- /dev/null +++ b/server/migrations/113_mobile_push_tokens.up.sql @@ -0,0 +1,35 @@ +CREATE TABLE mobile_push_device_token ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + provider TEXT NOT NULL CHECK (provider IN ('expo')), + token TEXT NOT NULL, + device_id TEXT, + platform TEXT NOT NULL DEFAULT 'ios', + app_version TEXT, + environment TEXT NOT NULL DEFAULT 'development', + enabled BOOLEAN NOT NULL DEFAULT TRUE, + last_registered_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(provider, token), + UNIQUE(user_id, workspace_id, device_id) +); + +CREATE INDEX idx_mobile_push_device_token_recipient + ON mobile_push_device_token(workspace_id, user_id) + WHERE enabled = TRUE; + +CREATE TABLE mobile_push_delivery ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + inbox_item_id UUID NOT NULL REFERENCES inbox_item(id) ON DELETE CASCADE, + device_token_id UUID NOT NULL REFERENCES mobile_push_device_token(id) ON DELETE CASCADE, + provider TEXT NOT NULL CHECK (provider IN ('expo')), + status TEXT NOT NULL CHECK (status IN ('queued', 'sent', 'failed', 'skipped')), + provider_message_id TEXT, + error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(inbox_item_id, device_token_id) +); diff --git a/server/pkg/db/generated/mobile_push.sql.go b/server/pkg/db/generated/mobile_push.sql.go new file mode 100644 index 00000000000..b2e815405e5 --- /dev/null +++ b/server/pkg/db/generated/mobile_push.sql.go @@ -0,0 +1,231 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: mobile_push.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createMobilePushDelivery = `-- name: CreateMobilePushDelivery :one +INSERT INTO mobile_push_delivery ( + inbox_item_id, device_token_id, provider, status, + provider_message_id, error +) VALUES ( + $1, $2, $3, $4, $5, $6 +) +ON CONFLICT (inbox_item_id, device_token_id) +DO UPDATE SET + status = EXCLUDED.status, + provider_message_id = EXCLUDED.provider_message_id, + error = EXCLUDED.error, + updated_at = now() +RETURNING id, inbox_item_id, device_token_id, provider, status, provider_message_id, error, created_at, updated_at +` + +type CreateMobilePushDeliveryParams struct { + InboxItemID pgtype.UUID `json:"inbox_item_id"` + DeviceTokenID pgtype.UUID `json:"device_token_id"` + Provider string `json:"provider"` + Status string `json:"status"` + ProviderMessageID pgtype.Text `json:"provider_message_id"` + Error pgtype.Text `json:"error"` +} + +func (q *Queries) CreateMobilePushDelivery(ctx context.Context, arg CreateMobilePushDeliveryParams) (MobilePushDelivery, error) { + row := q.db.QueryRow(ctx, createMobilePushDelivery, + arg.InboxItemID, + arg.DeviceTokenID, + arg.Provider, + arg.Status, + arg.ProviderMessageID, + arg.Error, + ) + var i MobilePushDelivery + err := row.Scan( + &i.ID, + &i.InboxItemID, + &i.DeviceTokenID, + &i.Provider, + &i.Status, + &i.ProviderMessageID, + &i.Error, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const disableMobilePushDeviceToken = `-- name: DisableMobilePushDeviceToken :one +UPDATE mobile_push_device_token +SET enabled = FALSE, updated_at = now() +WHERE user_id = $1 + AND workspace_id = $2 + AND provider = $3 + AND token = $4 +RETURNING id, user_id, workspace_id, provider, token, device_id, platform, app_version, environment, enabled, last_registered_at, last_seen_at, created_at, updated_at +` + +type DisableMobilePushDeviceTokenParams struct { + UserID pgtype.UUID `json:"user_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Provider string `json:"provider"` + Token string `json:"token"` +} + +func (q *Queries) DisableMobilePushDeviceToken(ctx context.Context, arg DisableMobilePushDeviceTokenParams) (MobilePushDeviceToken, error) { + row := q.db.QueryRow(ctx, disableMobilePushDeviceToken, arg.UserID, arg.WorkspaceID, arg.Provider, arg.Token) + var i MobilePushDeviceToken + err := row.Scan( + &i.ID, + &i.UserID, + &i.WorkspaceID, + &i.Provider, + &i.Token, + &i.DeviceID, + &i.Platform, + &i.AppVersion, + &i.Environment, + &i.Enabled, + &i.LastRegisteredAt, + &i.LastSeenAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const disableMobilePushDeviceTokenByID = `-- name: DisableMobilePushDeviceTokenByID :exec +UPDATE mobile_push_device_token +SET enabled = FALSE, updated_at = now() +WHERE id = $1 +` + +func (q *Queries) DisableMobilePushDeviceTokenByID(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, disableMobilePushDeviceTokenByID, id) + return err +} + +const listEnabledMobilePushTokensForInboxItem = `-- name: ListEnabledMobilePushTokensForInboxItem :many +SELECT t.id, t.user_id, t.workspace_id, t.provider, t.token, t.device_id, t.platform, t.app_version, t.environment, t.enabled, t.last_registered_at, t.last_seen_at, t.created_at, t.updated_at +FROM mobile_push_device_token t +LEFT JOIN notification_preference p + ON p.workspace_id = t.workspace_id + AND p.user_id = t.user_id +WHERE t.workspace_id = $1 + AND t.user_id = $2 + AND t.enabled = TRUE + AND COALESCE(p.preferences->>'system_notifications', 'all') <> 'muted' +ORDER BY t.last_registered_at DESC +` + +type ListEnabledMobilePushTokensForInboxItemParams struct { + WorkspaceID pgtype.UUID `json:"workspace_id"` + UserID pgtype.UUID `json:"user_id"` +} + +func (q *Queries) ListEnabledMobilePushTokensForInboxItem(ctx context.Context, arg ListEnabledMobilePushTokensForInboxItemParams) ([]MobilePushDeviceToken, error) { + rows, err := q.db.Query(ctx, listEnabledMobilePushTokensForInboxItem, arg.WorkspaceID, arg.UserID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []MobilePushDeviceToken + for rows.Next() { + var i MobilePushDeviceToken + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.WorkspaceID, + &i.Provider, + &i.Token, + &i.DeviceID, + &i.Platform, + &i.AppVersion, + &i.Environment, + &i.Enabled, + &i.LastRegisteredAt, + &i.LastSeenAt, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertMobilePushDeviceToken = `-- name: UpsertMobilePushDeviceToken :one +INSERT INTO mobile_push_device_token ( + user_id, workspace_id, provider, token, device_id, + platform, app_version, environment, enabled, + last_registered_at, last_seen_at, updated_at +) VALUES ( + $1, $2, $3, $4, $5, + $6, $7, $8, TRUE, + now(), now(), now() +) +ON CONFLICT (provider, token) +DO UPDATE SET + user_id = EXCLUDED.user_id, + workspace_id = EXCLUDED.workspace_id, + device_id = EXCLUDED.device_id, + platform = EXCLUDED.platform, + app_version = EXCLUDED.app_version, + environment = EXCLUDED.environment, + enabled = TRUE, + last_registered_at = now(), + last_seen_at = now(), + updated_at = now() +RETURNING id, user_id, workspace_id, provider, token, device_id, platform, app_version, environment, enabled, last_registered_at, last_seen_at, created_at, updated_at +` + +type UpsertMobilePushDeviceTokenParams struct { + UserID pgtype.UUID `json:"user_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Provider string `json:"provider"` + Token string `json:"token"` + DeviceID pgtype.Text `json:"device_id"` + Platform string `json:"platform"` + AppVersion pgtype.Text `json:"app_version"` + Environment string `json:"environment"` +} + +func (q *Queries) UpsertMobilePushDeviceToken(ctx context.Context, arg UpsertMobilePushDeviceTokenParams) (MobilePushDeviceToken, error) { + row := q.db.QueryRow(ctx, upsertMobilePushDeviceToken, + arg.UserID, + arg.WorkspaceID, + arg.Provider, + arg.Token, + arg.DeviceID, + arg.Platform, + arg.AppVersion, + arg.Environment, + ) + var i MobilePushDeviceToken + err := row.Scan( + &i.ID, + &i.UserID, + &i.WorkspaceID, + &i.Provider, + &i.Token, + &i.DeviceID, + &i.Platform, + &i.AppVersion, + &i.Environment, + &i.Enabled, + &i.LastRegisteredAt, + &i.LastSeenAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go index 901d8e9a2a3..760f834ea43 100644 --- a/server/pkg/db/generated/models.go +++ b/server/pkg/db/generated/models.go @@ -457,6 +457,35 @@ type NotificationPreference struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } +type MobilePushDeviceToken struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Provider string `json:"provider"` + Token string `json:"token"` + DeviceID pgtype.Text `json:"device_id"` + Platform string `json:"platform"` + AppVersion pgtype.Text `json:"app_version"` + Environment string `json:"environment"` + Enabled bool `json:"enabled"` + LastRegisteredAt pgtype.Timestamptz `json:"last_registered_at"` + LastSeenAt pgtype.Timestamptz `json:"last_seen_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type MobilePushDelivery struct { + ID pgtype.UUID `json:"id"` + InboxItemID pgtype.UUID `json:"inbox_item_id"` + DeviceTokenID pgtype.UUID `json:"device_token_id"` + Provider string `json:"provider"` + Status string `json:"status"` + ProviderMessageID pgtype.Text `json:"provider_message_id"` + Error pgtype.Text `json:"error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type PersonalAccessToken struct { ID pgtype.UUID `json:"id"` UserID pgtype.UUID `json:"user_id"` diff --git a/server/pkg/db/queries/mobile_push.sql b/server/pkg/db/queries/mobile_push.sql new file mode 100644 index 00000000000..2eb8582e613 --- /dev/null +++ b/server/pkg/db/queries/mobile_push.sql @@ -0,0 +1,64 @@ +-- name: UpsertMobilePushDeviceToken :one +INSERT INTO mobile_push_device_token ( + user_id, workspace_id, provider, token, device_id, + platform, app_version, environment, enabled, + last_registered_at, last_seen_at, updated_at +) VALUES ( + $1, $2, $3, $4, sqlc.narg('device_id'), + $5, sqlc.narg('app_version'), $6, TRUE, + now(), now(), now() +) +ON CONFLICT (provider, token) +DO UPDATE SET + user_id = EXCLUDED.user_id, + workspace_id = EXCLUDED.workspace_id, + device_id = EXCLUDED.device_id, + platform = EXCLUDED.platform, + app_version = EXCLUDED.app_version, + environment = EXCLUDED.environment, + enabled = TRUE, + last_registered_at = now(), + last_seen_at = now(), + updated_at = now() +RETURNING *; + +-- name: DisableMobilePushDeviceToken :one +UPDATE mobile_push_device_token +SET enabled = FALSE, updated_at = now() +WHERE user_id = $1 + AND workspace_id = $2 + AND provider = $3 + AND token = $4 +RETURNING *; + +-- name: DisableMobilePushDeviceTokenByID :exec +UPDATE mobile_push_device_token +SET enabled = FALSE, updated_at = now() +WHERE id = $1; + +-- name: ListEnabledMobilePushTokensForInboxItem :many +SELECT t.* +FROM mobile_push_device_token t +LEFT JOIN notification_preference p + ON p.workspace_id = t.workspace_id + AND p.user_id = t.user_id +WHERE t.workspace_id = $1 + AND t.user_id = $2 + AND t.enabled = TRUE + AND COALESCE(p.preferences->>'system_notifications', 'all') <> 'muted' +ORDER BY t.last_registered_at DESC; + +-- name: CreateMobilePushDelivery :one +INSERT INTO mobile_push_delivery ( + inbox_item_id, device_token_id, provider, status, + provider_message_id, error +) VALUES ( + $1, $2, $3, $4, sqlc.narg('provider_message_id'), sqlc.narg('error') +) +ON CONFLICT (inbox_item_id, device_token_id) +DO UPDATE SET + status = EXCLUDED.status, + provider_message_id = EXCLUDED.provider_message_id, + error = EXCLUDED.error, + updated_at = now() +RETURNING *;