Skip to content

Commit 8de3255

Browse files
committed
Merge branch 'development' into staging
2 parents 00a3644 + 5b3856b commit 8de3255

14 files changed

Lines changed: 496 additions & 153 deletions

File tree

src/client/.env.selfhost.template

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ APP_BASE_URL=http://localhost:3000
2020
# This MUST match the `SELF_HOST_AUTH_SECRET` in the server's .env.selfhost file.
2121
SELF_HOST_AUTH_TOKEN=<use_the_same_strong_secret_as_in_the_root_env>
2222

23+
# --- Database (for Server Actions) ---
24+
# This is the internal URI for the MongoDB service within Docker.
25+
# It MUST include the credentials defined in the root .env file.
26+
MONGO_URI=mongodb://<user_from_root_env>:<pass_from_root_env>@mongodb:27017/
27+
MONGO_DB_NAME=sentient_selfhost_db
28+
2329
# Auth0 variables are not used in selfhost mode, but are kept here
2430
# to avoid breaking any code that might reference them before a check.
2531
# The build process requires them to be present in some form.
@@ -38,4 +44,7 @@ NEXT_PUBLIC_POSTHOG_KEY=
3844
NEXT_PUBLIC_POSTHOG_HOST=
3945
# --- PWA Push Notifications (Optional) ---
4046
# Generate VAPID keys using `npx web-push generate-vapid-keys` and add the public key here.
41-
# This MUST match the public key derived from VAPID_PRIVATE_KEY in the server's .env.
47+
# The private key is also needed here for Next.js Server Actions.
48+
NEXT_PUBLIC_VAPID_PUBLIC_KEY=
49+
VAPID_PRIVATE_KEY=
50+
VAPID_ADMIN_EMAIL=mailto:your-email@example.com

src/client/.env.template

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,18 @@ AUTH0_CLIENT_SECRET=
1414
AUTH0_AUDIENCE=
1515
AUTH0_SCOPE='openid profile email offline_access read:profile write:profile read:tasks write:tasks read:notifications read:config write:config admin:user_metadata read:contacts write:contacts'
1616

17+
# --- Database (for Server Actions) ---
18+
# The full connection string for your MongoDB Atlas cluster.
19+
MONGO_URI=
20+
MONGO_DB_NAME=
21+
1722
# --- Analytics (Optional) ---
1823
# If you want to enable product analytics, provide your PostHog project key.
1924
NEXT_PUBLIC_POSTHOG_KEY=
2025
# If using a self-hosted PostHog instance, provide the host URL. Otherwise, leave it empty to default to PostHog's US cloud.
2126
NEXT_PUBLIC_POSTHOG_HOST=
2227
# --- PWA Push Notifications (Optional) ---
23-
# Generate VAPID keys using `npx web-push generate-vapid-keys` and add the public key here.
28+
# Generate VAPID keys using `npx web-push generate-vapid-keys`.
2429
NEXT_PUBLIC_VAPID_PUBLIC_KEY=
30+
VAPID_PRIVATE_KEY=
31+
VAPID_ADMIN_EMAIL=mailto:your-email@example.com

src/client/.gitignore

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,5 @@ yarn-error.log*
3737
*.tsbuildinfo
3838
next-env.d.ts
3939

40-
# PWA files
41-
/public/sw.js
42-
/public/workbox-*.js
43-
4440
# End of https://www.toptal.com/developers/gitignore/api/nextjs
4541
certificates

