diff --git a/.env.example b/.env.example index 03efbc11..95954cb1 100644 --- a/.env.example +++ b/.env.example @@ -37,3 +37,12 @@ LIGHTCAST_CLIENT_SECRET= # To run the application in production environment / check the envs # SKIP_ENV_CHECK=true npm run [replace with your script name] + + +# To test notification using web-push +# run 'node ./generate-vapidKey.js' in terminal and get Public/Private Key + +NEXT_PUBLIC_VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= + +ENABLE_SW=true #true if want to test locally \ No newline at end of file diff --git a/.gitignore b/.gitignore index ba44e4d0..82fc4d5c 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,9 @@ bun.lockb package-lock.json yarn.lock +**/public/sw.js +**/public/workbox-*.js +**/public/worker-*.js +**/public/sw.js.map +**/public/workbox-*.js.map +**/public/worker-*.js.map \ No newline at end of file diff --git a/generate-vapidKey.js b/generate-vapidKey.js new file mode 100644 index 00000000..05754a06 --- /dev/null +++ b/generate-vapidKey.js @@ -0,0 +1,5 @@ +import webpush from 'web-push'; +const vapidKeys = webpush.generateVAPIDKeys(); + +console.log('NEXT_PUBLIC_VAPID_PUBLIC_KEY=', vapidKeys.publicKey); +console.log('VAPID_PRIVATE_KEY=', vapidKeys.privateKey); diff --git a/next.config.js b/next.config.js index a715d6bf..a1377ecb 100644 --- a/next.config.js +++ b/next.config.js @@ -1,5 +1,6 @@ import { fileURLToPath } from 'node:url'; import createJiti from 'jiti'; +import withPWA from 'next-pwa'; if (process.env.SKIP_ENV_CHECK !== 'true') { const jiti = createJiti(fileURLToPath(import.meta.url)); @@ -34,9 +35,18 @@ const nextConfig = { { protocol: 'https', hostname: 'www.example.com', - } + }, ], }, }; -export default nextConfig; // ES module export +const pwaConfig = withPWA({ + dest: 'public', + register: true, + importScripts: ['/worker.js'], + // disable: process.env.NODE_ENV === 'development', + publicExcludes: ['!noprecache/**/*'], + buildExcludes: [/middleware-manifest.json$/], +}); + +export default pwaConfig(nextConfig); // ES module export diff --git a/package.json b/package.json index c8c006cd..33a6af81 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "lucide-react": "^0.426.0", "next": "^14.2.12", "next-auth": "^4.24.7", + "next-pwa": "^5.6.0", "next-themes": "^0.3.0", "nextjs-toploader": "^3.7.15", "node-cron": "^3.0.3", @@ -87,17 +88,20 @@ "tailwindcss-animate": "^1.0.7", "uuid": "^10.0.0", "vaul": "^0.9.1", + "web-push": "^3.6.7", "zod": "^3.23.8", "zod-error": "^1.5.0" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", + "@types/next-pwa": "^5.6.9", "@types/node": "^20.16.10", "@types/node-cron": "^3.0.11", "@types/nodemailer": "^6.4.16", "@types/randomstring": "^1.3.0", "@types/react": "^18", "@types/react-dom": "^18", + "@types/web-push": "^3.6.4", "@typescript-eslint/eslint-plugin": "^8.1.0", "@typescript-eslint/parser": "^8.1.0", "100xdevs-job-board": "file:", diff --git a/prisma/migrations/20241022165852_merged/migration.sql b/prisma/migrations/20241022165852_merged/migration.sql new file mode 100644 index 00000000..21f638de --- /dev/null +++ b/prisma/migrations/20241022165852_merged/migration.sql @@ -0,0 +1,18 @@ +-- CreateEnum +CREATE TYPE "ProjectStack" AS ENUM ('GO', 'PYTHON', 'MERN', 'NEXTJS', 'AI_GPT_APIS', 'SPRINGBOOT', 'OTHERS'); + +-- DropForeignKey +ALTER TABLE "Experience" DROP CONSTRAINT "Experience_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "Project" DROP CONSTRAINT "Project_userId_fkey"; + +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "projectThumbnail" TEXT, +ADD COLUMN "stack" "ProjectStack" NOT NULL DEFAULT 'OTHERS'; + +-- AddForeignKey +ALTER TABLE "Experience" ADD CONSTRAINT "Experience_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Project" ADD CONSTRAINT "Project_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20241023151855_added_notifications/migration.sql b/prisma/migrations/20241023151855_added_notifications/migration.sql new file mode 100644 index 00000000..6a73edb4 --- /dev/null +++ b/prisma/migrations/20241023151855_added_notifications/migration.sql @@ -0,0 +1,11 @@ +-- CreateTable +CREATE TABLE "Notifications" ( + "id" TEXT NOT NULL, + "subscription" TEXT NOT NULL, + "userId" TEXT NOT NULL, + + CONSTRAINT "Notifications_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Notifications" ADD CONSTRAINT "Notifications_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20241023170501_deleted_at/migration.sql b/prisma/migrations/20241023170501_deleted_at/migration.sql new file mode 100644 index 00000000..091492ed --- /dev/null +++ b/prisma/migrations/20241023170501_deleted_at/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Job" ADD COLUMN "deletedAt" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d7350ec2..9509427a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -29,8 +29,9 @@ model User { oauthId String? blockedByAdmin DateTime? - onBoard Boolean @default(false) - bookmark Bookmark[] + onBoard Boolean @default(false) + bookmark Bookmark[] + notifications Notifications[] } enum OauthProvider { @@ -122,6 +123,13 @@ model Project { user User @relation(fields: [userId], references: [id], onDelete: Cascade) } +model Notifications { + id String @id @default(uuid()) + subscription String + userId String + user User @relation(fields: [userId],references: [id],onDelete: Cascade) +} + enum ProjectStack { GO PYTHON diff --git a/public/worker.js b/public/worker.js new file mode 100644 index 00000000..da47d8cd --- /dev/null +++ b/public/worker.js @@ -0,0 +1,58 @@ +//Installing the sw.js + +const installEvent = () => { + self.addEventListener('install', (event) => { + event.waitUntil( + caches.open('v1').then((cache) => { + return cache.addAll(['/', '/offline', '/manifest.json']); + }) + ); + }); +}; +installEvent(); + +//Activating the sw.js + +const activateEvent = () => { + self.addEventListener('activate', () => { + return true; + }); +}; +activateEvent(); + +//handling the push manager + +self.addEventListener('push', async (e) => { + const { message, body, icon, route } = JSON.parse(e.data.text()); + + e.waitUntil( + self.registration.showNotification(message, { + body, + icon: icon || '/main.png', + badge: '/main.png', + vibrate: [100, 50, 100], + data: { route }, + }) + ); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + + const route = event.notification.data.route; + + event.waitUntil( + clients + .matchAll({ + type: 'window', + }) + .then((clientList) => { + for (const client of clientList) { + if (client.url === '/' && 'focus' in client) + return client.focu(route ? route : '/jobs'); + } + if (clients.openWindow) + return clients.openWindow(route ? route : '/jobs'); + }) + ); +}); diff --git a/src/actions/job.action.ts b/src/actions/job.action.ts index 659edb3e..f8fc6fe6 100644 --- a/src/actions/job.action.ts +++ b/src/actions/job.action.ts @@ -31,6 +31,7 @@ import { } from '@/types/jobs.types'; import { withAdminServerAction } from '@/lib/admin'; import { revalidatePath } from 'next/cache'; +import { sendNotificationAction } from '@/actions/notification'; type additional = { isVerifiedJob: boolean; @@ -467,15 +468,28 @@ export const toggleApproveJob = withAdminServerAction< throw new Error('Job not found'); } - await prisma.job.update({ + const updatedJob = await prisma.job.update({ where: { id: id, }, data: { isVerifiedJob: !job.isVerifiedJob, }, + select: { + id: true, + isVerifiedJob: true, + }, }); + if (updatedJob.isVerifiedJob) { + await sendNotificationAction( + 'New Posting Alert', + 'New Job is recently posted by 100xdevs , come fast and apply before other applys.', + `/jobs/${updatedJob.id}`, + '/main.png' + ); + } + revalidatePath('/manage'); const message = job.isVerifiedJob ? 'Job Unapproved' : 'Job Approved'; return new SuccessResponse(message, 200, { jobId: id }).serialize(); diff --git a/src/actions/notification.ts b/src/actions/notification.ts new file mode 100644 index 00000000..4c5e3516 --- /dev/null +++ b/src/actions/notification.ts @@ -0,0 +1,142 @@ +'use server'; + +import prisma from '@/config/prisma.config'; +import { authOptions } from '@/lib/authOptions'; +import { getServerSession } from 'next-auth'; +import webpush from 'web-push'; + +const vapidKeys = { + publicKey: process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY, + privateKey: process.env.VAPID_PRIVATE_KEY, +}; + +//Handling the db add logic for subscription + +export async function AddUserSubscription(subscription: string) { + try { + const user = await getServerSession(authOptions); + const userId = user?.user.id; + + if (!userId) throw new Error('User not loggedIn'); + + const isUser = await prisma.user.findFirst({ + where: { + id: userId, + }, + select: { + id: true, + }, + }); + + if (!isUser) throw new Error('No user found'); + + await prisma.notifications.create({ + data: { + subscription: subscription, + userId: isUser.id, + }, + }); + + return { + status: 200, + message: 'Subscription Added', + }; + } catch (error) { + return { + status: 400, + message: (error as Error).message, + }; + } +} + +//Handling the db remove logic for subscription + +export async function DeleteUserSubscription() { + try { + const user = await getServerSession(authOptions); + const userId = user?.user.id; + + if (!userId) throw new Error('User not loggedIn'); + + const isUser = await prisma.notifications.findFirst({ + where: { + userId: userId, + }, + select: { + id: true, + }, + }); + + if (!isUser) throw new Error('No user found'); + + await prisma.notifications.deleteMany({ + where: { + id: isUser.id, + }, + }); + + return { + status: 200, + message: 'Subscription Delete', + }; + } catch (error) { + return { + status: 400, + message: (error as Error).message, + }; + } +} + +//Handling the push notification + +export async function sendNotificationAction( + message: string, + body: string, + route: string, + icon: string +) { + try { + const user = await getServerSession(authOptions); + const userId = user?.user.id; + + if (!userId) throw new Error('User not loggedIn'); + + const allUser = await prisma.notifications.findMany({ + where: { + userId: { + not: userId, + }, + }, + }); + + if (!allUser || allUser.length <= 0) throw new Error('No other user found'); + + webpush.setVapidDetails( + 'mailto:example@yourdomain.org', + vapidKeys.publicKey as string, + vapidKeys.privateKey as string + ); + + for (let data of allUser) { + await webpush.sendNotification( + JSON.parse(data.subscription), + JSON.stringify({ + message, + icon, + body, + route, + }) + ); + } + + return { + status: 200, + message: 'Notification sent successfully', + }; + } catch (error) { + return { + status: 400, + message: (error as Error).message, + }; + } +} diff --git a/src/actions/user.profile.actions.ts b/src/actions/user.profile.actions.ts index 51128cda..cc9f3847 100644 --- a/src/actions/user.profile.actions.ts +++ b/src/actions/user.profile.actions.ts @@ -317,3 +317,28 @@ export const getUserDetails = async () => { return new ErrorHandler('Internal server error', 'DATABASE_ERROR'); } }; + +export const getUserSubscription = async () => { + const auth = await getServerSession(authOptions); + + if (!auth || !auth?.user?.id) + throw new ErrorHandler('Not Authorized', 'UNAUTHORIZED'); + try { + const res = await prisma.notifications.findFirst({ + where: { + userId: auth.user.id, + }, + }); + return { + status: 200, + message: 'Subscription Retrieved', + data: res, + }; + } catch (error) { + return { + status: 400, + message: (error as Error).message, + data: null, + }; + } +}; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 352bb044..176e7638 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -6,6 +6,7 @@ import type { Metadata } from 'next'; import NextTopLoader from 'nextjs-toploader'; import './globals.css'; import localFont from 'next/font/local'; +import Pwa from '@/components/Pwa'; const satoshi = localFont({ display: 'swap', @@ -19,6 +20,7 @@ const satoshi = localFont({ export const metadata: Metadata = { title: '100xJobs', description: 'Get your dream job', + keywords: ['jobs', 'job portal', 'remote job', 'saas'], }; export default async function RootLayout({ @@ -41,6 +43,7 @@ export default async function RootLayout({
{children}