feat: implement notification system
This commit is contained in:
parent
de3f8a4929
commit
e61c41e414
19 changed files with 883 additions and 3196 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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),
|
||||
};
|
||||
});
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
36
website/src/lib/server/notification.ts
Normal file
36
website/src/lib/server/notification.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
61
website/src/lib/stores/notifications.ts
Normal file
61
website/src/lib/stores/notifications.ts
Normal 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);
|
||||
|
|
@ -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();
|
||||
Reference in a new issue