Skip to content

Commit db5b042

Browse files
committed
fix (notifications): push notifications
1 parent c370e72 commit db5b042

File tree

7 files changed

+183
-30
lines changed

7 files changed

+183
-30
lines changed

src/client/app/actions.js

Lines changed: 52 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export async function subscribeUser(subscription) {
6464

6565
await userProfiles.updateOne(
6666
{ user_id: userId },
67-
{ $set: { "userData.pwa_subscription": subscription } },
67+
{ $addToSet: { "userData.pwa_subscriptions": subscription } },
6868
{ upsert: true }
6969
)
7070

@@ -79,7 +79,7 @@ export async function subscribeUser(subscription) {
7979
}
8080
}
8181

82-
export async function unsubscribeUser() {
82+
export async function unsubscribeUser(endpoint) {
8383
const session = await auth0.getSession()
8484
if (!session?.user) {
8585
throw new Error("Not authenticated")
@@ -92,10 +92,12 @@ export async function unsubscribeUser() {
9292

9393
await userProfiles.updateOne(
9494
{ user_id: userId },
95-
{ $unset: { "userData.pwa_subscription": "" } }
95+
{ $pull: { "userData.pwa_subscriptions": { endpoint: endpoint } } }
9696
)
9797

98-
console.log(`[Actions] Subscription removed for user: ${userId}`)
98+
console.log(
99+
`[Actions] Subscription with endpoint ${endpoint} removed for user: ${userId}`
100+
)
99101
return { success: true }
100102
} catch (error) {
101103
console.error("[Actions] Error removing subscription:", error)
@@ -132,39 +134,64 @@ export async function sendNotificationToCurrentUser(payload) {
132134
.collection("user_profiles")
133135
.findOne(
134136
{ user_id: userId },
135-
{ projection: { "userData.pwa_subscription": 1 } }
137+
{ projection: { "userData.pwa_subscriptions": 1 } }
136138
)
137139

138-
const subscription = userProfile?.userData?.pwa_subscription
140+
const subscriptions = userProfile?.userData?.pwa_subscriptions
139141

140-
if (!subscription) {
142+
if (
143+
!subscriptions ||
144+
!Array.isArray(subscriptions) ||
145+
subscriptions.length === 0
146+
) {
141147
console.log(
142148
`[Actions] No push subscription found for user ${userId}.`
143149
)
144150
return { success: false, error: "No subscription found for user." }
145151
}
146152

147-
await webpush.sendNotification(subscription, JSON.stringify(payload))
148-
149-
console.log(
150-
`[Actions] Push notification sent successfully to user ${userId}.`
151-
)
152-
return { success: true }
153+
let successCount = 0
154+
const promises = subscriptions.map((subscription) => {
155+
return webpush
156+
.sendNotification(subscription, JSON.stringify(payload))
157+
.then(() => {
158+
successCount++
159+
console.log(
160+
`[Actions] Push notification sent successfully to endpoint for user ${userId}.`
161+
)
162+
})
163+
.catch(async (error) => {
164+
console.error(
165+
`[Actions] Error sending push notification to an endpoint for user ${userId}:`,
166+
error.statusCode
167+
)
168+
if (error.statusCode === 410 || error.statusCode === 404) {
169+
console.log(
170+
`[Actions] Subscription for user ${userId} is invalid. Removing from DB.`
171+
)
172+
await unsubscribeUser(subscription.endpoint)
173+
}
174+
})
175+
})
176+
177+
await Promise.all(promises)
178+
179+
if (successCount > 0) {
180+
return {
181+
success: true,
182+
message: `Sent notifications to ${successCount} of ${subscriptions.length} devices.`
183+
}
184+
} else {
185+
return {
186+
success: false,
187+
error: "Failed to send notifications to any device."
188+
}
189+
}
153190
} catch (error) {
154191
console.error(
155-
`[Actions] Error sending push notification to user ${userId}:`,
192+
`[Actions] General error sending push notifications to user ${userId}:`,
156193
error
157194
)
158-
159-
// If the subscription is expired or invalid, the push service returns an error (e.g., 410 Gone).
160-
// We should handle this by removing the invalid subscription from the database.
161-
if (error.statusCode === 410 || error.statusCode === 404) {
162-
console.log(
163-
`[Actions] Subscription for user ${userId} is invalid. Removing from DB.`
164-
)
165-
await unsubscribeUser()
166-
}
167-
168-
return { success: false, error: "Failed to send push notification." }
195+
return { success: false, error: "A general error occurred." }
169196
}
170197
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { NextResponse } from "next/server"
2+
import { withAuth } from "@lib/api-utils"
3+
4+
const appServerUrl =
5+
process.env.NEXT_PUBLIC_ENVIRONMENT === "selfhost"
6+
? process.env.INTERNAL_APP_SERVER_URL
7+
: process.env.NEXT_PUBLIC_APP_SERVER_URL
8+
9+
export const POST = withAuth(async function POST(request, { authHeader }) {
10+
try {
11+
const body = await request.json() // { type: 'in-app' }
12+
const response = await fetch(`${appServerUrl}/testing/notification`, {
13+
method: "POST",
14+
headers: { "Content-Type": "application/json", ...authHeader },
15+
body: JSON.stringify(body)
16+
})
17+
18+
const data = await response.json()
19+
if (!response.ok) {
20+
throw new Error(data.detail || "Failed to send test notification")
21+
}
22+
return NextResponse.json(data)
23+
} catch (error) {
24+
console.error("API Error in /testing/notification:", error)
25+
return NextResponse.json({ detail: error.message }, { status: 500 })
26+
}
27+
})

src/client/app/settings/page.js

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { Switch } from "@headlessui/react"
2929

3030
import InteractiveNetworkBackground from "@components/ui/InteractiveNetworkBackground"
3131
import CollapsibleSection from "@components/tasks/CollapsibleSection"
32+
import { sendNotificationToCurrentUser } from "@app/actions"
3233

3334
const HelpTooltip = ({ content }) => (
3435
<div className="fixed bottom-6 left-6 z-40">
@@ -53,6 +54,48 @@ const questionSections = {
5354
}
5455
}
5556

57+
const handleTestInApp = async () => {
58+
const toastId = toast.loading("Sending test in-app notification...")
59+
try {
60+
const response = await fetch("/api/testing/notification", {
61+
method: "POST",
62+
headers: { "Content-Type": "application/json" },
63+
body: JSON.stringify({ type: "in-app" })
64+
})
65+
const result = await response.json()
66+
if (!response.ok) {
67+
throw new Error(result.detail || "Failed to send notification.")
68+
}
69+
// The notification will arrive via WebSocket, so no success toast here.
70+
// The LayoutWrapper will show the toast. I'll just dismiss the loading one.
71+
toast.dismiss(toastId)
72+
toast("In-app notification sent. It should appear shortly.")
73+
} catch (error) {
74+
toast.error(`Error: ${error.message}`, { id: toastId })
75+
}
76+
}
77+
78+
const handleTestPush = async () => {
79+
const toastId = toast.loading("Sending test push notification...")
80+
try {
81+
const result = await sendNotificationToCurrentUser({
82+
title: "Test Push Notification",
83+
body: "This is a test push notification from Sentient.",
84+
data: { url: "/tasks" } // Example data
85+
})
86+
if (result.success) {
87+
toast.success(
88+
result.message || "Push notification sent successfully!",
89+
{ id: toastId }
90+
)
91+
} else {
92+
toast.error(`Failed to send: ${result.error}`, { id: toastId })
93+
}
94+
} catch (error) {
95+
toast.error(`Error: ${error.message}`, { id: toastId })
96+
}
97+
}
98+
5699
const questions = [
57100
{
58101
id: "user-name",
@@ -629,6 +672,31 @@ const TestingTools = () => {
629672
</p>
630673
)}
631674
</div>
675+
{/* Notification Test Tools */}
676+
<div className="bg-neutral-900/50 p-6 rounded-2xl border border-neutral-800 mt-6">
677+
<h3 className="font-semibold text-lg text-white mb-2">
678+
Test Notifications
679+
</h3>
680+
<p className="text-gray-400 text-sm mb-4">
681+
Send test notifications to verify your setup. In-app
682+
notifications appear as toasts, while push notifications are
683+
sent to your subscribed devices.
684+
</p>
685+
<div className="flex flex-col sm:flex-row gap-4">
686+
<button
687+
onClick={handleTestInApp}
688+
className="flex items-center justify-center py-2 px-4 rounded-lg bg-blue-600 hover:bg-blue-500 text-white font-medium transition-colors"
689+
>
690+
Test In-App Notification
691+
</button>
692+
<button
693+
onClick={handleTestPush}
694+
className="flex items-center justify-center py-2 px-4 rounded-lg bg-green-600 hover:bg-green-500 text-white font-medium transition-colors"
695+
>
696+
Test Push Notification
697+
</button>
698+
</div>
699+
</div>
632700
{/* Poller Test Tool */}
633701
<div className="bg-neutral-900/50 p-6 rounded-2xl border border-neutral-800 mt-6">
634702
<h3 className="font-semibold text-lg text-white mb-2">

src/client/components/LayoutWrapper.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export default function LayoutWrapper({ children }) {
3333
const [isSidebarCollapsed, setSidebarCollapsed] = useState(true)
3434
const [isMobileNavOpen, setMobileNavOpen] = useState(false)
3535
const [unreadCount, setUnreadCount] = useState(0)
36+
const [notifRefreshKey, setNotifRefreshKey] = useState(0)
3637
const [userDetails, setUserDetails] = useState(null)
3738
const wsRef = useRef(null)
3839
const pathname = usePathname()
@@ -116,6 +117,7 @@ export default function LayoutWrapper({ children }) {
116117
const data = JSON.parse(event.data)
117118
if (data.type === "new_notification") {
118119
setUnreadCount((prev) => prev + 1)
120+
setNotifRefreshKey((prev) => prev + 1)
119121
toast(
120122
(t) => (
121123
<div className="flex items-center gap-3">
@@ -374,6 +376,7 @@ export default function LayoutWrapper({ children }) {
374376
<AnimatePresence>
375377
{isNotificationsOpen && (
376378
<NotificationsOverlay
379+
notifRefreshKey={notifRefreshKey}
377380
onClose={() => setNotificationsOpen(false)}
378381
/>
379382
)}

src/client/components/NotificationsOverlay.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ const NotificationItem = ({
216216
)
217217
}
218218

219-
const NotificationsOverlay = ({ onClose }) => {
219+
const NotificationsOverlay = ({ onClose, notifRefreshKey }) => {
220220
const [notifications, setNotifications] = useState([])
221221
const [isLoading, setIsLoading] = useState(true)
222222
const [error, setError] = useState(null)
@@ -271,7 +271,7 @@ const NotificationsOverlay = ({ onClose }) => {
271271
useEffect(() => {
272272
fetchNotifications()
273273
fetchUserTimezone()
274-
}, [fetchNotifications, fetchUserTimezone])
274+
}, [fetchNotifications, fetchUserTimezone, notifRefreshKey])
275275

276276
const handleDelete = async (e, notificationId) => {
277277
if (e && e.stopPropagation) e.stopPropagation()

src/server/main/testing/models.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,6 @@ class ContextInjectionRequest(BaseModel):
66
event_data: Dict[str, Any]
77

88
class WhatsAppTestRequest(BaseModel):
9-
phone_number: str
9+
phone_number: str
10+
class TestNotificationRequest(BaseModel):
11+
type: str

src/server/main/testing/routes.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,17 @@
33
import json
44
from typing import List
55

6-
from fastapi import APIRouter, Depends, HTTPException, status
6+
from fastapi import APIRouter, Depends, HTTPException, status, Body
77

88
from main.config import ENVIRONMENT
99
from main.dependencies import auth_helper
1010
from main.notifications.whatsapp_client import (check_phone_number_exists,
1111
send_whatsapp_message)
12+
from main.notifications.utils import create_and_push_notification
1213
from workers.tasks import (cud_memory_task, proactive_reasoning_pipeline, run_due_tasks,
1314
schedule_proactivity_polling, schedule_trigger_polling)
1415

15-
from .models import ContextInjectionRequest, WhatsAppTestRequest
16+
from .models import ContextInjectionRequest, WhatsAppTestRequest, TestNotificationRequest
1617

1718
logger = logging.getLogger(__name__)
1819
router = APIRouter(
@@ -148,6 +149,31 @@ async def trigger_poller(
148149
)
149150

150151

152+
@router.post("/notification", summary="Send a test notification")
153+
async def send_test_notification(
154+
request: TestNotificationRequest,
155+
user_id: str = Depends(auth_helper.get_current_user_id)
156+
):
157+
_check_allowed_environments(
158+
["dev-local", "selfhost"],
159+
"This endpoint is only available in development or self-host environments."
160+
)
161+
162+
if request.type == "in-app":
163+
try:
164+
await create_and_push_notification(
165+
user_id=user_id,
166+
message="This is a test in-app notification from the developer tools.",
167+
notification_type="general"
168+
)
169+
return {"message": "Test in-app notification sent successfully."}
170+
except Exception as e:
171+
logger.error(f"Failed to send test in-app notification for user {user_id}: {e}", exc_info=True)
172+
raise HTTPException(status_code=500, detail="Failed to send test notification.")
173+
else:
174+
raise HTTPException(status_code=400, detail="Invalid notification type specified.")
175+
176+
151177
@router.post("/whatsapp/verify", summary="Verify if a WhatsApp number exists")
152178
async def verify_whatsapp_number(
153179
request: WhatsAppTestRequest,

0 commit comments

Comments
 (0)