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}
+
{/* Commenting this out for temp basis */}
{/* */}