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) } })