diff --git a/app/api/links/[id]/route.ts b/app/api/links/[id]/route.ts index a3e7fe67..67f8c5c6 100644 --- a/app/api/links/[id]/route.ts +++ b/app/api/links/[id]/route.ts @@ -3,6 +3,7 @@ import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import prisma from "@/lib/prisma"; import { Prisma } from "@prisma/client"; +import { triggerPusherEvent } from "@/lib/pusher"; import { resolveActiveWorkspace } from "@/lib/workspace"; import { validatePlatformUrl, detectPlatform, slugifyPlatform, isKnownPlatform, type Platform } from "@/lib/platforms"; @@ -225,6 +226,8 @@ export async function PUT( // The updated link may be rendered on the public profile — purge the cache. await invalidateProfileCache(link.workspaceId); + await triggerPusherEvent(`private-user-${session.user.id}`, 'links-updated', { workspaceId: link.workspaceId }); + return NextResponse.json({ success: true, link: updatedLink }); } catch (err: unknown) { const error = err as { code?: string; proposedRoute?: string }; @@ -330,6 +333,8 @@ export async function DELETE( // Deleted links disappear from the public profile — purge the cache. await invalidateProfileCache(link.workspaceId); + await triggerPusherEvent(`private-user-${session.user.id}`, 'links-updated', { workspaceId: link.workspaceId }); + return NextResponse.json({ success: true }); } @@ -341,6 +346,8 @@ export async function DELETE( // Deleted links disappear from the public profile — purge the cache. await invalidateProfileCache(link.workspaceId); + await triggerPusherEvent(`private-user-${session.user.id}`, 'links-updated', { workspaceId: link.workspaceId }); + return NextResponse.json({ success: true }); } diff --git a/app/api/links/reorder/route.ts b/app/api/links/reorder/route.ts index 7172a204..f37b512d 100644 --- a/app/api/links/reorder/route.ts +++ b/app/api/links/reorder/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import prisma from "@/lib/prisma"; +import { triggerPusherEvent } from "@/lib/pusher"; import { resolveActiveWorkspace } from "@/lib/workspace"; import { invalidateProfileCache } from "@/lib/profileCache"; @@ -158,6 +159,8 @@ export async function POST(req: Request) { // Link order is part of the public profile payload — purge the cache. await invalidateProfileCache(workspace.id); + await triggerPusherEvent(`private-user-${session.user.id}`, 'links-updated', { workspaceId: workspace.id }); + return NextResponse.json({ ok: true, changed: updates.length }); } catch (err) { console.error("/api/links/reorder error", err); diff --git a/app/api/links/route.ts b/app/api/links/route.ts index 22d268b8..cd4bc381 100644 --- a/app/api/links/route.ts +++ b/app/api/links/route.ts @@ -3,6 +3,7 @@ import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import prisma from "@/lib/prisma"; import { Prisma } from "@prisma/client"; +import { triggerPusherEvent } from "@/lib/pusher"; import { resolveActiveWorkspace } from "@/lib/workspace"; import { @@ -83,6 +84,8 @@ export async function POST(req: NextRequest) { // New link is public — purge the cached public profile. await invalidateProfileCache(workspace.id); + await triggerPusherEvent(`private-user-${session.user.id}`, 'links-updated', { workspaceId: workspace.id }); + return NextResponse.json({ link: { ...link, children: [] } }); } catch (err: unknown) { const error = err as { code?: string }; @@ -242,6 +245,8 @@ export async function POST(req: NextRequest) { // New link is public — purge the cached public profile. await invalidateProfileCache(workspace.id); + await triggerPusherEvent(`private-user-${session.user.id}`, 'links-updated', { workspaceId: workspace.id }); + return NextResponse.json({ link }); } catch (err: unknown) { const error = err as { code?: string }; diff --git a/app/api/pusher/auth/route.ts b/app/api/pusher/auth/route.ts new file mode 100644 index 00000000..2be3452a --- /dev/null +++ b/app/api/pusher/auth/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { pusherServer } from '@/lib/pusher'; + +export async function POST(req: Request) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.id) { + return new NextResponse('Unauthorized', { status: 401 }); + } + + const data = await req.text(); + const params = new URLSearchParams(data); + const socketId = params.get('socket_id'); + const channelName = params.get('channel_name'); + + if (!socketId || !channelName) { + return new NextResponse('Missing socket_id or channel_name', { status: 400 }); + } + + // Ensure users can only subscribe to their own private channel + if (channelName !== `private-user-${session.user.id}`) { + return new NextResponse('Forbidden', { status: 403 }); + } + + const authResponse = pusherServer.authorizeChannel(socketId, channelName); + return NextResponse.json(authResponse); + } catch (error) { + console.error('Pusher auth error:', error); + return new NextResponse('Internal Server Error', { status: 500 }); + } +} diff --git a/app/dashboard/DashboardClient.tsx b/app/dashboard/DashboardClient.tsx index 6a5826e8..6b1aec95 100644 --- a/app/dashboard/DashboardClient.tsx +++ b/app/dashboard/DashboardClient.tsx @@ -1,5 +1,7 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { getPusherClient } from "@/lib/pusher"; import { DashboardNavbar } from "@/app/components/DashboardNavbar"; import { getCsrfToken } from "@/lib/csrfClient"; import toast, { Toaster } from "react-hot-toast"; @@ -14,6 +16,7 @@ import { LayoutStyle } from "@/app/[username]/types/type"; import { LivePreview } from "@/components/dashboard/LivePreview"; export default function DashboardClient({ + userId, workspaceId, username, initialLinks, @@ -33,6 +36,7 @@ export default function DashboardClient({ initialThemeColor, initialThemeCustom, }: { + userId?: string; workspaceId: string; username: string; initialLinks: ProfileLink[]; @@ -52,7 +56,32 @@ export default function DashboardClient({ initialThemeColor?: string; initialThemeCustom?: string | null; }) { + const router = useRouter(); const [links, setLinks] = useState(initialLinks); + + useEffect(() => { + setLinks(initialLinks); + }, [initialLinks]); + + useEffect(() => { + if (!userId) return; + + const pusher = getPusherClient(); + if (!pusher) return; + + const channelName = `private-user-${userId}`; + const channel = pusher.subscribe(channelName); + + channel.bind('links-updated', () => { + router.refresh(); + }); + + return () => { + channel.unbind_all(); + pusher.unsubscribe(channelName); + }; + }, [userId, router]); + const [theme, setTheme] = useState(initialTheme || "default"); const [layoutStyle, setLayoutStyle] = useState(initialLayout || "LIST"); const [backgroundImage, setBackgroundImage] = useState(initialBackgroundImage || ""); diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 46b8651f..680edfd9 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -47,6 +47,7 @@ export default async function DashboardPage() { return ( { + if (!process.env.PUSHER_APP_ID || !process.env.PUSHER_SECRET || !process.env.NEXT_PUBLIC_PUSHER_KEY) { + return; + } + try { + await pusherServer.trigger(channel, event, data); + } catch (err) { + console.error('Failed to trigger Pusher event:', err); + } +}; + +let clientInstance: PusherClient | null = null; + +export const getPusherClient = () => { + if (!clientInstance && typeof window !== 'undefined') { + const key = process.env.NEXT_PUBLIC_PUSHER_KEY; + if (!key) return null; + + clientInstance = new PusherClient(key, { + cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER || 'mt1', + authEndpoint: '/api/pusher/auth', + }); + } + return clientInstance; +}; diff --git a/package-lock.json b/package-lock.json index ceb95d45..5954415a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,8 @@ "nodemailer": "^7.0.12", "otplib": "^13.4.1", "pg": "^8.16.3", + "pusher": "^5.3.4", + "pusher-js": "^8.6.0", "qrcode": "^1.5.4", "react": "19.2.3", "react-dom": "19.2.3", @@ -5684,12 +5686,21 @@ "version": "20.19.28", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.28.tgz", "integrity": "sha512-VyKBr25BuFDzBFCK5sUM6ZXiWfqgCTwTAOK8qzGV/m9FCirXYDlmczJ+d5dXBAQALGCdRRdbteKYfJ84NGEusw==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, "node_modules/@types/nodemailer": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz", @@ -6387,6 +6398,18 @@ "uncrypto": "^0.1.3" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/abs-svg-path": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz", @@ -6740,6 +6763,12 @@ "node": ">= 0.4" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -7389,6 +7418,18 @@ "node": ">=12.20" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -7810,6 +7851,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", @@ -8177,7 +8227,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -8732,6 +8781,15 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -9021,6 +9079,43 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/framer-motion": { "version": "12.42.2", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.42.2.tgz", @@ -9462,7 +9557,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -9475,9 +9569,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -9790,6 +9884,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-base64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-base64/-/is-base64-1.1.0.tgz", + "integrity": "sha512-Nlhg7Z2dVC4/PTvIFkgVVNvPHSO2eR/Yd0XzhGiXCXEvWnptXlXa/clQ8aePPiMuxEGcWfzWbGw2Fe3d+Y3v1g==", + "license": "MIT", + "bin": { + "is_base64": "bin/is-base64", + "is-base64": "bin/is-base64" + } + }, "node_modules/is-bigint": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", @@ -12303,6 +12407,26 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-fetch-native": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", @@ -12310,6 +12434,28 @@ "devOptional": true, "license": "MIT" }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -13278,6 +13424,32 @@ ], "license": "MIT" }, + "node_modules/pusher": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/pusher/-/pusher-5.3.4.tgz", + "integrity": "sha512-gA7NpPRsQW9qpry/Ov+o9KKIPaRMziiDoEtiZ6EF0+QiSdsnT49+dH7+hf0Dvd4d+HEGQDHp6VXtrVM5DDrpmw==", + "license": "MIT", + "dependencies": { + "@types/node-fetch": "^2.5.7", + "abort-controller": "^3.0.0", + "is-base64": "^1.1.0", + "node-fetch": "^2.7.0", + "tweetnacl": "^1.0.0", + "tweetnacl-util": "^0.15.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/pusher-js": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.6.0.tgz", + "integrity": "sha512-wShJPfCS/kYkCBVzVW67wa9cnQIgHTszEK2XHNrFkOgGruuGw081aERAxfRjfdFU+WcIt8x6dvbwkTW4iZuQ8Q==", + "license": "MIT", + "dependencies": { + "tweetnacl": "^1.0.3" + } + }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", @@ -14929,6 +15101,18 @@ "url": "https://github.com/sponsors/Wombosvideo" } }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, + "node_modules/tweetnacl-util": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/tweetnacl-util/-/tweetnacl-util-0.15.1.tgz", + "integrity": "sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==", + "license": "Unlicense" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -15123,7 +15307,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unicode-properties": { diff --git a/package.json b/package.json index 01b64974..76112f68 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,8 @@ "nodemailer": "^7.0.12", "otplib": "^13.4.1", "pg": "^8.16.3", + "pusher": "^5.3.4", + "pusher-js": "^8.6.0", "qrcode": "^1.5.4", "react": "19.2.3", "react-dom": "19.2.3",