diff --git a/app/api/notifications/route.ts b/app/api/notifications/route.ts
new file mode 100644
index 0000000..25ba428
--- /dev/null
+++ b/app/api/notifications/route.ts
@@ -0,0 +1,53 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { withAuth } from '@/lib/auth/middleware'
+import { sql } from '@/lib/db'
+import { getUserIdByWallet } from '@/lib/reputation'
+
+export const GET = withAuth(async (request: NextRequest, auth) => {
+ const userId = await getUserIdByWallet(auth.walletAddress)
+ if (userId === null) {
+ return NextResponse.json({ error: 'User not found' }, { status: 404 })
+ }
+
+ try {
+ const notifications = await sql`
+ SELECT id, type, title, message, link, is_read, created_at
+ FROM notifications
+ WHERE user_id = ${userId}
+ ORDER BY created_at DESC
+ LIMIT 50
+ `
+ return NextResponse.json(notifications)
+ } catch (error) {
+ console.error('Failed to fetch notifications:', error)
+ return NextResponse.json({ error: 'Failed to fetch notifications' }, { status: 500 })
+ }
+})
+
+export const PATCH = withAuth(async (request: NextRequest, auth) => {
+ const userId = await getUserIdByWallet(auth.walletAddress)
+ if (userId === null) {
+ return NextResponse.json({ error: 'User not found' }, { status: 404 })
+ }
+
+ try {
+ const { id } = await request.json()
+ if (id) {
+ await sql`
+ UPDATE notifications
+ SET is_read = TRUE
+ WHERE id = ${id} AND user_id = ${userId}
+ `
+ } else {
+ await sql`
+ UPDATE notifications
+ SET is_read = TRUE
+ WHERE user_id = ${userId} AND is_read = FALSE
+ `
+ }
+ return NextResponse.json({ success: true })
+ } catch (error) {
+ console.error('Failed to update notifications:', error)
+ return NextResponse.json({ error: 'Failed to update notifications' }, { status: 500 })
+ }
+})
diff --git a/components/navbar.tsx b/components/navbar.tsx
index 22cae27..0dbc17f 100644
--- a/components/navbar.tsx
+++ b/components/navbar.tsx
@@ -6,6 +6,7 @@ import { Menu, X, Wallet } from 'lucide-react'
import { useState, useEffect } from 'react'
import Image from 'next/image'
import { ThemeToggle } from './ui/ThemeToggle'
+import { NotificationBell } from './notifications/notification-bell'
export function Navbar() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
@@ -69,7 +70,8 @@ export function Navbar() {
-
+
+
>
)}
@@ -116,6 +118,9 @@ export function Navbar() {
+
+
+
>
)}
diff --git a/components/notifications/notification-bell.tsx b/components/notifications/notification-bell.tsx
new file mode 100644
index 0000000..4674519
--- /dev/null
+++ b/components/notifications/notification-bell.tsx
@@ -0,0 +1,136 @@
+"use client"
+
+import { formatDistanceToNow } from 'date-fns'
+import { Bell, Check, Loader2 } from 'lucide-react'
+import { useEffect, useState } from 'react'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+import { Button } from '@/components/ui/button'
+import { Badge } from '@/components/ui/badge'
+import { cn } from '@/lib/utils'
+
+interface Notification {
+ id: number
+ type: string
+ title: string
+ message: string
+ link: string | null
+ is_read: boolean
+ created_at: string
+}
+
+export function NotificationBell() {
+ const [notifications, setNotifications] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [unreadCount, setUnreadCount] = useState(0)
+
+ const fetchNotifications = async () => {
+ try {
+ const res = await fetch('/api/notifications')
+ if (res.ok) {
+ const data = await res.json()
+ setNotifications(data)
+ setUnreadCount(data.filter((n: Notification) => !n.is_read).length)
+ }
+ } catch (error) {
+ console.error('Failed to fetch notifications:', error)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const markAllAsRead = async () => {
+ try {
+ const res = await fetch('/api/notifications', { method: 'PATCH', body: JSON.stringify({}) })
+ if (res.ok) {
+ setNotifications(notifications.map(n => ({ ...n, is_read: true })))
+ setUnreadCount(0)
+ }
+ } catch (error) {
+ console.error('Failed to mark all as read:', error)
+ }
+ }
+
+ const markAsRead = async (id: number) => {
+ try {
+ const res = await fetch('/api/notifications', { method: 'PATCH', body: JSON.stringify({ id }) })
+ if (res.ok) {
+ setNotifications(notifications.map(n => n.id === id ? { ...n, is_read: true } : n))
+ setUnreadCount(prev => Math.max(0, prev - 1))
+ }
+ } catch (error) {
+ console.error('Failed to mark as read:', error)
+ }
+ }
+
+ useEffect(() => {
+ fetchNotifications()
+ const interval = setInterval(fetchNotifications, 30000) // Poll every 30s
+ return () => clearInterval(interval)
+ }, [])
+
+ return (
+
+
+
+
+
+
+
Notifications
+ {unreadCount > 0 && (
+
+ )}
+
+
+ {loading ? (
+
+
+
+ ) : notifications.length === 0 ? (
+
+ No notifications yet.
+
+ ) : (
+ notifications.map((notification) => (
+
!notification.is_read && markAsRead(notification.id)}
+ >
+ {!notification.is_read && (
+
+ )}
+
{notification.title}
+
+ {notification.message}
+
+
+ {formatDistanceToNow(new Date(notification.created_at), { addSuffix: true })}
+
+
+ ))
+ )}
+
+
+
+ )
+}
diff --git a/lib/notifications/service.ts b/lib/notifications/service.ts
new file mode 100644
index 0000000..79830de
--- /dev/null
+++ b/lib/notifications/service.ts
@@ -0,0 +1,39 @@
+import { sql } from '@/lib/db';
+import { NotificationPayload } from './types';
+import { templates } from './templates';
+
+export class NotificationService {
+ /**
+ * Sends a notification to a user via all enabled channels.
+ */
+ static async send(payload: NotificationPayload) {
+ const template = templates[payload.type](payload.data);
+
+ // 1. Store in-app notification in the database
+ await this.sendToDatabase(payload, template);
+
+ // 2. Send email notification (placeholder)
+ await this.sendEmail(payload, template);
+
+ console.log(`[NotificationService] Notification sent to User #${payload.userId}: ${template.title}`);
+ }
+
+ private static async sendToDatabase(payload: NotificationPayload, template: { title: string; message: string }) {
+ try {
+ await sql`
+ INSERT INTO notifications (user_id, type, title, message, link)
+ VALUES (${payload.userId}, ${payload.type}, ${template.title}, ${template.message}, ${payload.link || null})
+ `;
+ } catch (error) {
+ console.error('[NotificationService] Failed to store notification in database:', error);
+ }
+ }
+
+ private static async sendEmail(payload: NotificationPayload, template: { title: string; message: string }) {
+ // Placeholder for email service integration (e.g., Resend, SendGrid)
+ // In a real app, we would fetch the user's email and send the message.
+ console.log(`[EMAIL SIMULATION] To: User #${payload.userId}`);
+ console.log(`[EMAIL SIMULATION] Subject: ${template.title}`);
+ console.log(`[EMAIL SIMULATION] Body: ${template.message}`);
+ }
+}
diff --git a/lib/notifications/templates.ts b/lib/notifications/templates.ts
new file mode 100644
index 0000000..58e2a9e
--- /dev/null
+++ b/lib/notifications/templates.ts
@@ -0,0 +1,24 @@
+import { NotificationType, NotificationTemplate } from './types';
+
+export const templates: Record NotificationTemplate> = {
+ milestone_submitted: (data: { jobTitle: string; freelancerName: string }) => ({
+ title: 'Milestone Submitted',
+ message: `${data.freelancerName} has submitted a milestone for "${data.jobTitle}".`,
+ }),
+ funds_released: (data: { jobTitle: string; amount: string; currency: string }) => ({
+ title: 'Funds Released',
+ message: `Payment of ${data.amount} ${data.currency} for "${data.jobTitle}" has been released to your wallet.`,
+ }),
+ dispute_opened: (data: { jobTitle: string; reason: string }) => ({
+ title: 'Dispute Opened',
+ message: `A dispute has been opened for "${data.jobTitle}". Reason: ${data.reason}`,
+ }),
+ wallet_activity: (data: { action: string; amount: string; currency: string }) => ({
+ title: 'Wallet Activity',
+ message: `New activity detected: ${data.action} of ${data.amount} ${data.currency}.`,
+ }),
+ funds_received: (data: { jobTitle: string; amount: string; currency: string }) => ({
+ title: 'Funds Received in Escrow',
+ message: `A deposit of ${data.amount} ${data.currency} for "${data.jobTitle}" has been confirmed in escrow.`,
+ }),
+};
diff --git a/lib/notifications/types.ts b/lib/notifications/types.ts
new file mode 100644
index 0000000..8ee9f70
--- /dev/null
+++ b/lib/notifications/types.ts
@@ -0,0 +1,18 @@
+export type NotificationType =
+ | 'milestone_submitted'
+ | 'funds_released'
+ | 'dispute_opened'
+ | 'wallet_activity'
+ | 'funds_received';
+
+export interface NotificationPayload {
+ userId: number;
+ type: NotificationType;
+ data: Record;
+ link?: string;
+}
+
+export interface NotificationTemplate {
+ title: string;
+ message: string;
+}
diff --git a/scripts/004-notifications.sql b/scripts/004-notifications.sql
new file mode 100644
index 0000000..eac7246
--- /dev/null
+++ b/scripts/004-notifications.sql
@@ -0,0 +1,18 @@
+-- Notification System Schema
+-- Stores in-app alerts for users
+
+CREATE TABLE IF NOT EXISTS notifications (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ type VARCHAR(50) NOT NULL, -- e.g., 'milestone_submitted', 'funds_released', 'dispute_opened', 'wallet_activity'
+ title VARCHAR(255) NOT NULL,
+ message TEXT NOT NULL,
+ link VARCHAR(255), -- Optional URL for navigation
+ is_read BOOLEAN DEFAULT FALSE,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Index for faster retrieval of user notifications
+CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id);
+CREATE INDEX IF NOT EXISTS idx_notifications_is_read ON notifications(is_read);
+CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at);
diff --git a/scripts/worker.ts b/scripts/worker.ts
index 8c164af..0d8a11a 100644
--- a/scripts/worker.ts
+++ b/scripts/worker.ts
@@ -1,31 +1,29 @@
import { Server } from '@stellar/stellar-sdk'
-import { neon } from '@neondatabase/serverless'
import * as dotenv from 'dotenv'
-
+import { sql } from '../lib/db'
+import { NotificationService } from '../lib/notifications/service'
dotenv.config()
-if (!process.env.DATABASE_URL) {
- console.error('FATAL: DATABASE_URL is not set. Worker cannot connect to the database.')
- process.exit(1)
-}
-
-const sql = neon(process.env.DATABASE_URL)
-
const server = new Server('https://horizon-testnet.stellar.org')
-
const PLATFORM_ESCROW_ACCOUNT = process.env.ESCROW_ACCOUNT_ID || 'GBD2Z3PZ2L5KHTC4YQZKVH4A4XJ4Q5X6M7N8O9P0Q1R2S3T4U5V6W7X8' // Dummy Address
-async function notifyUsers(jobId: number, message: string) {
-
- console.log(`[NOTIFICATION] Sending update for Job #${jobId}: ${message}`)
-
+async function notifyUsers(userId: number, type: any, data: any) {
+ try {
+ await NotificationService.send({
+ userId,
+ type,
+ data,
+ });
+ console.log(`[WORKER] Internal notification sent for Job update.`);
+ } catch (error) {
+ console.error('[WORKER ERROR] Failed to send notification:', error);
+ }
}
async function processPaymentEvent(record: any) {
try {
-
const transaction = await record.transaction()
const memo = transaction.memo
const isDeposit = record.to === PLATFORM_ESCROW_ACCOUNT
@@ -33,7 +31,6 @@ async function processPaymentEvent(record: any) {
const currency = record.asset_type === 'native' ? 'XLM' : record.asset_code
if (isDeposit && memo) {
-
const jobIdStr = memo.replace('JOB-', '')
const jobId = parseInt(jobIdStr, 10)
@@ -41,31 +38,35 @@ async function processPaymentEvent(record: any) {
console.log(`[WORKER] Detected DEPOSIT of ${amount} ${currency} for Job ${jobId}`)
-
const existingTx = await sql`SELECT id FROM escrow_transactions WHERE stellar_transaction_hash = ${transaction.hash}`
if (existingTx.length > 0) {
console.log(`[WORKER] Transaction ${transaction.hash} already processed. Skipping.`)
return
}
-
await sql`
INSERT INTO escrow_transactions (job_id, stellar_transaction_hash, amount, currency, transaction_type, from_wallet, to_wallet, status)
VALUES (${jobId}, ${transaction.hash}, ${amount}, ${currency}, 'deposit', ${record.from}, ${record.to}, 'confirmed')
`
-
-
+
await sql`
UPDATE jobs
SET escrow_status = 'funded', status = 'in_progress', updated_at = CURRENT_TIMESTAMP
WHERE id = ${jobId} AND escrow_status != 'funded'
`
-
- await notifyUsers(jobId, `A deposit of ${amount} ${currency} has been confirmed in escrow.`)
+ // Fetch client and freelancer IDs for the job
+ const jobInfo = await sql`SELECT client_id, freelancer_id, title FROM jobs WHERE id = ${jobId}`;
+ if (jobInfo.length > 0) {
+ const { client_id, freelancer_id, title } = jobInfo[0];
+ // Notify both client and freelancer about the deposit
+ await notifyUsers(client_id, 'funds_received', { jobTitle: title, amount, currency });
+ if (freelancer_id) {
+ await notifyUsers(freelancer_id, 'funds_received', { jobTitle: title, amount, currency });
+ }
+ }
} else if (record.from === PLATFORM_ESCROW_ACCOUNT && memo) {
-
const jobIdStr = memo.replace('JOB-', '')
const jobId = parseInt(jobIdStr, 10)
@@ -79,13 +80,11 @@ async function processPaymentEvent(record: any) {
return
}
-
await sql`
INSERT INTO escrow_transactions (job_id, stellar_transaction_hash, amount, currency, transaction_type, from_wallet, to_wallet, status)
VALUES (${jobId}, ${transaction.hash}, ${amount}, ${currency}, 'release', ${record.from}, ${record.to}, 'confirmed')
`
-
await sql`
UPDATE jobs
SET escrow_status = 'released',
@@ -95,8 +94,15 @@ async function processPaymentEvent(record: any) {
WHERE id = ${jobId} AND escrow_status != 'released'
`
-
- await notifyUsers(jobId, `Payment of ${amount} ${currency} has been released from escrow. Contract marked as completed.`)
+ // Notify freelancer about the release
+ const jobInfo = await sql`SELECT freelancer_id, title FROM jobs WHERE id = ${jobId}`;
+ if (jobInfo.length > 0 && jobInfo[0].freelancer_id) {
+ await notifyUsers(jobInfo[0].freelancer_id, 'funds_released', {
+ jobTitle: jobInfo[0].title,
+ amount,
+ currency
+ });
+ }
}
} catch (error) {
@@ -108,18 +114,16 @@ async function startWorker() {
console.log('[WORKER] Starting Stellar Blockchain Event Worker...')
console.log(`[WORKER] Monitoring Escrow Account/Contract: ${PLATFORM_ESCROW_ACCOUNT}`)
-
server.payments()
.forAccount(PLATFORM_ESCROW_ACCOUNT)
.cursor('now')
.stream({
onmessage: async (paymentRecord: any) => {
-
if (paymentRecord.type === 'payment') {
await processPaymentEvent(paymentRecord)
}
},
- onerror: (error) => {
+ onerror: (error: any) => {
console.error('[WORKER ERROR] Stellar SDK Streaming Error:', error)
}
})