upd: webpush

Deskripsi:
- database push notification
- update package
- memasang webpush

NO Issues
This commit is contained in:
amel
2024-11-18 17:12:58 +08:00
parent bc5ce5b48c
commit d847c97bec
30 changed files with 1267 additions and 282 deletions

View File

@@ -55,6 +55,8 @@
"rrule": "^2.8.1", "rrule": "^2.8.1",
"supabase": "^1.192.5", "supabase": "^1.192.5",
"web-push": "^3.6.7", "web-push": "^3.6.7",
"wibu-cli": "^1.0.91",
"wibu-pkg": "^1.0.63",
"wibu-realtime": "bipproduction/wibu-realtime", "wibu-realtime": "bipproduction/wibu-realtime",
"yargs": "^17.7.2" "yargs": "^17.7.2"
}, },
@@ -77,4 +79,4 @@
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "ISC" "license": "ISC"
} }

View File

@@ -124,6 +124,7 @@ model User {
DivisionCalendarMember DivisionCalendarMember[] DivisionCalendarMember DivisionCalendarMember[]
Notifications Notifications[] @relation("UserToUser") Notifications Notifications[] @relation("UserToUser")
Notifications2 Notifications[] @relation("UserFromUser") Notifications2 Notifications[] @relation("UserFromUser")
Subscribe Subscribe[]
} }
model UserLog { model UserLog {
@@ -494,11 +495,6 @@ model BannerImage {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model Subscription {
id String @id @default(cuid())
data Json
}
model Notifications { model Notifications {
id String @id @default(cuid()) id String @id @default(cuid())
User1 User @relation("UserToUser", fields: [idUserTo], references: [id], map: "UserToUserMap") User1 User @relation("UserToUser", fields: [idUserTo], references: [id], map: "UserToUserMap")
@@ -514,3 +510,12 @@ model Notifications {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model Subscribe {
id String @id @default(cuid())
User User @relation(fields: [idUser], references: [id])
idUser String @unique
subscription String @db.Text
createdAt DateTime? @default(now())
updatedAt DateTime? @updatedAt
}

BIN
public/icon-192x192.webp Normal file

Binary file not shown.

BIN
public/icon-512x512.webp Normal file

Binary file not shown.

121
public/wibu-push-worker.js Normal file
View File

@@ -0,0 +1,121 @@
const log = false; // Ganti ke true untuk debugging
function printLog(text) {
if (log) {
const stack = new Error().stack;
const lineInfo = stack.split('\n')[2];
const match = lineInfo.match(/(\/.*:\d+:\d+)/);
const lineNumber = match ? match[1] : 'unknown line';
console.log(`[${lineNumber}] ==>`, text);
}
}
self.addEventListener('install', (event) => {
event.waitUntil(self.skipWaiting());
printLog('Service Worker installing...');
});
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
printLog('Service Worker activating...');
});
self.addEventListener('push', async function (event) {
let title = "Default Title";
let options = {
body: "Default notification body",
icon: '/icon-192x192.png',
badge: '/icon-192x192.png',
image: '/icon-192x192.png',
vibrate: [100, 50, 100],
data: {
dateOfArrival: Date.now(),
primaryKey: '2',
},
};
if (event.data) {
try {
const data = event.data.json();
title = data.title || title;
options.body = data.body || options.body;
options.data = {
...options.data,
...data,
};
printLog(`Push event data: ${JSON.stringify(options, null, 2)}`);
} catch (e) {
console.error("Error parsing push event data:", e);
}
} else {
console.warn("Push event has no data.");
}
event.waitUntil(
(async () => {
try {
const eventData = (options.data);
const clientList = await clients.matchAll({ type: 'window', includeUncontrolled: true });
let isClientFocused = false;
for (const client of clientList) {
client.postMessage({
type: 'PUSH_RECEIVED',
title: eventData.title,
body: eventData.body,
variant: eventData.variant,
createdAt: eventData.createdAt,
acceptedAt: Date.now(),
});
if (client.focused) {
isClientFocused = true;
break;
}
}
const subscription = await self.registration.pushManager.getSubscription();
const myEndpoint = subscription ? subscription.endpoint : null;
if (myEndpoint && eventData.endpoint === myEndpoint) {
printLog("Notification sent to self, skipping display.");
return;
}
if (eventData.variant === 'data') {
printLog('Type is data, skipping display.');
return;
}
if (!isClientFocused) {
await self.registration.showNotification(title, options);
} else {
printLog('Client is focused, notification not shown.');
}
} catch (err) {
console.error("Error displaying notification:", err);
}
})()
);
});
self.addEventListener('notificationclick', function (event) {
const clickedLink = event.notification.data.link;
event.notification.close();
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
for (const client of clientList) {
if (client.url.includes(clickedLink) && 'focus' in client) {
return client.focus();
}
}
if (clients.openWindow) {
return clients.openWindow(clickedLink);
}
}).catch(err => {
console.error("Error handling notification click:", err);
})
);
});
// wibu:1.0.87

View File

@@ -1,77 +0,0 @@
self.addEventListener('install', (event) => {
event.waitUntil(self.skipWaiting());
console.log('Service worker installing...');
});
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
console.log('Service worker activating...');
});
self.addEventListener('push', function (event) {
console.log('Push event received:', event);
let title = "Sistem Desa Mandiri";
let options = {
body: "Default notification body",
icon: '/icon-192x192.png',
badge: '/icon-192x192.png',
image: '/icon-192x192.png',
vibrate: [100, 50, 100],
data: {
dateOfArrival: Date.now(),
primaryKey: '2',
},
};
if (event.data) {
try {
const data = event.data.json();
title = data.title || title;
options.body = data.body || options.body;
options.icon = data.icon || options.icon;
options.badge = data.badge || options.badge;
options.image = data.image || options.image;
options.data = {
...options.data,
...data.data, // Merging additional data from the event
};
} catch (e) {
console.error("Error parsing push event data:", e);
}
} else {
console.warn("Push event has no data.");
}
event.waitUntil(
self.registration.showNotification(title, options)
.then(() => console.log('Notification shown.', JSON.stringify(options, null, 2)))
.catch(err => {
console.error("Error showing notification:", err);
})
);
});
self.addEventListener('notificationclick', function (event) {
console.log('Notification click received.');
event.notification.close(); // Close the notification
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
for (const client of clientList) {
if (client.url.includes('http://localhost:3005') && 'focus' in client) {
return client.focus();
}
}
if (clients.openWindow) {
return clients.openWindow('http://localhost:3005');
}
}).catch(err => {
console.error("Error handling notification click:", err);
})
);
});

