feat: implement notification system

This commit is contained in:
Face 2025-06-11 18:37:03 +03:00
parent de3f8a4929
commit e61c41e414
19 changed files with 883 additions and 3196 deletions

View file

@ -30,7 +30,8 @@
ShieldCheck,
Hammer,
BookOpen,
Info
Info,
Bell
} from 'lucide-svelte';
import { mode, setMode } from 'mode-watcher';
import type { HTMLAttributes } from 'svelte/elements';
@ -46,6 +47,7 @@
import { goto } from '$app/navigation';
import { liveTradesStore, isLoadingTrades } from '$lib/stores/websocket';
import { onMount } from 'svelte';
import { UNREAD_COUNT, fetchNotifications } from '$lib/stores/notifications';
const data = {
navMain: [
@ -57,6 +59,7 @@
{ title: 'Portfolio', url: '/portfolio', icon: BriefcaseBusiness },
{ title: 'Treemap', url: '/treemap', icon: ChartColumn },
{ title: 'Create coin', url: '/coin/create', icon: Coins },
{ title: 'Notifications', url: '/notifications', icon: Bell },
{ title: 'About', url: '/about', icon: Info }
]
};
@ -70,6 +73,7 @@
onMount(() => {
if ($USER_DATA) {
fetchPortfolioSummary();
fetchNotifications();
} else {
PORTFOLIO_SUMMARY.set(null);
}
@ -177,10 +181,15 @@
<a
href={item.url || '/'}
onclick={() => handleNavClick(item.title)}
class={`${props.class}`}
class={`${props.class} ${item.title === 'Notifications' && !$USER_DATA ? 'pointer-events-none opacity-50' : ''}`}
>
<item.icon />
<span>{item.title}</span>
{#if item.title === 'Notifications' && $UNREAD_COUNT > 0 && $USER_DATA}
<Sidebar.MenuBadge class="bg-primary text-primary-foreground">
{$UNREAD_COUNT > 99 ? '99+' : $UNREAD_COUNT}
</Sidebar.MenuBadge>
{/if}
</a>
{/snippet}
</Sidebar.MenuButton>
@ -358,7 +367,8 @@
</div>
<div class="flex justify-between">
<span>Coins:</span>
<span class="font-mono">${formatCurrency($PORTFOLIO_SUMMARY.totalCoinValue)}</span>
<span class="font-mono">${formatCurrency($PORTFOLIO_SUMMARY.totalCoinValue)}</span
>
</div>
</div>
{/if}

View file

@ -0,0 +1,21 @@
<script lang="ts">
import { Skeleton } from '$lib/components/ui/skeleton';
</script>
<div class="space-y-1">
{#each Array(8) as _, i}
<div class="flex items-start gap-4 rounded-md p-3">
<Skeleton class="h-8 w-8 flex-shrink-0 rounded-full" />
<div class="min-w-0 flex-1 space-y-2">
<Skeleton class="h-4 w-48" />
<Skeleton class="h-3 w-full max-w-md" />
</div>
<div class="flex flex-shrink-0 flex-col items-end gap-1">
<Skeleton class="h-3 w-16" />
</div>
</div>
{#if i < 7}
<div class="border-border border-t"></div>
{/if}
{/each}
</div>

View file

@ -3,6 +3,7 @@ import { sql } from "drizzle-orm";
export const transactionTypeEnum = pgEnum('transaction_type', ['BUY', 'SELL', 'TRANSFER_IN', 'TRANSFER_OUT']);
export const predictionMarketEnum = pgEnum('prediction_market_status', ['ACTIVE', 'RESOLVED', 'CANCELLED']);
export const notificationTypeEnum = pgEnum('notification_type', ['HOPIUM', 'SYSTEM', 'TRANSFER', 'RUG_PULL']);
export const user = pgTable("user", {
id: serial("id").primaryKey(),
@ -226,4 +227,21 @@ export const accountDeletionRequest = pgTable("account_deletion_request", {
.on(table.userId)
.where(sql`is_processed = false`),
};
});
export const notifications = pgTable("notification", {
id: serial("id").primaryKey(),
userId: integer("user_id").notNull().references(() => user.id, { onDelete: "cascade" }),
type: notificationTypeEnum("type").notNull(),
title: varchar("title", { length: 200 }).notNull(),
message: text("message").notNull(),
isRead: boolean("is_read").notNull().default(false),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => {
return {
userIdIdx: index("notification_user_id_idx").on(table.userId),
typeIdx: index("notification_type_idx").on(table.type),
isReadIdx: index("notification_is_read_idx").on(table.isRead),
createdAtIdx: index("notification_created_at_idx").on(table.createdAt),
};
});

View file

@ -2,6 +2,8 @@ import { db } from '$lib/server/db';
import { predictionQuestion, predictionBet, user, accountDeletionRequest, session, account, promoCodeRedemption, userPortfolio, commentLike, comment, transaction, coin } from '$lib/server/db/schema';
import { eq, and, lte, isNull } from 'drizzle-orm';
import { resolveQuestion, getRugplayData } from '$lib/server/ai';
import { createNotification } from '$lib/server/notification';
import { formatValue } from '$lib/utils';
export async function resolveExpiredQuestions() {
const now = new Date();
@ -68,6 +70,13 @@ export async function resolveExpiredQuestions() {
? Number(question.totalYesAmount)
: Number(question.totalNoAmount);
const notificationsToCreate: Array<{
userId: number;
amount: number;
winnings: number;
won: boolean;
}> = [];
for (const bet of bets) {
const won = bet.side === resolution.resolution;
@ -101,6 +110,32 @@ export async function resolveExpiredQuestions() {
.where(eq(user.id, bet.userId));
}
}
if (bet.userId !== null) {
notificationsToCreate.push({
userId: bet.userId,
amount: Number(bet.amount),
winnings,
won
});
}
}
// Create notifications for all users who had bets
for (const notifData of notificationsToCreate) {
const { userId, amount, winnings, won } = notifData;
const title = won ? 'Prediction won! 🎉' : 'Prediction lost ;(';
const message = won
? `You won ${formatValue(winnings)} on "${question.question}"`
: `You lost ${formatValue(amount)} on "${question.question}"`;
await createNotification(
userId.toString(),
'HOPIUM',
title,
message,
);
}
});

View file

@ -0,0 +1,36 @@
import { db } from './db';
import { notifications, notificationTypeEnum } from './db/schema';
import { redis } from './redis';
export type NotificationType = typeof notificationTypeEnum.enumValues[number];
export async function createNotification(
userId: string,
type: NotificationType,
title: string,
message: string,
): Promise<void> {
await db.insert(notifications).values({
userId: parseInt(userId),
type,
title,
message
});
try {
const channel = `notifications:${userId}`;
const payload = {
type: 'notification',
timestamp: new Date().toISOString(),
userId,
notificationType: type,
title,
message,
};
await redis.publish(channel, JSON.stringify(payload));
} catch (error) {
console.error('Failed to send notification via Redis:', error);
}
}

View file

@ -0,0 +1,61 @@
import { writable, derived } from 'svelte/store';
export interface Notification {
id: number;
type: string;
title: string;
message: string;
data: any;
isRead: boolean;
createdAt: string;
}
export const NOTIFICATIONS = writable<Notification[]>([]);
export const UNREAD_COUNT = writable<number>(0);
export async function fetchNotifications(unreadOnly = false) {
try {
const params = new URLSearchParams({
unread_only: unreadOnly.toString()
});
const response = await fetch(`/api/notifications?${params}`);
if (!response.ok) throw new Error('Failed to fetch notifications');
const data = await response.json();
NOTIFICATIONS.set(data.notifications);
UNREAD_COUNT.set(data.unreadCount);
return data;
} catch (error) {
console.error('Failed to fetch notifications:', error);
throw error;
}
}
export async function markNotificationsAsRead(ids: number[]) {
try {
const response = await fetch('/api/notifications', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids, markAsRead: true })
});
if (!response.ok) throw new Error('Failed to mark notifications as read');
NOTIFICATIONS.update(notifications =>
notifications.map(notif =>
ids.includes(notif.id) ? { ...notif, isRead: true } : notif
)
);
UNREAD_COUNT.update(count => Math.max(0, count - ids.length));
} catch (error) {
console.error('Failed to mark notifications as read:', error);
throw error;
}
}
export const hasUnreadNotifications = derived(UNREAD_COUNT, count => count > 0);

View file

@ -1,6 +1,10 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
import { PUBLIC_WEBSOCKET_URL } from '$env/static/public';
import { NOTIFICATIONS, UNREAD_COUNT } from './notifications';
import { USER_DATA } from './user-data';
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
export interface LiveTrade {
type: 'BUY' | 'SELL' | 'TRANSFER_IN' | 'TRANSFER_OUT';
@ -187,6 +191,32 @@ function handleWebSocketMessage(event: MessageEvent): void {
handleCommentMessage(message);
break;
case 'notification':
const notification = {
id: Date.now(),
type: message.notificationType,
title: message.title,
message: message.message,
isRead: false,
createdAt: message.timestamp,
data: message.amount ? { amount: message.amount } : null
};
NOTIFICATIONS.update(notifications => [notification, ...notifications]);
UNREAD_COUNT.update(count => count + 1);
toast.success(message.title, {
description: message.message,
action: {
label: 'View',
onClick: () => {
goto('/notifications');
}
},
duration: 5000
});
break;
default:
console.log('Unhandled message type:', message.type, message);
}
@ -267,13 +297,56 @@ function unsubscribeFromPriceUpdates(coinSymbol: string): void {
priceUpdateSubscriptions.delete(coinSymbol);
}
export const websocketController = {
connect,
disconnect,
setCoin,
subscribeToComments,
unsubscribeFromComments,
subscribeToPriceUpdates,
unsubscribeFromPriceUpdates,
loadInitialTrades
};
class WebSocketController {
connect() {
connect();
}
disconnect() {
disconnect();
}
setCoin(coinSymbol: string) {
setCoin(coinSymbol);
}
subscribeToComments(coinSymbol: string, callback: (message: any) => void) {
subscribeToComments(coinSymbol, callback);
}
unsubscribeFromComments(coinSymbol: string) {
unsubscribeFromComments(coinSymbol);
}
subscribeToPriceUpdates(coinSymbol: string, callback: (priceUpdate: PriceUpdate) => void) {
subscribeToPriceUpdates(coinSymbol, callback);
}
unsubscribeFromPriceUpdates(coinSymbol: string) {
unsubscribeFromPriceUpdates(coinSymbol);
}
loadInitialTrades(mode: 'preview' | 'expanded' = 'preview') {
loadInitialTrades(mode);
}
setUser(userId: string) {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({
type: 'set_user',
userId
}));
}
}
}
// Auto-connect user when USER_DATA changes
if (typeof window !== 'undefined') {
USER_DATA.subscribe(user => {
if (user?.id) {
websocketController.setUser(user.id.toString());
}
});
}
export const websocketController = new WebSocketController();

View file

@ -4,6 +4,7 @@ import { db } from '$lib/server/db';
import { coin, userPortfolio, user, transaction, priceHistory } from '$lib/server/db/schema';
import { eq, and, gte } from 'drizzle-orm';
import { redis } from '$lib/server/redis';
import { createNotification } from '$lib/server/notification';
async function calculate24hMetrics(coinId: number, currentPrice: number) {
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
@ -329,6 +330,36 @@ export async function POST({ params, request }) {
})
.where(eq(coin.id, coinData.id));
const isRugPull = priceImpact < -20 && totalCost > 1000;
// Send rug pull notifications to affected users
if (isRugPull) {
(async () => {
const affectedUsers = await db
.select({
userId: userPortfolio.userId,
quantity: userPortfolio.quantity
})
.from(userPortfolio)
.where(eq(userPortfolio.coinId, coinData.id));
for (const holder of affectedUsers) {
if (holder.userId === userId) continue;
const holdingValue = Number(holder.quantity) * newPrice;
if (holdingValue > 10) {
const lossAmount = Number(holder.quantity) * (currentPrice - newPrice);
await createNotification(
holder.userId.toString(),
'RUG_PULL',
'Coin rugpulled!',
`A coin you owned, ${coinData.name} (*${normalizedSymbol}), crashed ${Math.abs(priceImpact).toFixed(1)}%!`,
);
}
}
})();
}
const priceUpdateData = {
currentPrice: newPrice,
marketCap: Number(coinData.circulatingSupply) * newPrice,

View file

@ -0,0 +1,77 @@
import { auth } from '$lib/auth';
import { error, json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { notifications } from '$lib/server/db/schema';
import { eq, desc, and, count, inArray } from 'drizzle-orm';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url, request }) => {
const session = await auth.api.getSession({ headers: request.headers });
if (!session?.user) throw error(401, 'Not authenticated');
const userId = Number(session.user.id);
const unreadOnly = url.searchParams.get('unread_only') === 'true';
try {
const conditions = [eq(notifications.userId, userId)];
if (unreadOnly) {
conditions.push(eq(notifications.isRead, false));
}
const whereCondition = and(...conditions);
const notificationsList = await db.select({
id: notifications.id,
type: notifications.type,
title: notifications.title,
message: notifications.message,
isRead: notifications.isRead,
createdAt: notifications.createdAt,
})
.from(notifications)
.where(whereCondition)
.orderBy(desc(notifications.createdAt))
.limit(50);
const unreadCount = await db
.select({ count: count() })
.from(notifications)
.where(and(eq(notifications.userId, userId), eq(notifications.isRead, false)))
.then(result => result[0]?.count || 0);
return json({
notifications: notificationsList,
unreadCount
});
} catch (e) {
console.error('Failed to fetch notificationss:', e);
throw error(500, 'Failed to fetch notificationss');
}
};
export const PATCH: RequestHandler = async ({ request }) => {
const session = await auth.api.getSession({ headers: request.headers });
if (!session?.user) throw error(401, 'Not authenticated');
const userId = Number(session.user.id);
const { ids, markAsRead } = await request.json();
if (!Array.isArray(ids) || typeof markAsRead !== 'boolean') {
throw error(400, 'Invalid request body');
}
try {
await db
.update(notifications)
.set({ isRead: markAsRead })
.where(and(
eq(notifications.userId, userId),
inArray(notifications.id, ids)
));
return json({ success: true });
} catch (e) {
console.error('Failed to update notifications:', e);
throw error(500, 'Failed to update notifications');
}
};

View file

@ -3,6 +3,8 @@ import { error, json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { user, userPortfolio, coin, transaction } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm';
import { createNotification } from '$lib/server/notification';
import { formatValue } from '$lib/utils';
import type { RequestHandler } from './$types';
interface TransferRequest {
@ -19,7 +21,7 @@ export const POST: RequestHandler = async ({ request }) => {
if (!session?.user) {
throw error(401, 'Not authenticated');
} try {
} try {
const { recipientUsername, type, amount, coinSymbol }: TransferRequest = await request.json();
if (!recipientUsername || !type || !amount || typeof amount !== 'number' || !Number.isFinite(amount) || amount <= 0) {
@ -123,6 +125,15 @@ export const POST: RequestHandler = async ({ request }) => {
recipientUserId: recipientData.id
});
(async () => {
await createNotification(
recipientData.id.toString(),
'TRANSFER',
'Money received!',
`You received ${formatValue(amount)} from @${senderData.username}`,
);
})();
return json({
success: true,
type: 'CASH',
@ -247,6 +258,15 @@ export const POST: RequestHandler = async ({ request }) => {
recipientUserId: recipientData.id
});
(async () => {
await createNotification(
recipientData.id.toString(),
'TRANSFER',
'Coins received!',
`You received ${amount.toFixed(6)} *${coinData.symbol} from @${senderData.username}`,
);
})();
return json({
success: true,
type: 'COIN',

View file

@ -0,0 +1,202 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import { Badge } from '$lib/components/ui/badge';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import { Separator } from '$lib/components/ui/separator/index.js';
import SEO from '$lib/components/self/SEO.svelte';
import NotificationsSkeleton from '$lib/components/self/skeletons/NotificationsSkeleton.svelte';
import { Bell, Target, Settings, TrendingUp, AlertTriangle } from 'lucide-svelte';
import { onMount } from 'svelte';
import {
NOTIFICATIONS,
fetchNotifications,
markNotificationsAsRead
} from '$lib/stores/notifications';
import { USER_DATA } from '$lib/stores/user-data';
import { formatTimeAgo, formatValue } from '$lib/utils';
import { goto } from '$app/navigation';
import { toast } from 'svelte-sonner';
let loading = $state(true);
let newNotificationIds = $state<number[]>([]);
onMount(async () => {
if (!$USER_DATA) {
goto('/');
return;
}
try {
await fetchNotifications();
const unreadIds = ($NOTIFICATIONS || []).filter((n) => !n.isRead).map((n) => n.id);
newNotificationIds = unreadIds;
if (unreadIds.length > 0) {
await markNotificationsAsRead(unreadIds);
}
} catch (error) {
toast.error('Failed to load notifications');
} finally {
loading = false;
}
});
function getNotificationIcon(type: string) {
switch (type) {
case 'HOPIUM':
return Target;
case 'TRANSFER':
return TrendingUp;
case 'RUG_PULL':
return AlertTriangle;
case 'SYSTEM':
return Settings;
default:
return Bell;
}
}
function getNotificationColorClasses(type: string, isNew: boolean, isRead: boolean) {
const base =
'hover:bg-muted/50 flex w-full items-start gap-4 rounded-md p-3 text-left transition-all duration-200';
if (isNew) {
return `${base} bg-primary/10`;
}
if (!isRead) {
const colors = {
HOPIUM: 'bg-blue-50/50 dark:bg-blue-950/10',
TRANSFER: 'bg-green-50/50 dark:bg-green-950/10',
RUG_PULL: 'bg-red-50/50 dark:bg-red-950/10',
SYSTEM: 'bg-purple-50/50 dark:bg-purple-950/10'
};
return `${base} ${colors[type as keyof typeof colors] || 'bg-muted/20'}`;
}
return base;
}
function getNotificationIconColorClasses(type: string) {
const colors = {
HOPIUM: 'bg-blue-100 text-blue-600 dark:bg-blue-900/50 dark:text-blue-400',
TRANSFER: 'bg-green-100 text-green-600 dark:bg-green-900/50 dark:text-green-400',
RUG_PULL: 'bg-red-100 text-red-600 dark:bg-red-900/50 dark:text-red-400',
SYSTEM: 'bg-purple-100 text-purple-600 dark:bg-purple-900/50 dark:text-purple-400'
};
return colors[type as keyof typeof colors] || 'bg-muted text-muted-foreground';
}
</script>
<SEO
title="Notifications - Rugplay"
description="View your notifications and updates from Rugplay."
/>
<div class="container mx-auto max-w-4xl p-6">
<header class="mb-8">
<div class="text-center">
<h1 class="mb-2 text-3xl font-bold">Notifications</h1>
<p class="text-muted-foreground mb-6">Stay updated with your activities</p>
</div>
</header>
<Card.Root class="gap-1">
<Card.Content>
{#if !$USER_DATA}
<div class="py-12 text-center">
<div
class="bg-muted mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full"
>
<Bell class="text-muted-foreground h-8 w-8" />
</div>
<h3 class="mb-2 text-lg font-semibold">Please sign in</h3>
<p class="text-muted-foreground">You need to be signed in to view notifications</p>
</div>
{:else if loading}
<NotificationsSkeleton />
{:else if !$NOTIFICATIONS || $NOTIFICATIONS.length === 0}
<div class="py-12 text-center">
<div
class="bg-muted mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full"
>
<Bell class="text-muted-foreground h-8 w-8" />
</div>
<h3 class="mb-2 text-lg font-semibold">No notifications yet</h3>
<p class="text-muted-foreground">You'll see updates about your activities here</p>
</div>
{:else}
<ScrollArea class="h-[600px]">
<div class="space-y-1">
{#each $NOTIFICATIONS as notification, index (notification.id)}
{@const IconComponent = getNotificationIcon(notification.type)}
{@const isNewNotification = newNotificationIds.includes(notification.id)}
<button
class={getNotificationColorClasses(
notification.type,
isNewNotification,
notification.isRead
)}
>
<div
class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full {getNotificationIconColorClasses(
notification.type
)}"
>
<IconComponent class="h-4 w-4" />
</div>
<div class="min-w-0 flex-1">
<div class="mb-1 flex items-center gap-2">
<h3 class="truncate text-sm font-medium">{notification.title}</h3>
{#if !notification.isRead && !isNewNotification}
<div class="bg-primary h-2 w-2 flex-shrink-0 rounded-full"></div>
{/if}
{#if isNewNotification}
<Badge variant="default" class="px-1.5 py-0.5 text-xs">New</Badge>
{/if}
</div>
<p class="text-muted-foreground text-xs leading-relaxed">
{notification.message}
</p>
{#if notification.data}
<div class="mt-1 flex flex-wrap gap-1">
{#if notification.data.profit !== undefined}
<Badge
variant={notification.data.profit > 0 ? 'success' : 'destructive'}
class="px-1.5 py-0.5 text-xs"
>
{notification.data.profit > 0 ? '+' : ''}{formatValue(
notification.data.profit
)}
</Badge>
{/if}
{#if notification.data.resolution}
<Badge variant="outline" class="px-1.5 py-0.5 text-xs">
Resolved: {notification.data.resolution}
</Badge>
{/if}
</div>
{/if}
</div>
<div class="flex flex-shrink-0 flex-col items-end justify-center gap-1">
<p class="text-muted-foreground text-right text-xs">
{formatTimeAgo(notification.createdAt)}
</p>
</div>
</button>
{#if index < $NOTIFICATIONS.length - 1}
<Separator />
{/if}
{/each}
</div>
</ScrollArea>
{/if}
</Card.Content>
</Card.Root>
</div>