diff --git a/CHANGELOG.md b/CHANGELOG.md index 46a5e574..7aed5bb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines. +## [1.5.33](https://wibugit.wibudev.com/wibu/hipmi/compare/v1.5.32...v1.5.33) (2026-01-06) + ## [1.5.32](https://wibugit.wibudev.com/wibu/hipmi/compare/v1.5.31...v1.5.32) (2026-01-05) ## [1.5.31](https://wibugit.wibudev.com/wibu/hipmi/compare/v1.5.30...v1.5.31) (2025-12-24) diff --git a/package.json b/package.json index b6de4ec3..94350e77 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hipmi", - "version": "1.5.32", + "version": "1.5.33", "private": true, "prisma": { "seed": "bun prisma/seed.ts" diff --git a/prisma/migrations/20260105064508_fix_table_notifikasi_optional_data/migration.sql b/prisma/migrations/20260105064508_fix_table_notifikasi_optional_data/migration.sql new file mode 100644 index 00000000..91086ef5 --- /dev/null +++ b/prisma/migrations/20260105064508_fix_table_notifikasi_optional_data/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "Notifikasi" ALTER COLUMN "appId" DROP NOT NULL, +ALTER COLUMN "kategoriApp" DROP NOT NULL, +ALTER COLUMN "pesan" DROP NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 831d6714..81c3273d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -982,9 +982,9 @@ model Notifikasi { isActive Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - appId String - kategoriApp String - pesan String + appId String? + kategoriApp String? + pesan String? title String? status String? diff --git a/src/app/api/auth/mobile-register/route.ts b/src/app/api/auth/mobile-register/route.ts index 8240bf9a..380a3a81 100644 --- a/src/app/api/auth/mobile-register/route.ts +++ b/src/app/api/auth/mobile-register/route.ts @@ -1,5 +1,6 @@ import { sessionCreate } from "@/app/(auth)/_lib/session_create"; import { randomOTP } from "@/app_modules/auth/fun/rondom_otp"; +import { adminMessaging } from "@/lib/firebase-admin"; import prisma from "@/lib/prisma"; import { NextResponse } from "next/server"; @@ -51,12 +52,6 @@ export async function POST(req: Request) { { status: 500 } ); - // const token = await sessionCreate({ - // sessionKey: process.env.NEXT_PUBLIC_BASE_SESSION_KEY!, - // encodedKey: process.env.NEXT_PUBLIC_BASE_TOKEN_KEY!, - // user: createUser as any, - // }); - const createOtpId = await prisma.kodeOtp.create({ data: { nomor: data.nomor, @@ -87,11 +82,89 @@ export async function POST(req: Request) { { status: 400 } ); + // =========== START SEND NOTIFICATION =========== // + + const findAllUserBySendTo = await prisma.user.findMany({ + where: { masterUserRoleId: "2" }, + select: { id: true }, + }); + + console.log("Users to notify:", findAllUserBySendTo); + + const dataNotification = { + title: "Pendaftaran Baru", + type: "announcement", + kategoriApp: "OTHER", + createdAt: new Date(), + pesan: "User baru telah melakukan registrasi. Ayo cek dan verifikasi!", + deepLink: `/admin/user-access/${createUser.id}`, + senderId: createUser.id, + }; + + for (let a of findAllUserBySendTo) { + const createdNotification = await prisma.notifikasi.create({ + data: { + ...dataNotification, + recipientId: a.id, + }, + }); + + if (createdNotification) { + const deviceToken = await prisma.tokenUserDevice.findMany({ + where: { + userId: a.id, + isActive: true, + }, + }); + + for (let i of deviceToken) { + const message = { + token: i.token, + notification: { + title: dataNotification.title, + body: dataNotification.pesan, + }, + data: { + sentAt: new Date().toISOString(), // ✅ Simpan metadata di data + id: createdNotification.id, + deepLink: dataNotification.deepLink, + }, + // Konfigurasi Android untuk prioritas tinggi + android: { + priority: "high" as const, // Kirim secepatnya, bahkan di doze mode untuk notifikasi penting + notification: { + channelId: "default", // Sesuaikan dengan channel yang kamu buat di Android + }, + ttl: 0 as const, // Kirim secepatnya, jangan tunda + }, + // Opsional: tambahkan untuk iOS juga + apns: { + payload: { + aps: { + sound: "default" as const, + // 'content-available': 1 as const, // jika butuh silent push + }, + }, + }, + }; + + try { + const response = await adminMessaging.send(message); + console.log("✅ FCM sent successfully", "Response:", response); + } catch (error: any) { + console.error("❌ FCM send failed:", error); + // Lanjutkan ke token berikutnya meski satu gagal + } + } + } + } + + // =========== END SEND NOTIFICATION =========== // + return NextResponse.json( { success: true, message: "Registrasi Berhasil", - // token: token, kodeId: createOtpId.id, }, { status: 201 } diff --git a/src/app/api/mobile/admin/user/[id]/route.ts b/src/app/api/mobile/admin/user/[id]/route.ts index c9cec177..5c58248d 100644 --- a/src/app/api/mobile/admin/user/[id]/route.ts +++ b/src/app/api/mobile/admin/user/[id]/route.ts @@ -34,9 +34,15 @@ async function GET(request: Request, { params }: { params: { id: string } }) { async function PUT(request: Request, { params }: { params: { id: string } }) { const { id } = params; const { data } = await request.json(); + const { searchParams } = new URL(request.url); + const category = searchParams.get("category"); + + console.log("Received data:", data); + console.log("User ID:", id); + console.log("Category:", category); try { - if (data.active) { + if (category === "access") { const updateData = await prisma.user.update({ where: { id: id, @@ -47,7 +53,7 @@ async function PUT(request: Request, { params }: { params: { id: string } }) { }); console.log("[Update Active Berhasil]", updateData); - } else if (data.role) { + } else if (category === "role") { const fixName = _.startCase(data.role.replace(/_/g, " ")); const checkRole = await prisma.masterUserRole.findFirst({ @@ -68,6 +74,12 @@ async function PUT(request: Request, { params }: { params: { id: string } }) { }); console.log("[Update Role Berhasil]", updateData); + } else { + return NextResponse.json({ + status: 400, + success: false, + message: "Invalid category", + }); } return NextResponse.json({ diff --git a/src/app/api/mobile/job/route.ts b/src/app/api/mobile/job/route.ts index 527302ed..3744f347 100644 --- a/src/app/api/mobile/job/route.ts +++ b/src/app/api/mobile/job/route.ts @@ -1,3 +1,5 @@ +import { sendNotificationMobileToManyUser } from "@/lib/mobile/notification/send-notification"; +import { routeAdminMobile } from "@/lib/mobile/route-page-mobile"; import prisma from "@/lib/prisma"; import { NextResponse } from "next/server"; @@ -17,6 +19,25 @@ async function POST(request: Request) { }, }); + // kirim notifikasi ke semua admin untuk mengetahui ada job baru yang harus di review + + const adminUsers = await prisma.user.findMany({ + where: { masterUserRoleId: "2" }, + select: { id: true }, + }); + + await sendNotificationMobileToManyUser({ + recipientIds: adminUsers.map((user) => user.id), + senderId: data.authorId, + payload: { + title: "Job: Pengajuan Review", + body: data.title, + type: "announcement", + deepLink: routeAdminMobile.jobByStatus({ status: "review" }), + kategoriApp: "JOB", + }, + }); + return NextResponse.json( { success: true, @@ -54,10 +75,10 @@ async function GET(request: Request) { MasterStatus: { name: "Publish", }, - // title: { - // contains: search || "", - // mode: "insensitive", - // }, + // title: { + // contains: search || "", + // mode: "insensitive", + // }, }, orderBy: { createdAt: "desc", @@ -90,46 +111,46 @@ async function GET(request: Request) { fixData = data; } else if (category === "beranda") { - const data = await prisma.job.findMany({ - where: { - isActive: true, - isArsip: false, - MasterStatus: { - name: "Publish", - }, - title: { - contains: search || "", - mode: "insensitive", - }, - }, - orderBy: { - createdAt: "desc", - }, - select: { - id: true, - title: true, - deskripsi: true, - authorId: true, - MasterStatus: { - select: { - name: true, - }, - }, - Author: { - select: { - id: true, - username: true, - Profile: { - select: { - id: true, - name: true, - imageId: true, - }, - }, - }, - }, - }, - }); + const data = await prisma.job.findMany({ + where: { + isActive: true, + isArsip: false, + MasterStatus: { + name: "Publish", + }, + title: { + contains: search || "", + mode: "insensitive", + }, + }, + orderBy: { + createdAt: "desc", + }, + select: { + id: true, + title: true, + deskripsi: true, + authorId: true, + MasterStatus: { + select: { + name: true, + }, + }, + Author: { + select: { + id: true, + username: true, + Profile: { + select: { + id: true, + name: true, + imageId: true, + }, + }, + }, + }, + }, + }); fixData = data; } diff --git a/src/app/api/mobile/notification/[id]/route.ts b/src/app/api/mobile/notification/[id]/route.ts index 8bed585b..e9a03897 100644 --- a/src/app/api/mobile/notification/[id]/route.ts +++ b/src/app/api/mobile/notification/[id]/route.ts @@ -1,6 +1,8 @@ import { prisma } from "@/lib"; import _ from "lodash"; import { NextRequest, NextResponse } from "next/server"; +import { NotificationProp } from "../route"; +import { adminMessaging } from "@/lib/firebase-admin"; export async function GET( request: NextRequest, @@ -9,7 +11,7 @@ export async function GET( const { id } = params; const { searchParams } = new URL(request.url); const category = searchParams.get("category"); - + let fixData; const fixCategory = _.upperCase(category || ""); @@ -51,6 +53,7 @@ export async function PUT( }, data: { isRead: true, + readAt: new Date(), }, }); @@ -65,3 +68,130 @@ export async function PUT( ); } } + +export async function POST( + request: NextRequest, + { params }: { params: { id: string } } +) { + const { id } = params; + + const { data } = await request.json(); + + const { + title, + body: notificationBody, + userLoginId, + type, + kategoriApp, + appId, + status, + deepLink, + } = data as NotificationProp; + + console.log("Notification Send >>", data); + + try { + // Cari user yang login + const findUserLogin = await prisma.user.findUnique({ + where: { + id: userLoginId, + }, + }); + + if (!findUserLogin) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + // Cari token fcm user yang login + const checkFcmToken = await prisma.tokenUserDevice.findFirst({ + where: { + userId: findUserLogin.id, + }, + }); + + if (!checkFcmToken) { + return NextResponse.json( + { error: "FCM Token not found" }, + { status: 404 } + ); + } + + const created = await prisma.notifikasi.create({ + data: { + title, + type, + createdAt: new Date(), + appId, + kategoriApp, + pesan: notificationBody || "", + userRoleId: findUserLogin.masterUserRoleId, + status, + deepLink, + senderId: findUserLogin.id, + recipientId: id, + }, + }); + + if (created) { + const deviceToken = await prisma.tokenUserDevice.findMany({ + where: { + userId: id, + isActive: true, + }, + }); + + for (let i of deviceToken) { + const message = { + token: i.token, + notification: { + title, + body: notificationBody || "", + }, + data: { + sentAt: new Date().toISOString(), // ✅ Simpan metadata di data + id: created.id, + deepLink: deepLink || "", + }, + // Konfigurasi Android untuk prioritas tinggi + android: { + priority: "high" as const, // Kirim secepatnya, bahkan di doze mode untuk notifikasi penting + notification: { + channelId: "default", // Sesuaikan dengan channel yang kamu buat di Android + }, + + ttl: 0 as const, // Kirim secepatnya, jangan tunda + }, + // Opsional: tambahkan untuk iOS juga + apns: { + payload: { + aps: { + sound: "default" as const, + // 'content-available': 1 as const, // jika butuh silent push + }, + }, + }, + }; + + try { + const response = await adminMessaging.send(message); + console.log("✅ FCM sent successfully", "Response:", response); + } catch (error: any) { + console.error("❌ FCM send failed:", error); + // Lanjutkan ke token berikutnya meski satu gagal + } + } + } + + return NextResponse.json({ + success: true, + message: "Notification sent successfully", + }); + } catch (error) { + console.error("❌ FCM error:", error); + + return NextResponse.json( + { error: (error as Error).message }, + { status: 500 } + ); + } +} diff --git a/src/app/api/mobile/notification/route.ts b/src/app/api/mobile/notification/route.ts index 90b42061..132aa21f 100644 --- a/src/app/api/mobile/notification/route.ts +++ b/src/app/api/mobile/notification/route.ts @@ -3,7 +3,7 @@ import { prisma } from "@/lib"; import { adminMessaging } from "@/lib/firebase-admin"; import { NextRequest, NextResponse } from "next/server"; -type NotificationProp = { +export type NotificationProp = { title: string; body: string; userLoginId: string; @@ -134,12 +134,12 @@ export async function POST(request: NextRequest) { try { const response = await adminMessaging.send(message); console.log( - "✅ FCM sent successfully", + "✅ FCM sent to token:", "Response:", response ); } catch (error: any) { - console.error("❌ FCM send failed:", error); + console.error("❌ FCM send failed for token:", i.token, error); // Lanjutkan ke token berikutnya meski satu gagal } } diff --git a/src/lib/mobile/notification/send-notification.ts b/src/lib/mobile/notification/send-notification.ts new file mode 100644 index 00000000..faf8898e --- /dev/null +++ b/src/lib/mobile/notification/send-notification.ts @@ -0,0 +1,111 @@ +// lib/notifications/send-notification.ts +import { adminMessaging } from "@/lib/firebase-admin"; +import prisma from "@/lib/prisma"; +import { NotificationMobilePayload } from "../../../../types/type-mobile-notification"; + +/** + * Kirim notifikasi ke satu user (semua device aktifnya) + * @param recipientId - ID penerima + * @param senderId - ID pengirim + * @param payload - Data notifikasi + */ + +export async function sendNotificationMobileToOneUser({ + recipientId, + senderId, + payload, +}: { + recipientId: string; + senderId: string; + payload: NotificationMobilePayload; +}) { + try { + // 1. Simpan notifikasi ke DB + const notification = await prisma.notifikasi.create({ + data: { + title: payload.title, + pesan: payload.body, + deepLink: payload.deepLink, + kategoriApp: payload.kategoriApp, + recipientId: recipientId, + senderId: senderId, + }, + }); + + // 2. Ambil semua token aktif milik penerima + const tokens = await prisma.tokenUserDevice.findMany({ + where: { userId: recipientId, isActive: true }, + select: { token: true, id: true }, + }); + + if (tokens.length === 0) { + console.warn(`No active tokens found for user ${recipientId}`); + return; + } + + // 3. Kirim FCM ke semua token + + await Promise.allSettled( + tokens.map(async ({ token, id }) => { + try { + await adminMessaging.send({ + token, + notification: { + title: payload.title, + body: payload.body, + }, + data: { + sentAt: new Date().toISOString(), // ✅ Simpan metadata di data + id: notification.id, + deepLink: payload.deepLink, + }, + android: { + priority: "high" as const, + notification: { channelId: "default" }, + ttl: 0 as const, + }, + apns: { + payload: { aps: { sound: "default" as const } }, + }, + }); + } catch (fcmError: any) { + // Hapus token jika invalid + console.log("fcmError", fcmError); + if (fcmError.code === "messaging/invalid-registration-token") { + await prisma.tokenUserDevice.delete({ where: { id: id } }); + console.log(`❌ Invalid token removed: ${token}`); + } + console.error(`FCM failed for token ${token}:`, fcmError.message); + } + }) + ); + + console.log(`✅ Notification sent to user ${recipientId}`); + } catch (error) { + console.error("Failed to send notification:", error); + throw error; // biarkan caller handle error + } +} + +/** + * Kirim notifikasi ke banyak user + */ +export async function sendNotificationMobileToManyUser({ + recipientIds, + senderId, + payload, +}: { + recipientIds: string[]; + senderId: string; + payload: NotificationMobilePayload; +}) { + await Promise.allSettled( + recipientIds.map((id) => + sendNotificationMobileToOneUser({ + recipientId: id, + senderId: senderId, + payload: payload, + }) + ) + ); +} diff --git a/src/lib/mobile/route-page-mobile.ts b/src/lib/mobile/route-page-mobile.ts new file mode 100644 index 00000000..d9ba6084 --- /dev/null +++ b/src/lib/mobile/route-page-mobile.ts @@ -0,0 +1,14 @@ +export { routeAdminMobile, routeUserMobile }; + +type StatusApp = "review" | "draft" | "reject" | "publish"; + +const routeAdminMobile = { + userAccess: ({ id }: { id: string }) => `/admin/user-access/${id}`, + // JOB + jobDetail: ({ id, status }: { id: string; status: StatusApp }) => `/admin/job/${id}/${status}`, + jobByStatus: ({ status }: { status: StatusApp }) => `/admin/job/${status}/status`, +}; + +const routeUserMobile = { + home: `/(user)/home`, +}; diff --git a/types/type-mobile-notification.ts b/types/type-mobile-notification.ts new file mode 100644 index 00000000..b3c9884f --- /dev/null +++ b/types/type-mobile-notification.ts @@ -0,0 +1,20 @@ +export type NotificationMobilePayload = { + title: string; + body: string; + userLoginId?: string; + appId?: string; + status?: string; + type: "announcement" | "trigger"; + deepLink: string; + kategoriApp: TypeNotificationCategoryApp +}; + +export type TypeNotificationCategoryApp = + | "EVENT" + | "JOB" + | "VOTING" + | "DONASI" + | "INVESTASI" + | "COLLABORATION" + | "FORUM" + | "OTHER";