View File

@@ -1,3 +1,4 @@
import { PushProvider } from "@/lib/PushProvider"
import { WrapLayout } from "@/module/_global" import { WrapLayout } from "@/module/_global"
import { funDetectCookies, funGetUserByCookies } from "@/module/auth" import { funDetectCookies, funGetUserByCookies } from "@/module/auth"
import _ from "lodash" import _ from "lodash"
@@ -10,6 +11,7 @@ export default async function Layout({ children }: { children: React.ReactNode }
const user = await funGetUserByCookies() const user = await funGetUserByCookies()
return ( return (
<> <>
<PushProvider user={String(user.id)} />
<WrapLayout role={user.idUserRole} theme={user.theme} user={user.id} village={user.idVillage}> <WrapLayout role={user.idUserRole} theme={user.theme} user={user.id} village={user.idVillage}>
{children} {children}
</WrapLayout> </WrapLayout>

View File

@@ -1,6 +0,0 @@
import prisma from "@/lib/prisma";
export async function GET() {
const sub = await prisma.subscription.findMany();
return new Response(JSON.stringify({ data: sub }));
}

View File

@@ -0,0 +1,44 @@
import { prisma } from "@/module/_global"
import { WibuServerPush } from 'wibu-pkg'
WibuServerPush.init({
NEXT_PUBLIC_VAPID_PUBLIC_KEY: process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
VAPID_PRIVATE_KEY: process.env.VAPID_PRIVATE_KEY!,
})
export async function POST(req: Request) {
const { user, subscription } = await req.json()
const upsert = await prisma.subscribe.upsert({
where: {
idUser: user
},
create: {
idUser: user,
subscription: JSON.stringify(subscription)
},
update: {
subscription: JSON.stringify(subscription)
}
})
return new Response(JSON.stringify(upsert))
}
export async function PUT(req: Request) {
const sub = await prisma.subscribe.findMany()
const subs: PushSubscription[] = sub.map((v) => JSON.parse(v.subscription)) as PushSubscription[]
const kirim = await WibuServerPush.sendMany({
subscriptions: subs as any,
data: {
body: "ini test ",
title: "test notif",
link: "/",
variant: "notification"
}
})
return new Response(JSON.stringify(kirim))
}

View File

@@ -1,70 +0,0 @@
import webpush from "web-push";
import prisma from "@/lib/prisma";
// Set VAPID details for web-push
webpush.setVapidDetails(
"mailto:bip.production.js@gmail.com",
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!
);
export async function POST() {
try {
// Fetch all subscriptions from your database
const subscriptions = await prisma.subscription.findMany();
if (!subscriptions || subscriptions.length === 0) {
console.error("No subscriptions available to send notification");
return new Response("No subscriptions available", { status: 400 });
}
// Notification payload
const notificationPayload = JSON.stringify({
title: "Test Notification",
body: "This is a test notification | makuro",
icon: "/icon-192x192.png",
badge: "/icon-192x192.png",
image: "/icon-192x192.png",
});
let successCount = 0;
let failureCount = 0;
// Loop through all subscriptions and send notifications
for (const sub of subscriptions) {
try {
const subscriptionData = sub.data as any;
await webpush.sendNotification(subscriptionData, notificationPayload);
// console.log(
// `Notification sent successfully to ${subscriptionData.endpoint}`
// );
successCount++;
} catch (error: any) {
console.error(
`Error sending push notification to subscription ${sub.id}:`,
error
);
failureCount++;
}
}
// Return a success or failure response
return new Response(
JSON.stringify({
message: `Notifications sent: ${successCount}, Failed: ${failureCount}`,
success: failureCount === 0
}),
{ status: 200 }
);
} catch (error: any) {
console.error("Error during notification process:", error);
return new Response(
JSON.stringify({
success: false,
error: error.message || "Failed to process notifications"
}),
{ status: 500 }
);
}
}

View File

@@ -1,25 +0,0 @@
import webpush from "web-push";
import prisma from "@/lib/prisma";
webpush.setVapidDetails(
"mailto:bip.production.js@gmail.com",
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!
);
export async function POST(req: Request) {
const { sub } = await req.json();
console.log(sub);
if (!sub || !sub.endpoint) {
console.error("Invalid subscription object");
return new Response("Invalid subscription object", { status: 400 });
}
const data = await prisma.subscription.create({
data: {
id: sub.keys.auth,
data: sub
}
});
return new Response(JSON.stringify({ data }));
}

View File

@@ -1,11 +0,0 @@
import prisma from "@/lib/prisma";
export async function POST(req: Request) {
const { sub } = await req.json();
const data = await prisma.subscription.delete({
where: {
id: sub.keys.auth
}
});
return new Response(JSON.stringify({ data }));
}

39
src/app/icon.tsx Normal file
View File

@@ -0,0 +1,39 @@
import { ImageResponse } from 'next/og'
// Image metadata
export const size = {
width: 32,
height: 32,
}
export const contentType = 'image/png'
// Image generation
export default function Icon(): ImageResponse {
const { width, height } = size
return new ImageResponse(
(
// Element JSX yang berfungsi sebagai ikon
<div
style={{
fontSize: 24,
backgroundColor: 'black',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
}}
>
B
</div>
),
{
width,
height,
}
)
}
// wibu:1.0.87

View File

@@ -1,19 +1,13 @@
import "@mantine/core/styles.css"; import { ScrollProvider } from "@/module/_global";
import {
Box,
ColorSchemeScript,
Container,
MantineProvider,
rem,
} from "@mantine/core";
import { ScrollProvider, WARNA } from "@/module/_global";
import { Lato } from "next/font/google";
import '@mantine/carousel/styles.css';
import { Toaster } from 'react-hot-toast';
import '@mantine/dates/styles.css';
import '@mantine/notifications/styles.css';
import { Notifications } from '@mantine/notifications'
import LayoutBackground from "@/module/_global/layout/layout_background"; import LayoutBackground from "@/module/_global/layout/layout_background";
import '@mantine/carousel/styles.css';
import { Box, ColorSchemeScript, MantineProvider } from "@mantine/core";
import "@mantine/core/styles.css";
import '@mantine/dates/styles.css';
import { Notifications } from '@mantine/notifications';
import '@mantine/notifications/styles.css';
import { Lato } from "next/font/google";
import { Toaster } from 'react-hot-toast';
export const metadata = { export const metadata = {
title: "SISTEM DESA MANDIRI", title: "SISTEM DESA MANDIRI",

View File

@@ -0,0 +1,28 @@
import { Button } from "@mantine/core";
import { useState } from "react";
export function ButtonKirim() {
const [loading, setLoading] = useState(false)
async function onKirim() {
setLoading(true)
try {
const res = await fetch('/makuro/api/kirim', {
method: 'POST',
})
const dataText = await res.text()
if (!res.ok) {
alert(dataText)
throw new Error(dataText)
}
const dataJson = JSON.parse(dataText)
console.log(dataJson)
alert("berhasil kirim")
} catch (error) {
console.error(error);
} finally {
setLoading(false)
}
}
return <Button loading={loading} onClick={() => onKirim()} >Kirim</Button>
}

View File

@@ -0,0 +1,32 @@
import { Button } from "@mantine/core";
import { useState } from "react";
export function ButtonSubscribe({ user, subscription }: { user: string, subscription?: PushSubscription | null }) {
const [loading, setLoading] = useState(false)
async function subscribe() {
if(!subscription) return alert("no subscription")
try {
setLoading(true)
const res = await fetch('/makuro/api/sub', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user, subscription })
})
const dataText = await res.text()
if (!res.ok) {
alert(dataText)
throw new Error(dataText)
}
console.log(dataText)
alert("berhasil subscribe")
} catch (error) {
console.error(error);
} finally {
setLoading(false)
}
}
return <Button disabled={!subscription} onClick={() => subscribe()} loading={loading} variant="outline" radius={"xl"} size={"lg"}>Subscribe</Button>
}

View File

@@ -0,0 +1,5 @@
import { WibuPermissionProvider, WibuPushNotificationHandler } from "wibu-pkg";
export function MakuroProvider() {
return null
}

View File

@@ -0,0 +1,7 @@
import { PushSubscription } from 'web-push';
export class UserSub {
public static subscription: PushSubscription | null;
public static setSub(subscription: PushSubscription) {
this.subscription = subscription
}
}

View File

@@ -0,0 +1,3 @@
import { hookstate } from "@hookstate/core";
export const subState = hookstate<PushSubscription | null>(null)

View File

@@ -0,0 +1,25 @@
import { prisma } from "@/module/_global";
import { WibuServerPush } from 'wibu-pkg'
WibuServerPush.init({
NEXT_PUBLIC_VAPID_PUBLIC_KEY: process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
VAPID_PRIVATE_KEY: process.env.VAPID_PRIVATE_KEY!,
})
export async function POST(req: Request) {
const sub = await prisma.subscribe.findMany()
const subs: PushSubscription[] = sub.map((v) => JSON.parse(v.subscription)) as PushSubscription[]
const kirim = await WibuServerPush.sendMany({
subscriptions: subs as any,
data: {
body: "ini test ",
title: "test notif",
link: "/",
variant: "notification"
}
})
return new Response(JSON.stringify(kirim))
}

View File

@@ -0,0 +1,21 @@
import { prisma } from "@/module/_global"
export async function POST(req: Request) {
const { user, subscription } = await req.json()
console.log(user, subscription)
const upsert = await prisma.subscribe.upsert({
where: {
idUser: user
},
create: {
idUser: user,
subscription: JSON.stringify(subscription)
},
update: {
subscription: JSON.stringify(subscription)
}
})
return new Response(JSON.stringify(upsert))
}

View File

@@ -0,0 +1,22 @@
'use client'
import { useHookstate } from "@hookstate/core";
import { Card, Stack, Text, Title } from "@mantine/core";
import { subState } from "../_lib/state";
import { ButtonSubscribe } from "../_lib/ButtonSubscribe";
import { useSearchParams } from "next/navigation";
export default function Page() {
const user = useSearchParams().get("user")
const { value: sub } = useHookstate(subState)
if (!sub) return <Text>loading ...</Text>
if (!user) return <Text>masukkan user</Text>
return <Stack p={"md"} gap={"lg"}>
<Title>Kirim</Title>
<Card>
<Text>
{JSON.stringify(sub)}
</Text>
</Card>
<ButtonSubscribe user={user} subscription={sub} />
</Stack>;
}

17
src/app/makuro/layout.tsx Normal file
View File

@@ -0,0 +1,17 @@
'use client'
import { useHookstate } from "@hookstate/core"
import { WibuPermissionProvider, WibuPushNotificationHandler } from "wibu-pkg"
import { subState } from "./_lib/state"
export default function Layout({ children }: { children: React.ReactNode }) {
const { set: setSubcribe } = useHookstate(subState)
return <WibuPermissionProvider requiredPermissions={["notifications"]}>
<WibuPushNotificationHandler
NEXT_PUBLIC_VAPID_PUBLIC_KEY={process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!}
onSubscribe={(sub) => {
setSubcribe(sub)
}} />
{children}
</WibuPermissionProvider>
}

View File

@@ -0,0 +1,24 @@
'use client'
import { useHookstate } from "@hookstate/core";
import { Card, Stack, Text, Title } from "@mantine/core";
import { subState } from "../_lib/state";
import { ButtonSubscribe } from "../_lib/ButtonSubscribe";
import { useSearchParams } from "next/navigation";
import { ButtonKirim } from "../_lib/ButtonKirim";
export default function Page() {
const user = useSearchParams().get("user")
const { value: sub } = useHookstate(subState)
if (!sub) return <Text>loading ...</Text>
if (!user) return <Text>masukkan user</Text>
return <Stack p={"md"} gap={"lg"}>
<Title>terima</Title>
<Card>
<Text>
{JSON.stringify(sub)}
</Text>
</Card>
<ButtonSubscribe user={user} subscription={sub} />
<ButtonKirim />
</Stack>;
}

View File

@@ -2,9 +2,9 @@ import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest { export default function manifest(): MetadataRoute.Manifest {
return { return {
name: "Sistem Desa Mandiri", name: "Wibu Example",
short_name: "SDM", short_name: "WibuExp",
description: "Sistem Desa Mandiri", description: "Contoh penggunaan PWA dan push",
start_url: "/", start_url: "/",
display: "standalone", display: "standalone",
background_color: "#ffffff", background_color: "#ffffff",
@@ -13,17 +13,15 @@ export default function manifest(): MetadataRoute.Manifest {
{ {
src: "/icon-192x192.png", src: "/icon-192x192.png",
sizes: "192x192", sizes: "192x192",
type: "image/png" type: "image/png",
}, },
{ {
src: "/icon-512x512.png", src: "/icon-512x512.png",
sizes: "512x512", sizes: "512x512",
type: "image/png" type: "image/png",
} },
], ],
serviceworker: {
src: "/wibu_worker.js"
},
}; };
} }
// wibu:1.0.87

View File

@@ -1,37 +0,0 @@
'use client'
import { Button, Stack } from '@mantine/core'
import { useShallowEffect } from '@mantine/hooks'
import { useWibuRealtime } from 'wibu-realtime'
export function RealtimePage({ wibuKey }: { wibuKey: string }) {
const [data, setData] = useWibuRealtime({
WIBU_REALTIME_TOKEN: wibuKey,
project: "sdm"
})
useShallowEffect(() => {
if (data) {
console.log(data)
}
}, [data])
async function onTekan() {
setData([{
idUserTo: 'supadminAmalia',
title: Math.random().toString(),
desc: 'Anda memiliki pengumuman baru. Silahkan periksa detailnya.',
category: 'announcement',
idContent:'cm1eg9fqh00019rhi3oqbej1i'
},{
idUserTo: 'supadmieenAmalia',
title: Math.random().toString(),
desc: 'Anda memiliki pengumuman baru. Silahkan periksa detailnya.',
category: 'announcement',
idContent:'dfdf'
}])
}
return (
<Stack p={"lg"}>
{JSON.stringify(data)}
<Button onClick={onTekan}>Tekan</Button>
</Stack>
)
}

View File

@@ -1,11 +0,0 @@
import { Stack } from "@mantine/core";
import { RealtimePage } from "./_ui/RealtimePage";
const WIBU_REALTIME_KEY = process.env.WIBU_REALTIME_KEY!
export default function Page() {
return (
<Stack>
<RealtimePage wibuKey={WIBU_REALTIME_KEY} />
</Stack>
)
}

28
src/lib/PushProvider.tsx Normal file
View File

@@ -0,0 +1,28 @@
'use client'
import { WibuPermissionProvider, WibuPushNotificationHandler } from 'wibu-pkg'
const NEXT_PUBLIC_VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!
export function PushProvider({ user }: { user: string }) {
if (!user) {
return <div>tunggu user</div>
}
return <>
<WibuPermissionProvider requiredPermissions={["notifications"]}>
<WibuPushNotificationHandler
NEXT_PUBLIC_VAPID_PUBLIC_KEY={NEXT_PUBLIC_VAPID_PUBLIC_KEY}
onMessage={(msg) => {
console.log(msg)
}}
onSubscribe={(subscription) => {
fetch("/api/push-notification/", {
method: "POST",
body: JSON.stringify({
user,
subscription
})
})
}}
/>
</WibuPermissionProvider>
</>
}

32
src/opengraph-image.tsx Normal file
View File

@@ -0,0 +1,32 @@
import { ImageResponse } from "next/og";
export default async function Image() {
return new ImageResponse(
(
<div
style={{
background: "green",
color: "white",
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
textAlign: "center",
alignItems: "center",
justifyContent: "center"
}}
>
<div style={{
fontSize: 128,
}}>WIBU APP</div>
<p style={{ fontSize: 24 }}>Comprehensive Dock for Wibu web app.</p>
</div>
),
{
width: 1200,
height: 630
}
);
}
// wibu:1.0.87

801
yarn.lock

File diff suppressed because it is too large Load Diff