src/client/app/actions.js

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
"use server"
2+
3+
import { auth0 } from "@lib/auth0"
4+
import { MongoClient } from "mongodb"
5+
import webpush from "web-push"
6+
7+
// --- DB Connection ---
8+
const MONGO_URI = process.env.MONGO_URI
9+
const MONGO_DB_NAME = process.env.MONGO_DB_NAME
10+
11+
let cachedClient = null
12+
let cachedDb = null
13+
14+
async function connectToDatabase() {
15+
if (cachedClient && cachedDb) {
16+
return { client: cachedClient, db: cachedDb }
17+
}
18+
19+
if (!MONGO_URI) {
20+
throw new Error(
21+
"MONGO_URI is not defined in the environment variables."
22+
)
23+
}
24+
25+
const client = new MongoClient(MONGO_URI)
26+
await client.connect()
27+
const db = client.db(MONGO_DB_NAME)
28+
29+
cachedClient = client
30+
cachedDb = db
31+
32+
return { client, db }
33+
}
34+
35+
// --- WebPush Setup ---
36+
if (
37+
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY &&
38+
process.env.VAPID_PRIVATE_KEY &&
39+
process.env.VAPID_ADMIN_EMAIL
40+
) {
41+
webpush.setVapidDetails(
42+
process.env.VAPID_ADMIN_EMAIL,
43+
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY,
44+
process.env.VAPID_PRIVATE_KEY
45+
)
46+
} else {
47+
console.warn(
48+
"VAPID details not fully configured. Push notifications may not work."
49+
)
50+
}
51+
52+
// --- Server Actions ---
53+
54+
export async function subscribeUser(subscription) {
55+
const session = await auth0.getSession()
56+
if (!session?.user) {
57+
throw new Error("Not authenticated")
58+
}
59+
const userId = session.user.sub
60+
61+
try {
62+
const { db } = await connectToDatabase()
63+
const userProfiles = db.collection("user_profiles")
64+
65+
await userProfiles.updateOne(
66+
{ user_id: userId },
67+
{ $set: { "userData.pwa_subscription": subscription } },
68+
{ upsert: true }
69+
)
70+
71+
console.log(`[Actions] Subscription saved for user: ${userId}`)
72+
return { success: true }
73+
} catch (error) {
74+
console.error("[Actions] Error saving subscription:", error)
75+
return {
76+
success: false,
77+
error: "Failed to save subscription to database."
78+
}
79+
}
80+
}
81+
82+
export async function unsubscribeUser() {
83+
const session = await auth0.getSession()
84+
if (!session?.user) {
85+
throw new Error("Not authenticated")
86+
}
87+
const userId = session.user.sub
88+
89+
try {
90+
const { db } = await connectToDatabase()
91+
const userProfiles = db.collection("user_profiles")
92+
93+
await userProfiles.updateOne(
94+
{ user_id: userId },
95+
{ $unset: { "userData.pwa_subscription": "" } }
96+
)
97+
98+
console.log(`[Actions] Subscription removed for user: ${userId}`)
99+
return { success: true }
100+
} catch (error) {
101+
console.error("[Actions] Error removing subscription:", error)
102+
return {
103+
success: false,
104+
error: "Failed to remove subscription from database."
105+
}
106+
}
107+
}
108+
109+
export async function sendNotificationToCurrentUser(payload) {
110+
const session = await auth0.getSession()
111+
if (!session?.user) {
112+
throw new Error("Not authenticated")
113+
}
114+
const userId = session.user.sub
115+
116+
if (
117+
!process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY ||
118+
!process.env.VAPID_PRIVATE_KEY
119+
) {
120+
console.error(
121+
"[Actions] VAPID keys not configured. Cannot send push notification."
122+
)
123+
return {
124+
success: false,
125+
error: "VAPID keys not configured on the server."
126+
}
127+
}
128+
129+
try {
130+
const { db } = await connectToDatabase()
131+
const userProfile = await db
132+
.collection("user_profiles")
133+
.findOne(
134+
{ user_id: userId },
135+
{ projection: { "userData.pwa_subscription": 1 } }
136+
)
137+
138+
const subscription = userProfile?.userData?.pwa_subscription
139+
140+
if (!subscription) {
141+
console.log(
142+
`[Actions] No push subscription found for user ${userId}.`
143+
)
144+
return { success: false, error: "No subscription found for user." }
145+
}
146+
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+
} catch (error) {
154+
console.error(
155+
`[Actions] Error sending push notification to user ${userId}:`,
156+
error
157+
)
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." }
169+
}
170+
}

src/client/components/LayoutWrapper.js

Lines changed: 44 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,22 @@ import GlobalSearch from "./GlobalSearch"
1010
import { useGlobalShortcuts } from "@hooks/useGlobalShortcuts"
1111
import { cn } from "@utils/cn"
1212
import toast from "react-hot-toast"
13+
import { subscribeUser } from "@app/actions"
1314

