feat: implement notification system
This commit is contained in:
parent
de3f8a4929
commit
e61c41e414
19 changed files with 883 additions and 3196 deletions
|
|
@ -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,
|
||||
|
|
|
|||
77
website/src/routes/api/notifications/+server.ts
Normal file
77
website/src/routes/api/notifications/+server.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
202
website/src/routes/notifications/+page.svelte
Normal file
202
website/src/routes/notifications/+page.svelte
Normal 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>
|
||||
Reference in a new issue