feat: implement notification system
This commit is contained in:
parent
de3f8a4929
commit
e61c41e414
19 changed files with 883 additions and 3196 deletions
|
|
@ -1,3 +1,9 @@
|
|||
DO $$ BEGIN
|
||||
CREATE TYPE "public"."notification_type" AS ENUM('HOPIUM', 'SYSTEM', 'TRANSFER', 'RUG_PULL');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "public"."prediction_market_status" AS ENUM('ACTIVE', 'RESOLVED', 'CANCELLED');
|
||||
EXCEPTION
|
||||
|
|
@ -5,7 +11,7 @@ EXCEPTION
|
|||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "public"."transaction_type" AS ENUM('BUY', 'SELL');
|
||||
CREATE TYPE "public"."transaction_type" AS ENUM('BUY', 'SELL', 'TRANSFER_IN', 'TRANSFER_OUT');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
|
@ -47,7 +53,7 @@ CREATE TABLE IF NOT EXISTS "coin" (
|
|||
"current_price" numeric(20, 8) NOT NULL,
|
||||
"market_cap" numeric(30, 2) NOT NULL,
|
||||
"volume_24h" numeric(30, 2) DEFAULT '0.00',
|
||||
"change_24h" numeric(10, 4) DEFAULT '0.0000',
|
||||
"change_24h" numeric(30, 4) DEFAULT '0.0000',
|
||||
"pool_coin_amount" numeric(30, 8) DEFAULT '0.00000000' NOT NULL,
|
||||
"pool_base_currency_amount" numeric(30, 8) DEFAULT '0.00000000' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
|
|
@ -74,6 +80,16 @@ CREATE TABLE IF NOT EXISTS "comment_like" (
|
|||
CONSTRAINT "comment_like_user_id_comment_id_pk" PRIMARY KEY("user_id","comment_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "notification" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"type" "notification_type" NOT NULL,
|
||||
"title" varchar(200) NOT NULL,
|
||||
"message" text NOT NULL,
|
||||
"is_read" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "prediction_bet" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"user_id" integer,
|
||||
|
|
@ -149,7 +165,9 @@ CREATE TABLE IF NOT EXISTS "transaction" (
|
|||
"quantity" numeric(30, 8) NOT NULL,
|
||||
"price_per_coin" numeric(20, 8) NOT NULL,
|
||||
"total_base_currency_amount" numeric(30, 8) NOT NULL,
|
||||
"timestamp" timestamp with time zone DEFAULT now() NOT NULL
|
||||
"timestamp" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"recipient_user_id" integer,
|
||||
"sender_user_id" integer
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "user" (
|
||||
|
|
@ -234,6 +252,12 @@ EXCEPTION
|
|||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "notification" ADD CONSTRAINT "notification_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "prediction_bet" ADD CONSTRAINT "prediction_bet_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
|
|
@ -294,6 +318,18 @@ EXCEPTION
|
|||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "transaction" ADD CONSTRAINT "transaction_recipient_user_id_user_id_fk" FOREIGN KEY ("recipient_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "transaction" ADD CONSTRAINT "transaction_sender_user_id_user_id_fk" FOREIGN KEY ("sender_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "user_portfolio" ADD CONSTRAINT "user_portfolio_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
|
|
@ -311,6 +347,10 @@ CREATE INDEX IF NOT EXISTS "account_deletion_request_scheduled_deletion_idx" ON
|
|||
CREATE INDEX IF NOT EXISTS "account_deletion_request_open_idx" ON "account_deletion_request" USING btree ("user_id") WHERE is_processed = false;--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "comment_user_id_idx" ON "comment" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "comment_coin_id_idx" ON "comment" USING btree ("coin_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "notification_user_id_idx" ON "notification" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "notification_type_idx" ON "notification" USING btree ("type");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "notification_is_read_idx" ON "notification" USING btree ("is_read");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "notification_created_at_idx" ON "notification" USING btree ("created_at");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "prediction_bet_user_id_idx" ON "prediction_bet" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "prediction_bet_question_id_idx" ON "prediction_bet" USING btree ("question_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "prediction_bet_user_question_idx" ON "prediction_bet" USING btree ("user_id","question_id");--> statement-breakpoint
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
ALTER TYPE "transaction_type" ADD VALUE 'TRANSFER_IN';--> statement-breakpoint
|
||||
ALTER TYPE "transaction_type" ADD VALUE 'TRANSFER_OUT';--> statement-breakpoint
|
||||
ALTER TABLE "transaction" ADD COLUMN "recipient_user_id" integer;--> statement-breakpoint
|
||||
ALTER TABLE "transaction" ADD COLUMN "sender_user_id" integer;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "transaction" ADD CONSTRAINT "transaction_recipient_user_id_user_id_fk" FOREIGN KEY ("recipient_user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "transaction" ADD CONSTRAINT "transaction_sender_user_id_user_id_fk" FOREIGN KEY ("sender_user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
ALTER TABLE "transaction" DROP CONSTRAINT "transaction_recipient_user_id_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "transaction" DROP CONSTRAINT "transaction_sender_user_id_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "coin" ALTER COLUMN "change_24h" SET DATA TYPE numeric(30, 4);--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "transaction" ADD CONSTRAINT "transaction_recipient_user_id_user_id_fk" FOREIGN KEY ("recipient_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "transaction" ADD CONSTRAINT "transaction_sender_user_id_user_id_fk" FOREIGN KEY ("sender_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"id": "077132a0-ccad-4d56-855b-150b8fe31d94",
|
||||
"id": "41f7bba3-1d5d-41ba-83bb-ca129ace81f0",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
|
|
@ -292,7 +292,7 @@
|
|||
},
|
||||
"change_24h": {
|
||||
"name": "change_24h",
|
||||
"type": "numeric(10, 4)",
|
||||
"type": "numeric(30, 4)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'0.0000'"
|
||||
|
|
@ -544,6 +544,136 @@
|
|||
},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"public.notification": {
|
||||
"name": "notification",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "notification_type",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "varchar(200)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"message": {
|
||||
"name": "message",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"is_read": {
|
||||
"name": "is_read",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"notification_user_id_idx": {
|
||||
"name": "notification_user_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"notification_type_idx": {
|
||||
"name": "notification_type_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "type",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"notification_is_read_idx": {
|
||||
"name": "notification_is_read_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "is_read",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"notification_created_at_idx": {
|
||||
"name": "notification_created_at_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"notification_user_id_user_id_fk": {
|
||||
"name": "notification_user_id_user_id_fk",
|
||||
"tableFrom": "notification",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"public.prediction_bet": {
|
||||
"name": "prediction_bet",
|
||||
"schema": "",
|
||||
|
|
@ -1194,6 +1324,18 @@
|
|||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"recipient_user_id": {
|
||||
"name": "recipient_user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"sender_user_id": {
|
||||
"name": "sender_user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
|
|
@ -1223,6 +1365,32 @@
|
|||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"transaction_recipient_user_id_user_id_fk": {
|
||||
"name": "transaction_recipient_user_id_user_id_fk",
|
||||
"tableFrom": "transaction",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"recipient_user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"transaction_sender_user_id_user_id_fk": {
|
||||
"name": "transaction_sender_user_id_user_id_fk",
|
||||
"tableFrom": "transaction",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"sender_user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
|
|
@ -1492,6 +1660,16 @@
|
|||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.notification_type": {
|
||||
"name": "notification_type",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"HOPIUM",
|
||||
"SYSTEM",
|
||||
"TRANSFER",
|
||||
"RUG_PULL"
|
||||
]
|
||||
},
|
||||
"public.prediction_market_status": {
|
||||
"name": "prediction_market_status",
|
||||
"schema": "public",
|
||||
|
|
@ -1506,7 +1684,9 @@
|
|||
"schema": "public",
|
||||
"values": [
|
||||
"BUY",
|
||||
"SELL"
|
||||
"SELL",
|
||||
"TRANSFER_IN",
|
||||
"TRANSFER_OUT"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -5,22 +5,8 @@
|
|||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1748604150899,
|
||||
"tag": "0000_spooky_umar",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1748690470287,
|
||||
"tag": "0001_yummy_meggan",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1748700252762,
|
||||
"tag": "0002_lush_guardian",
|
||||
"when": 1749654046953,
|
||||
"tag": "0000_crazy_bloodstrike",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
@ -227,3 +228,20 @@ export const accountDeletionRequest = pgTable("account_deletion_request", {
|
|||
.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();
|
||||
|
|
@ -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>
|
||||
|
|
@ -14,10 +14,22 @@ if (!process.env.REDIS_URL) {
|
|||
|
||||
const redis = new Redis(process.env.REDIS_URL);
|
||||
|
||||
const HEARTBEAT_INTERVAL = 30_000;
|
||||
|
||||
type WebSocketData = {
|
||||
coinSymbol?: string;
|
||||
userId?: string;
|
||||
lastActivity: number;
|
||||
};
|
||||
|
||||
const coinSockets = new Map<string, Set<ServerWebSocket<WebSocketData>>>();
|
||||
const userSockets = new Map<string, Set<ServerWebSocket<WebSocketData>>>();
|
||||
const pingIntervals = new WeakMap<ServerWebSocket<WebSocketData>, NodeJS.Timeout>();
|
||||
|
||||
redis.on('error', (err) => console.error('Redis Client Error', err));
|
||||
|
||||
redis.on('connect', () => {
|
||||
redis.psubscribe('comments:*', 'prices:*', (err, count) => {
|
||||
redis.psubscribe('comments:*', 'prices:*', 'notifications:*', (err, count) => {
|
||||
if (err) console.error("Failed to psubscribe to patterns", err);
|
||||
else console.log(`Successfully psubscribed to patterns. Active psubscriptions: ${count}`);
|
||||
});
|
||||
|
|
@ -59,6 +71,17 @@ redis.on('pmessage', (pattern, channel, msg) => {
|
|||
}
|
||||
}
|
||||
}
|
||||
} else if (channel.startsWith('notifications:')) {
|
||||
const userId = channel.substring('notifications:'.length);
|
||||
const sockets = userSockets.get(userId);
|
||||
console.log(`Received notification for user ${userId}:`, msg);
|
||||
if (sockets) {
|
||||
for (const ws of sockets) {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing Redis pmessage:', error, `Pattern: ${pattern}, Channel: ${channel}, Raw message: ${msg}`);
|
||||
|
|
@ -85,16 +108,6 @@ redis.on('message', (channel, msg) => {
|
|||
}
|
||||
});
|
||||
|
||||
const HEARTBEAT_INTERVAL = 30_000;
|
||||
|
||||
type WebSocketData = {
|
||||
coinSymbol?: string;
|
||||
lastActivity: number;
|
||||
};
|
||||
|
||||
const coinSockets = new Map<string, Set<ServerWebSocket<WebSocketData>>>();
|
||||
const pingIntervals = new WeakMap<ServerWebSocket<WebSocketData>, NodeJS.Timeout>();
|
||||
|
||||
function handleSetCoin(ws: ServerWebSocket<WebSocketData>, coinSymbol: string) {
|
||||
if (ws.data.coinSymbol) {
|
||||
const prev = coinSockets.get(ws.data.coinSymbol);
|
||||
|
|
@ -115,6 +128,26 @@ function handleSetCoin(ws: ServerWebSocket<WebSocketData>, coinSymbol: string) {
|
|||
}
|
||||
}
|
||||
|
||||
function handleSetUser(ws: ServerWebSocket<WebSocketData>, userId: string) {
|
||||
if (ws.data.userId) {
|
||||
const prev = userSockets.get(ws.data.userId);
|
||||
if (prev) {
|
||||
prev.delete(ws);
|
||||
if (prev.size === 0) {
|
||||
userSockets.delete(ws.data.userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ws.data.userId = userId;
|
||||
|
||||
if (!userSockets.has(userId)) {
|
||||
userSockets.set(userId, new Set([ws]));
|
||||
} else {
|
||||
userSockets.get(userId)!.add(ws);
|
||||
}
|
||||
}
|
||||
|
||||
function checkConnections() {
|
||||
const now = Date.now();
|
||||
for (const [coinSymbol, sockets] of coinSockets.entries()) {
|
||||
|
|
@ -163,10 +196,13 @@ const server = Bun.serve<WebSocketData, undefined>({
|
|||
const data = JSON.parse(msg) as {
|
||||
type: string;
|
||||
coinSymbol?: string;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
if (data.type === 'set_coin' && data.coinSymbol) {
|
||||
handleSetCoin(ws, data.coinSymbol);
|
||||
} else if (data.type === 'set_user' && data.userId) {
|
||||
handleSetUser(ws, data.userId);
|
||||
} else if (data.type === 'pong') {
|
||||
ws.data.lastActivity = Date.now();
|
||||
}
|
||||
|
|
@ -201,6 +237,16 @@ const server = Bun.serve<WebSocketData, undefined>({
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ws.data.userId) {
|
||||
const sockets = userSockets.get(ws.data.userId);
|
||||
if (sockets) {
|
||||
sockets.delete(ws);
|
||||
if (sockets.size === 0) {
|
||||
userSockets.delete(ws.data.userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Reference in a new issue