1415
// Helper function to convert VAPID key
1516
function urlBase64ToUint8Array(base64String) {
16-
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
17-
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
18-
const rawData = window.atob(base64);
19-
const outputArray = new Uint8Array(rawData.length);
17+
const padding = "=".repeat((4 - (base64String.length % 4)) % 4)
18+
const base64 = (base64String + padding)
19+
.replace(/-/g, "+")
20+
.replace(/_/g, "/")
21+
const rawData = atob(base64)
22+
const outputArray = new Uint8Array(rawData.length)
2023
for (let i = 0; i < rawData.length; ++i) {
21-
outputArray[i] = rawData.charCodeAt(i);
24+
outputArray[i] = rawData.charCodeAt(i)
2225
}
23-
return outputArray;
26+
return outputArray
2427
}
28+
2529
export default function LayoutWrapper({ children }) {
2630
const [isNotificationsOpen, setNotificationsOpen] = useState(false)
2731
const [isSearchOpen, setSearchOpen] = useState(false)
@@ -189,11 +193,9 @@ export default function LayoutWrapper({ children }) {
189193
// PWA Update Handler
190194

191195
useEffect(() => {
192-
// This effect runs only on the client side, after the component mounts.
193-
if (
194-
"serviceWorker" in navigator &&
195-
process.env.NODE_ENV !== "development"
196-
) {
196+
// This effect runs only on the client side to register the service worker.
197+
// It's enabled for all environments to allow testing in development.
198+
if ("serviceWorker" in navigator) {
197199
// The 'load' event ensures that SW registration doesn't delay page rendering.
198200
window.addEventListener("load", function () {
199201
navigator.serviceWorker.register("/sw.js").then(
@@ -265,73 +267,51 @@ export default function LayoutWrapper({ children }) {
265267
}
266268
}, [])
267269

268-
useEffect(() => {
269-
// This effect runs only on the client side, after the component mounts.
270-
if (
271-
"serviceWorker" in navigator &&
272-
process.env.NODE_ENV === "production"
273-
) {
274-
// The 'load' event ensures that SW registration doesn't delay page rendering.
275-
window.addEventListener("load", function () {
276-
navigator.serviceWorker.register("/sw.js").then(
277-
function (registration) {
278-
console.log(
279-
"ServiceWorker registration successful with scope: ",
280-
registration.scope
281-
)
282-
},
283-
function (err) {
284-
console.log("ServiceWorker registration failed: ", err)
285-
}
286-
)
287-
})
288-
}
289-
}, [])
290-
291270
const subscribeToPushNotifications = useCallback(async () => {
292-
if (!("serviceWorker" in navigator) || !("PushManager" in window)) return;
271+
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
272+
console.log("Push notifications not supported by this browser.")
273+
return
274+
}
293275
if (!process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY) {
294-
console.warn("VAPID public key not configured. Skipping push subscription.");
295-
return;
276+
console.warn(
277+
"VAPID public key not configured. Skipping push subscription."
278+
)
279+
return
296280
}
297-
console.log("VAPID Public Key is configured. Proceeding with push subscription.");
298281

299282
try {
300-
const registration = await navigator.serviceWorker.ready;
301-
console.log("Service Worker is ready:", registration);
302-
303-
let subscription = await registration.pushManager.getSubscription();
304-
console.log("Existing subscription:", subscription);
283+
const registration = await navigator.serviceWorker.ready
284+
let subscription = await registration.pushManager.getSubscription()
305285

306286
if (subscription === null) {
307-
console.log("No existing subscription found. Requesting permission...");
308-
const permission = await window.Notification.requestPermission();
309-
console.log("Notification permission status:", permission);
287+
const permission = await window.Notification.requestPermission()
288+
if (permission !== "granted") {
289+
console.log("Notification permission not granted.")
290+
return
291+
}
310292

311-
if (permission !== "granted") return;
293+
console.log("Permission granted. Subscribing...")
294+
const applicationServerKey = urlBase64ToUint8Array(
295+
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY
296+
)
312297

313-
console.log("Permission granted. Subscribing to push manager...");
314298
subscription = await registration.pushManager.subscribe({
315299
userVisibleOnly: true,
316-
applicationServerKey: urlBase64ToUint8Array(process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY),
317-
});
318-
console.log("New subscription created:", subscription);
319-
320-
const response = await fetch("/api/notifications/subscribe", {
321-
method: "POST",
322-
headers: { "Content-Type": "application/json" },
323-
body: JSON.stringify(subscription),
324-
});
325-
326-
if (!response.ok) {
327-
throw new Error("Failed to send subscription to server.");
328-
}
329-
console.log("Subscription successfully sent to server.");
300+
applicationServerKey
301+
})
302+
303+
console.log("New subscription created:", subscription)
304+
const serializedSub = JSON.parse(JSON.stringify(subscription))
305+
await subscribeUser(serializedSub)
306+
toast.success("Subscribed to push notifications!")
307+
} else {
308+
console.log("User is already subscribed.")
330309
}
331310
} catch (error) {
332-
console.error("Error during push notification subscription:", error);
311+
console.error("Error during push notification subscription:", error)
312+
toast.error("Failed to subscribe to push notifications.")
333313
}
334-
}, []);
314+
}, [])
335315

336316
useEffect(() => {
337317
if (showNav && userDetails?.sub) subscribeToPushNotifications()
@@ -406,4 +386,3 @@ export default function LayoutWrapper({ children }) {
406386
</>
407387
)
408388
}
409-

0 commit comments

Comments
 (0)