From de0987a007db21fdad749526b44da7f2efca3cf8 Mon Sep 17 00:00:00 2001 From: Face <69168154+face-hh@users.noreply.github.com> Date: Sat, 31 May 2025 16:26:51 +0300 Subject: [PATCH] feat: sending money / coins --- website/drizzle/0001_yummy_meggan.sql | 15 + website/drizzle/meta/0001_snapshot.json | 1559 +++++++++++++++++ website/drizzle/meta/_journal.json | 7 + website/src/hooks.server.ts | 4 + .../src/lib/components/self/DataTable.svelte | 190 +- .../lib/components/self/SendMoneyModal.svelte | 316 ++++ website/src/lib/server/db/schema.ts | 4 +- website/src/routes/+page.svelte | 18 +- .../src/routes/api/transactions/+server.ts | 97 +- website/src/routes/api/transfer/+server.ts | 259 +++ website/src/routes/portfolio/+page.server.ts | 16 - website/src/routes/portfolio/+page.svelte | 360 ++-- website/src/routes/transactions/+page.svelte | 115 +- .../src/routes/user/[username]/+page.svelte | 190 +- 14 files changed, 2825 insertions(+), 325 deletions(-) create mode 100644 website/drizzle/0001_yummy_meggan.sql create mode 100644 website/drizzle/meta/0001_snapshot.json create mode 100644 website/src/lib/components/self/SendMoneyModal.svelte create mode 100644 website/src/routes/api/transfer/+server.ts delete mode 100644 website/src/routes/portfolio/+page.server.ts diff --git a/website/drizzle/0001_yummy_meggan.sql b/website/drizzle/0001_yummy_meggan.sql new file mode 100644 index 0000000..d87e95a --- /dev/null +++ b/website/drizzle/0001_yummy_meggan.sql @@ -0,0 +1,15 @@ +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 $$; diff --git a/website/drizzle/meta/0001_snapshot.json b/website/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..4df2f2d --- /dev/null +++ b/website/drizzle/meta/0001_snapshot.json @@ -0,0 +1,1559 @@ +{ + "id": "5afb9188-6be0-462e-8455-2f1ac41dc67f", + "prevId": "077132a0-ccad-4d56-855b-150b8fe31d94", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.account_deletion_request": { + "name": "account_deletion_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "scheduled_deletion_at": { + "name": "scheduled_deletion_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_processed": { + "name": "is_processed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "account_deletion_request_user_id_idx": { + "name": "account_deletion_request_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_deletion_request_scheduled_deletion_idx": { + "name": "account_deletion_request_scheduled_deletion_idx", + "columns": [ + { + "expression": "scheduled_deletion_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_deletion_request_open_idx": { + "name": "account_deletion_request_open_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "is_processed = false", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_deletion_request_user_id_user_id_fk": { + "name": "account_deletion_request_user_id_user_id_fk", + "tableFrom": "account_deletion_request", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "account_deletion_request_user_id_unique": { + "name": "account_deletion_request_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + } + }, + "public.coin": { + "name": "coin", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "symbol": { + "name": "symbol", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "initial_supply": { + "name": "initial_supply", + "type": "numeric(30, 8)", + "primaryKey": false, + "notNull": true + }, + "circulating_supply": { + "name": "circulating_supply", + "type": "numeric(30, 8)", + "primaryKey": false, + "notNull": true + }, + "current_price": { + "name": "current_price", + "type": "numeric(20, 8)", + "primaryKey": false, + "notNull": true + }, + "market_cap": { + "name": "market_cap", + "type": "numeric(30, 2)", + "primaryKey": false, + "notNull": true + }, + "volume_24h": { + "name": "volume_24h", + "type": "numeric(30, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0.00'" + }, + "change_24h": { + "name": "change_24h", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.0000'" + }, + "pool_coin_amount": { + "name": "pool_coin_amount", + "type": "numeric(30, 8)", + "primaryKey": false, + "notNull": true, + "default": "'0.00000000'" + }, + "pool_base_currency_amount": { + "name": "pool_base_currency_amount", + "type": "numeric(30, 8)", + "primaryKey": false, + "notNull": true, + "default": "'0.00000000'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_listed": { + "name": "is_listed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": { + "coin_creator_id_user_id_fk": { + "name": "coin_creator_id_user_id_fk", + "tableFrom": "coin", + "tableTo": "user", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "coin_symbol_unique": { + "name": "coin_symbol_unique", + "nullsNotDistinct": false, + "columns": [ + "symbol" + ] + } + } + }, + "public.comment": { + "name": "comment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "coin_id": { + "name": "coin_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "likes_count": { + "name": "likes_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_deleted": { + "name": "is_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "comment_user_id_idx": { + "name": "comment_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comment_coin_id_idx": { + "name": "comment_coin_id_idx", + "columns": [ + { + "expression": "coin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comment_user_id_user_id_fk": { + "name": "comment_user_id_user_id_fk", + "tableFrom": "comment", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "comment_coin_id_coin_id_fk": { + "name": "comment_coin_id_coin_id_fk", + "tableFrom": "comment", + "tableTo": "coin", + "columnsFrom": [ + "coin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.comment_like": { + "name": "comment_like", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "comment_id": { + "name": "comment_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "comment_like_user_id_user_id_fk": { + "name": "comment_like_user_id_user_id_fk", + "tableFrom": "comment_like", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comment_like_comment_id_comment_id_fk": { + "name": "comment_like_comment_id_comment_id_fk", + "tableFrom": "comment_like", + "tableTo": "comment", + "columnsFrom": [ + "comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "comment_like_user_id_comment_id_pk": { + "name": "comment_like_user_id_comment_id_pk", + "columns": [ + "user_id", + "comment_id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.prediction_bet": { + "name": "prediction_bet", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "question_id": { + "name": "question_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "side": { + "name": "side", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(20, 8)", + "primaryKey": false, + "notNull": true + }, + "actual_winnings": { + "name": "actual_winnings", + "type": "numeric(20, 8)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "prediction_bet_user_id_idx": { + "name": "prediction_bet_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "prediction_bet_question_id_idx": { + "name": "prediction_bet_question_id_idx", + "columns": [ + { + "expression": "question_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "prediction_bet_user_question_idx": { + "name": "prediction_bet_user_question_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "question_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "prediction_bet_created_at_idx": { + "name": "prediction_bet_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "prediction_bet_user_id_user_id_fk": { + "name": "prediction_bet_user_id_user_id_fk", + "tableFrom": "prediction_bet", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "prediction_bet_question_id_prediction_question_id_fk": { + "name": "prediction_bet_question_id_prediction_question_id_fk", + "tableFrom": "prediction_bet", + "tableTo": "prediction_question", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.prediction_question": { + "name": "prediction_question", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "creator_id": { + "name": "creator_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "question": { + "name": "question", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "prediction_market_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ACTIVE'" + }, + "resolution_date": { + "name": "resolution_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ai_resolution": { + "name": "ai_resolution", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "total_yes_amount": { + "name": "total_yes_amount", + "type": "numeric(20, 8)", + "primaryKey": false, + "notNull": true, + "default": "'0.00000000'" + }, + "total_no_amount": { + "name": "total_no_amount", + "type": "numeric(20, 8)", + "primaryKey": false, + "notNull": true, + "default": "'0.00000000'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "requires_web_search": { + "name": "requires_web_search", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "validation_reason": { + "name": "validation_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "prediction_question_creator_id_idx": { + "name": "prediction_question_creator_id_idx", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "prediction_question_status_idx": { + "name": "prediction_question_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "prediction_question_resolution_date_idx": { + "name": "prediction_question_resolution_date_idx", + "columns": [ + { + "expression": "resolution_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "prediction_question_creator_id_user_id_fk": { + "name": "prediction_question_creator_id_user_id_fk", + "tableFrom": "prediction_question", + "tableTo": "user", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.price_history": { + "name": "price_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "coin_id": { + "name": "coin_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "numeric(20, 8)", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "price_history_coin_id_coin_id_fk": { + "name": "price_history_coin_id_coin_id_fk", + "tableFrom": "price_history", + "tableTo": "coin", + "columnsFrom": [ + "coin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.promo_code": { + "name": "promo_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reward_amount": { + "name": "reward_amount", + "type": "numeric(20, 8)", + "primaryKey": false, + "notNull": true + }, + "max_uses": { + "name": "max_uses", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "promo_code_created_by_user_id_fk": { + "name": "promo_code_created_by_user_id_fk", + "tableFrom": "promo_code", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "promo_code_code_unique": { + "name": "promo_code_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + } + } + }, + "public.promo_code_redemption": { + "name": "promo_code_redemption", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "promo_code_id": { + "name": "promo_code_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reward_amount": { + "name": "reward_amount", + "type": "numeric(20, 8)", + "primaryKey": false, + "notNull": true + }, + "redeemed_at": { + "name": "redeemed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "promo_code_redemption_user_id_user_id_fk": { + "name": "promo_code_redemption_user_id_user_id_fk", + "tableFrom": "promo_code_redemption", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "promo_code_redemption_promo_code_id_promo_code_id_fk": { + "name": "promo_code_redemption_promo_code_id_promo_code_id_fk", + "tableFrom": "promo_code_redemption", + "tableTo": "promo_code", + "columnsFrom": [ + "promo_code_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "promo_code_redemption_user_id_promo_code_id_unique": { + "name": "promo_code_redemption_user_id_promo_code_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "promo_code_id" + ] + } + } + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + } + }, + "public.transaction": { + "name": "transaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "coin_id": { + "name": "coin_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "transaction_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(30, 8)", + "primaryKey": false, + "notNull": true + }, + "price_per_coin": { + "name": "price_per_coin", + "type": "numeric(20, 8)", + "primaryKey": false, + "notNull": true + }, + "total_base_currency_amount": { + "name": "total_base_currency_amount", + "type": "numeric(30, 8)", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "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": {}, + "foreignKeys": { + "transaction_user_id_user_id_fk": { + "name": "transaction_user_id_user_id_fk", + "tableFrom": "transaction", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "transaction_coin_id_coin_id_fk": { + "name": "transaction_coin_id_coin_id_fk", + "tableFrom": "transaction", + "tableTo": "coin", + "columnsFrom": [ + "coin_id" + ], + "columnsTo": [ + "id" + ], + "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": "no action", + "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": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_banned": { + "name": "is_banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_currency_balance": { + "name": "base_currency_balance", + "type": "numeric(20, 8)", + "primaryKey": false, + "notNull": true, + "default": "'100.00000000'" + }, + "bio": { + "name": "bio", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false, + "default": "'Hello am 48 year old man from somalia. Sorry for my bed england. I selled my wife for internet connection for play “conter stirk”'" + }, + "username": { + "name": "username", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "volume_master": { + "name": "volume_master", + "type": "numeric(3, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0.70'" + }, + "volume_muted": { + "name": "volume_muted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_reward_claim": { + "name": "last_reward_claim", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "total_rewards_claimed": { + "name": "total_rewards_claimed", + "type": "numeric(20, 8)", + "primaryKey": false, + "notNull": true, + "default": "'0.00000000'" + }, + "login_streak": { + "name": "login_streak", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_username_unique": { + "name": "user_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + } + }, + "public.user_portfolio": { + "name": "user_portfolio", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "coin_id": { + "name": "coin_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(30, 8)", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_portfolio_user_id_user_id_fk": { + "name": "user_portfolio_user_id_user_id_fk", + "tableFrom": "user_portfolio", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_portfolio_coin_id_coin_id_fk": { + "name": "user_portfolio_coin_id_coin_id_fk", + "tableFrom": "user_portfolio", + "tableTo": "coin", + "columnsFrom": [ + "coin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_portfolio_user_id_coin_id_pk": { + "name": "user_portfolio_user_id_coin_id_pk", + "columns": [ + "user_id", + "coin_id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": { + "public.prediction_market_status": { + "name": "prediction_market_status", + "schema": "public", + "values": [ + "ACTIVE", + "RESOLVED", + "CANCELLED" + ] + }, + "public.transaction_type": { + "name": "transaction_type", + "schema": "public", + "values": [ + "BUY", + "SELL", + "TRANSFER_IN", + "TRANSFER_OUT" + ] + } + }, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/website/drizzle/meta/_journal.json b/website/drizzle/meta/_journal.json index f1bf52d..4b98504 100644 --- a/website/drizzle/meta/_journal.json +++ b/website/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1748604150899, "tag": "0000_spooky_umar", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1748690470287, + "tag": "0001_yummy_meggan", + "breakpoints": true } ] } \ No newline at end of file diff --git a/website/src/hooks.server.ts b/website/src/hooks.server.ts index 118e082..8d58715 100644 --- a/website/src/hooks.server.ts +++ b/website/src/hooks.server.ts @@ -73,5 +73,9 @@ export async function handle({ event, resolve }) { // 'Cache-Control': 'private, no-cache, no-store, must-revalidate' // }); + if (event.url.pathname.startsWith('/.well-known/appspecific/com.chrome.devtools')) { + return new Response(null, { status: 204 }); + } + return svelteKitHandler({ event, resolve, auth }); } \ No newline at end of file diff --git a/website/src/lib/components/self/DataTable.svelte b/website/src/lib/components/self/DataTable.svelte index 133be81..a9fd26c 100644 --- a/website/src/lib/components/self/DataTable.svelte +++ b/website/src/lib/components/self/DataTable.svelte @@ -6,7 +6,7 @@ import CoinIcon from './CoinIcon.svelte'; import UserProfilePreview from './UserProfilePreview.svelte'; import { getPublicUrl } from '$lib/utils'; - + import { ArrowUp, ArrowDown } from 'lucide-svelte'; interface Column { key: string; label: string; @@ -34,13 +34,63 @@ emptyDescription?: string; enableUserPreview?: boolean; } = $props(); + function renderCell(column: any, row: any, value: any, index: number) { + if (column.render) { + const rendered = column.render(value, row, index); + if (rendered?.component === 'badge') { + return { + type: 'badge', + variant: rendered.variant || 'default', + text: rendered.text, + icon: rendered.icon, + class: rendered.class || '' + }; + } + if (rendered?.component === 'coin') { + return { + type: 'coin', + icon: rendered.icon, + symbol: rendered.symbol, + name: rendered.name, + size: rendered.size || 6 + }; + } + if (rendered?.component === 'text') { + return { + type: 'text', + text: rendered.text, + class: rendered.class || '' + }; + } + if (rendered?.component === 'rank') { + return { + type: 'rank', + icon: rendered.icon, + color: rendered.color, + number: rendered.number + }; + } + if (rendered?.component === 'user') { + return { + type: 'user', + image: rendered.image, + name: rendered.name, + username: rendered.username + }; + } + if (typeof rendered === 'string') { + return { type: 'text', text: rendered }; + } + } + return { type: 'text', text: value }; + } {#if data.length === 0}
{#if emptyIcon}
- +
{/if}

{emptyTitle}

@@ -50,99 +100,79 @@ - {#each columns as column} + {#each columns as column (column.key)} {column.label} {/each} - - - {#each data as row, index} + + {#each data as row, index (row.symbol || row.id || index)} onRowClick(row) : undefined} > - {#each columns as column} + {#each columns as column (column.key)} - {#if column.render} - {@const rendered = column.render(row[column.key], row, index)} - {#if typeof rendered === 'object' && rendered !== null} - {#if rendered.component === 'badge'} - - {rendered.text} - - {:else if rendered.component === 'user'} - {#if enableUserPreview} - - -
- - - - {rendered.name?.charAt(0) || '?'} - - -
-

{rendered.name}

-

@{rendered.username}

-
-
-
- - - -
- {:else} + {@const cellData = renderCell(column, row, row[column.key], index)} + {#if cellData.type === 'badge'} + + {#if cellData.icon === 'arrow-up'} + + {:else if cellData.icon === 'arrow-down'} + + {/if} + {cellData.text} + + {:else if cellData.type === 'coin'} +
+ + {cellData.name} +
+ {:else if cellData.type === 'rank'} +
+
+ +
+ #{cellData.number} +
+ {:else if cellData.type === 'user'} + {#if enableUserPreview} + +
- - - - {rendered.name?.charAt(0) || '?'} + + + + {cellData.name?.charAt(0) || '?'} -
-

{rendered.name}

-

@{rendered.username}

+
+ {cellData.name} + @{cellData.username}
- {/if} - {:else if rendered.component === 'rank'} -
- - #{rendered.number} -
- {:else if rendered.component === 'coin'} -
- -
-
{rendered.name}
-
*{rendered.symbol}
-
-
- {:else if rendered.component === 'link'} - - - {rendered.content.name} - (*{rendered.content.symbol}) - - {/if} + + + + + {:else} - {rendered} +
+ + + {cellData.name?.charAt(0) || '?'} + +
+ {cellData.name} + @{cellData.username} +
+
{/if} - {:else} - {row[column.key]} + {:else if cellData.type === 'text'} + {cellData.text} {/if} {/each} diff --git a/website/src/lib/components/self/SendMoneyModal.svelte b/website/src/lib/components/self/SendMoneyModal.svelte new file mode 100644 index 0000000..2afc335 --- /dev/null +++ b/website/src/lib/components/self/SendMoneyModal.svelte @@ -0,0 +1,316 @@ + + + + + + + + Send + + Send cash or coins to another user + + +
+ +
+ + +
+ + +
+ + + + {currentTransferTypeLabel} + + + + +
+ + Cash ($) +
+
+ +
+ + Coins +
+
+
+
+
+
+ + + {#if transferType === 'COIN'} +
+ + + + {currentCoinLabel} + + + + {#each coinHoldings as holding} + + *{holding.symbol} ({holding.quantity.toFixed(6)} available) + + {/each} + + + +
+ {/if} + + +
+ +
+ + +
+
+

+ Available: {transferType === 'CASH' + ? `$${userBalance.toFixed(2)}` + : selectedCoinHolding + ? `${selectedCoinHolding.quantity.toFixed(6)} ${selectedCoinSymbol}` + : '0'} +

+ {#if transferType === 'COIN' && estimatedValue > 0} +

+ ≈ ${estimatedValue.toFixed(2)} +

+ {/if} +
+
+ + {#if !hasEnoughFunds && hasValidAmount} + + Insufficient {transferType === 'CASH' ? 'funds' : 'coins'} + + {/if} + + {#if hasValidAmount && hasEnoughFunds && hasValidRecipient} +
+
+ You're sending: +
+ + {transferType === 'CASH' + ? `$${numericAmount.toFixed(2)}` + : `${numericAmount.toFixed(6)} ${selectedCoinSymbol}`} + + {#if transferType === 'COIN' && estimatedValue > 0} + + ≈ ${estimatedValue.toFixed(2)} USD + + {/if} +
+
+
+ To: + @{recipientUsername} +
+
+ {/if} +
+ + + + + +
+
diff --git a/website/src/lib/server/db/schema.ts b/website/src/lib/server/db/schema.ts index 6754383..0e23e6d 100644 --- a/website/src/lib/server/db/schema.ts +++ b/website/src/lib/server/db/schema.ts @@ -1,7 +1,7 @@ import { pgTable, text, timestamp, boolean, decimal, serial, varchar, integer, primaryKey, pgEnum, index, unique, check } from "drizzle-orm/pg-core"; import { sql } from "drizzle-orm"; -export const transactionTypeEnum = pgEnum('transaction_type', ['BUY', 'SELL']); +export const transactionTypeEnum = pgEnum('transaction_type', ['BUY', 'SELL', 'TRANSFER_IN', 'TRANSFER_OUT']); export const predictionMarketEnum = pgEnum('prediction_market_status', ['ACTIVE', 'RESOLVED', 'CANCELLED']); export const user = pgTable("user", { @@ -110,6 +110,8 @@ export const transaction = pgTable("transaction", { pricePerCoin: decimal("price_per_coin", { precision: 20, scale: 8 }).notNull(), totalBaseCurrencyAmount: decimal("total_base_currency_amount", { precision: 30, scale: 8 }).notNull(), timestamp: timestamp("timestamp", { withTimezone: true }).notNull().defaultNow(), + recipientUserId: integer('recipient_user_id').references(() => user.id, { onDelete: 'set null' }), + senderUserId: integer('sender_user_id').references(() => user.id, { onDelete: 'set null' }), }); export const priceHistory = pgTable("price_history", { diff --git a/website/src/routes/+page.svelte b/website/src/routes/+page.svelte index b787c9c..4aff858 100644 --- a/website/src/routes/+page.svelte +++ b/website/src/routes/+page.svelte @@ -32,7 +32,6 @@ loading = false; } }); - const marketColumns = [ { key: 'name', @@ -40,13 +39,11 @@ class: 'font-medium', render: (value: any, row: any) => { return { - component: 'link', - href: `/coin/${row.symbol}`, - content: { - icon: row.icon, - symbol: row.symbol, - name: row.name - } + component: 'coin', + icon: row.icon, + symbol: row.symbol, + name: row.name, + size: 6 }; } }, @@ -116,9 +113,8 @@

Be the first to create a coin!

- {:else} -
- {#each coins.slice(0, 6) as coin} + {:else}
+ {#each coins.slice(0, 6) as coin (coin.symbol)} diff --git a/website/src/routes/api/transactions/+server.ts b/website/src/routes/api/transactions/+server.ts index 56348d5..197d074 100644 --- a/website/src/routes/api/transactions/+server.ts +++ b/website/src/routes/api/transactions/+server.ts @@ -1,8 +1,9 @@ import { auth } from '$lib/auth'; import { error, json } from '@sveltejs/kit'; import { db } from '$lib/server/db'; -import { transaction, coin } from '$lib/server/db/schema'; +import { transaction, coin, user } from '$lib/server/db/schema'; import { eq, desc, asc, and, or, ilike, sql } from 'drizzle-orm'; +import { alias } from 'drizzle-orm/pg-core'; export async function GET({ request, url }) { const authSession = await auth.api.getSession({ @@ -18,25 +19,43 @@ export async function GET({ request, url }) { const typeFilter = url.searchParams.get('type') || 'all'; const sortBy = url.searchParams.get('sortBy') || 'timestamp'; const sortOrder = url.searchParams.get('sortOrder') || 'desc'; - const page = parseInt(url.searchParams.get('page') || '1'); - const limit = Math.min(parseInt(url.searchParams.get('limit') || '20'), 50); - let whereConditions = and(eq(transaction.userId, userId)); + // Validate page parameter + const pageParam = url.searchParams.get('page') || '1'; + const page = parseInt(pageParam); + if (isNaN(page) || page < 1) { + throw error(400, 'Invalid page parameter'); + } + + // Validate limit parameter + const limitParam = url.searchParams.get('limit') || '20'; + const parsedLimit = parseInt(limitParam); + const limit = isNaN(parsedLimit) ? 20 : Math.min(Math.max(parsedLimit, 1), 50); const recipientUser = alias(user, 'recipientUser'); + + const senderUser = alias(user, 'senderUser'); + + const conditions = [eq(transaction.userId, userId)]; if (searchQuery) { - whereConditions = and( - whereConditions, + conditions.push( or( ilike(coin.name, `%${searchQuery}%`), ilike(coin.symbol, `%${searchQuery}%`) - ) + )! ); } if (typeFilter !== 'all') { - whereConditions = and(whereConditions, eq(transaction.type, typeFilter as 'BUY' | 'SELL')); + const validTypes = ['BUY', 'SELL', 'TRANSFER_IN', 'TRANSFER_OUT'] as const; + if (validTypes.includes(typeFilter as any)) { + conditions.push(eq(transaction.type, typeFilter as typeof validTypes[number])); + } else { + throw error(400, `Invalid type parameter. Allowed: ${validTypes.join(', ')}`); + } } + const whereConditions = conditions.length === 1 ? conditions[0] : and(...conditions); + let sortColumn; switch (sortBy) { case 'totalBaseCurrencyAmount': @@ -52,12 +71,10 @@ export async function GET({ request, url }) { sortColumn = transaction.timestamp; } - const orderBy = sortOrder === 'asc' ? asc(sortColumn) : desc(sortColumn); - - const [{ count }] = await db + const orderBy = sortOrder === 'asc' ? asc(sortColumn) : desc(sortColumn); const [{ count }] = await db .select({ count: sql`count(*)` }) .from(transaction) - .leftJoin(coin, eq(transaction.coinId, coin.id)) + .innerJoin(coin, eq(transaction.coinId, coin.id)) .where(whereConditions); const transactions = await db @@ -68,26 +85,64 @@ export async function GET({ request, url }) { pricePerCoin: transaction.pricePerCoin, totalBaseCurrencyAmount: transaction.totalBaseCurrencyAmount, timestamp: transaction.timestamp, + recipientUserId: transaction.recipientUserId, + senderUserId: transaction.senderUserId, coin: { id: coin.id, name: coin.name, symbol: coin.symbol, icon: coin.icon + }, + recipientUser: { + id: recipientUser.id, + username: recipientUser.username + }, + senderUser: { + id: senderUser.id, + username: senderUser.username } - }) - .from(transaction) - .leftJoin(coin, eq(transaction.coinId, coin.id)) + }).from(transaction) + .innerJoin(coin, eq(transaction.coinId, coin.id)) + .leftJoin(recipientUser, eq(transaction.recipientUserId, recipientUser.id)) + .leftJoin(senderUser, eq(transaction.senderUserId, senderUser.id)) .where(whereConditions) .orderBy(orderBy) .limit(limit) .offset((page - 1) * limit); - const formattedTransactions = transactions.map(tx => ({ - ...tx, - quantity: Number(tx.quantity), - pricePerCoin: Number(tx.pricePerCoin), - totalBaseCurrencyAmount: Number(tx.totalBaseCurrencyAmount) - })); + const formattedTransactions = transactions.map(tx => { + const isTransfer = tx.type.startsWith('TRANSFER_'); + const isIncoming = tx.type === 'TRANSFER_IN'; + const isCoinTransfer = isTransfer && Number(tx.quantity) > 0; + + let actualSenderUsername = null; + let actualRecipientUsername = null; + + if (isTransfer) { + actualSenderUsername = tx.senderUser?.username; + actualRecipientUsername = tx.recipientUser?.username; + } + + return { + ...tx, + quantity: Number(tx.quantity), + pricePerCoin: Number(tx.pricePerCoin), + totalBaseCurrencyAmount: Number(tx.totalBaseCurrencyAmount), + isTransfer, + isIncoming, + isCoinTransfer, + recipient: actualRecipientUsername, + sender: actualSenderUsername, + transferInfo: isTransfer ? { + isTransfer: true, + isIncoming, + isCoinTransfer, + otherUser: isIncoming ? + (tx.senderUser ? { id: tx.senderUser.id, username: actualSenderUsername } : null) : + (tx.recipientUser ? { id: tx.recipientUser.id, username: actualRecipientUsername } : null) + } : null + }; + }); return json({ transactions: formattedTransactions, diff --git a/website/src/routes/api/transfer/+server.ts b/website/src/routes/api/transfer/+server.ts new file mode 100644 index 0000000..dfe77d8 --- /dev/null +++ b/website/src/routes/api/transfer/+server.ts @@ -0,0 +1,259 @@ +import { auth } from '$lib/auth'; +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 type { RequestHandler } from './$types'; + +interface TransferRequest { + recipientUsername: string; + type: 'CASH' | 'COIN'; + amount: number; + coinSymbol?: string; +} + +export const POST: RequestHandler = async ({ request }) => { + const session = await auth.api.getSession({ + headers: request.headers + }); + + if (!session?.user) { + throw error(401, 'Not authenticated'); + } try { + const { recipientUsername, type, amount, coinSymbol }: TransferRequest = await request.json(); + + if (!recipientUsername || !type || !amount || typeof amount !== 'number' || !Number.isFinite(amount) || amount <= 0) { + throw error(400, 'Invalid transfer parameters'); + } + + if (amount > Number.MAX_SAFE_INTEGER) { + throw error(400, 'Transfer amount too large'); + } + + if (type === 'COIN' && !coinSymbol) { + throw error(400, 'Coin symbol required for coin transfers'); + } + + const senderId = Number(session.user.id); + + return await db.transaction(async (tx) => { + const [senderData] = await tx + .select({ + id: user.id, + username: user.username, + baseCurrencyBalance: user.baseCurrencyBalance + }) + .from(user) + .where(eq(user.id, senderId)) + .for('update') + .limit(1); + + if (!senderData) { + throw error(404, 'Sender not found'); + } + + const [recipientData] = await tx + .select({ + id: user.id, + username: user.username, + baseCurrencyBalance: user.baseCurrencyBalance + }) + .from(user) + .where(eq(user.username, recipientUsername)) + .for('update') + .limit(1); + + if (!recipientData) { + throw error(404, 'Recipient not found'); + } + + if (senderData.id === recipientData.id) { + throw error(400, 'Cannot transfer to yourself'); + } + + if (type === 'CASH') { + const senderBalance = Number(senderData.baseCurrencyBalance); + if (senderBalance < amount) { + throw error(400, `Insufficient funds. You have $${senderBalance.toFixed(2)} but trying to send $${amount.toFixed(2)}`); + } + + const recipientBalance = Number(recipientData.baseCurrencyBalance); + + await tx + .update(user) + .set({ + baseCurrencyBalance: (senderBalance - amount).toFixed(8), + updatedAt: new Date() + }) + .where(eq(user.id, senderId)); + + await tx + .update(user) + .set({ + baseCurrencyBalance: (recipientBalance + amount).toFixed(8), + updatedAt: new Date() + }) + .where(eq(user.id, recipientData.id)); + + await tx.insert(transaction).values({ + userId: senderId, + coinId: 1, + type: 'TRANSFER_OUT', + quantity: '0', + pricePerCoin: '1', + totalBaseCurrencyAmount: amount.toString(), + timestamp: new Date(), + senderUserId: senderId, + recipientUserId: recipientData.id + }); + + await tx.insert(transaction).values({ + userId: recipientData.id, + coinId: 1, + type: 'TRANSFER_IN', + quantity: '0', + pricePerCoin: '1', + totalBaseCurrencyAmount: amount.toString(), + timestamp: new Date(), + senderUserId: senderId, + recipientUserId: recipientData.id + }); + + return json({ + success: true, + type: 'CASH', + amount, + recipient: recipientData.username, + newBalance: senderBalance - amount + }); + + } else { + const normalizedSymbol = coinSymbol!.toUpperCase(); + + const [coinData] = await tx + .select({ id: coin.id, symbol: coin.symbol, name: coin.name, currentPrice: coin.currentPrice }) + .from(coin) + .where(eq(coin.symbol, normalizedSymbol)) + .limit(1); + + if (!coinData) { + throw error(404, 'Coin not found'); + } + + const [senderHolding] = await tx + .select({ + quantity: userPortfolio.quantity + }) + .from(userPortfolio) + .where(and( + eq(userPortfolio.userId, senderId), + eq(userPortfolio.coinId, coinData.id) + )) + .for('update') + .limit(1); + + if (!senderHolding || Number(senderHolding.quantity) < amount) { + const availableAmount = senderHolding ? Number(senderHolding.quantity) : 0; + throw error(400, `Insufficient ${coinData.symbol}. You have ${availableAmount.toFixed(6)} but trying to send ${amount.toFixed(6)}`); + } + + const [recipientHolding] = await tx + .select({ quantity: userPortfolio.quantity }) + .from(userPortfolio) + .where(and( + eq(userPortfolio.userId, recipientData.id), + eq(userPortfolio.coinId, coinData.id) + )) + .for('update') + .limit(1); + + const coinPrice = Number(coinData.currentPrice) || 0; + const totalValue = amount * coinPrice; + + const newSenderQuantity = Number(senderHolding.quantity) - amount; + if (newSenderQuantity > 0.000001) { + await tx + .update(userPortfolio) + .set({ + quantity: newSenderQuantity.toString(), + updatedAt: new Date() + }) + .where(and( + eq(userPortfolio.userId, senderId), + eq(userPortfolio.coinId, coinData.id) + )); + } else { + await tx + .delete(userPortfolio) + .where(and( + eq(userPortfolio.userId, senderId), + eq(userPortfolio.coinId, coinData.id) + )); + } + + if (recipientHolding) { + const newRecipientQuantity = Number(recipientHolding.quantity) + amount; + await tx + .update(userPortfolio) + .set({ + quantity: newRecipientQuantity.toString(), + updatedAt: new Date() + }) + .where(and( + eq(userPortfolio.userId, recipientData.id), + eq(userPortfolio.coinId, coinData.id) + )); + } else { + await tx + .insert(userPortfolio) + .values({ + userId: recipientData.id, + coinId: coinData.id, + quantity: amount.toString() + }); + } + + await tx.insert(transaction).values({ + userId: senderId, + coinId: coinData.id, + type: 'TRANSFER_OUT', + quantity: amount.toString(), + pricePerCoin: coinPrice.toString(), + totalBaseCurrencyAmount: totalValue.toString(), + timestamp: new Date(), + senderUserId: senderId, + recipientUserId: recipientData.id + }); + + await tx.insert(transaction).values({ + userId: recipientData.id, + coinId: coinData.id, + type: 'TRANSFER_IN', + quantity: amount.toString(), + pricePerCoin: coinPrice.toString(), + totalBaseCurrencyAmount: totalValue.toString(), + timestamp: new Date(), + senderUserId: senderId, + recipientUserId: recipientData.id + }); + + return json({ + success: true, + type: 'COIN', + amount, + coinSymbol: coinData.symbol, + coinName: coinData.name, + recipient: recipientData.username, + newQuantity: newSenderQuantity + }); + } + }); + + } catch (e) { + console.error('Transfer error:', e); + if (e && typeof e === 'object' && 'status' in e) { + throw e; + } + return json({ error: 'Transfer failed' }, { status: 500 }); + } +}; diff --git a/website/src/routes/portfolio/+page.server.ts b/website/src/routes/portfolio/+page.server.ts deleted file mode 100644 index aae6401..0000000 --- a/website/src/routes/portfolio/+page.server.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { auth } from '$lib/auth'; -import { redirect } from '@sveltejs/kit'; - -export async function load({ request }) { - const session = await auth.api.getSession({ - headers: request.headers - }); - - if (!session?.user) { - throw redirect(302, '/'); - } - - return { - user: session.user - }; -} diff --git a/website/src/routes/portfolio/+page.svelte b/website/src/routes/portfolio/+page.svelte index b6df5c7..598f96a 100644 --- a/website/src/routes/portfolio/+page.svelte +++ b/website/src/routes/portfolio/+page.svelte @@ -1,23 +1,23 @@ -
-
-
-

Portfolio

-

View your holdings and portfolio performance

-
-
+ +
{#if loading} {:else if error} @@ -196,122 +240,148 @@
- {:else} - -
- - - - - - Total - - - -

{formatValue(totalPortfolioValue)}

-
-
- - - - - - - Cash Balance - - - -

- {formatValue(portfolioData.baseCurrencyBalance)} -

-

- {totalPortfolioValue > 0 - ? `${((portfolioData.baseCurrencyBalance / totalPortfolioValue) * 100).toFixed(1)}% of portfolio` - : '100% of portfolio'} -

-
-
- - - - - - - Coin Holdings - - - -

{formatValue(portfolioData.totalCoinValue)}

-

- {portfolioData.coinHoldings.length} positions -

-
-
+ {:else if !$USER_DATA} +
+
+
+ You need to be logged in to view your portfolio +
+ +
+ {:else} + +
+
+
+

Portfolio

+

Manage your investments and transactions

+
+
+ + +
+
- {#if !hasHoldings} - - - -
- -
-

No coin holdings

-

- You haven't invested in any coins yet. Start by buying existing coins. -

-
- -
-
-
- {:else} - - + +
+ + + + + + Total + + + +

{formatValue(totalPortfolioValue)}

+
+
+ + + + + + + Cash Balance + + + +

+ {formatValue(portfolioData.baseCurrencyBalance)} +

+

+ {totalPortfolioValue > 0 + ? `${((portfolioData.baseCurrencyBalance / totalPortfolioValue) * 100).toFixed(1)}% of portfolio` + : '100% of portfolio'} +

+
+
+ + + + + + + Coin Holdings + + + +

{formatValue(portfolioData.totalCoinValue)}

+

+ {portfolioData.coinHoldings.length} positions +

+
+
+
+ + {#if !hasHoldings} + + + +
+ +
+

No coin holdings

+

+ You haven't invested in any coins yet. Start by buying existing coins. +

+
+ +
+
+
+ {:else} + + + + Your Holdings + Current positions in your portfolio + + + goto(`/coin/${holding.symbol}`)} + /> + + + {/if} + + + - Your Holdings - Current positions in your portfolio +
+
+ + + Recent Transactions + + Your latest trading activity +
+ {#if hasTransactions} + + {/if} +
goto(`/coin/${holding.symbol}`)} + columns={transactionsColumns} + data={transactions} + onRowClick={(tx) => !tx.isTransfer && goto(`/coin/${tx.coin.symbol}`)} + emptyIcon={Receipt} + emptyTitle="No transactions yet" + emptyDescription="You haven't made any trades yet. Start by buying or selling coins." />
- {/if} - - - - -
-
- - - Recent Transactions - - Your latest trading activity -
- {#if hasTransactions} - - {/if} -
-
- - goto(`/coin/${tx.coin.symbol}`)} - emptyIcon={Receipt} - emptyTitle="No transactions yet" - emptyDescription="You haven't made any trades yet. Start by buying or selling coins." - /> - -
+
{/if}
diff --git a/website/src/routes/transactions/+page.svelte b/website/src/routes/transactions/+page.svelte index 8ebdfdd..daaab2e 100644 --- a/website/src/routes/transactions/+page.svelte +++ b/website/src/routes/transactions/+page.svelte @@ -41,7 +41,9 @@ const typeFilterOptions = [ { value: 'all', label: 'All transactions' }, { value: 'BUY', label: 'Buys only' }, - { value: 'SELL', label: 'Sells only' } + { value: 'SELL', label: 'Sells only' }, + { value: 'TRANSFER_IN', label: 'Received transfers' }, + { value: 'TRANSFER_OUT', label: 'Sent transfers' } ]; const sortOrderOptions = [ @@ -50,7 +52,7 @@ ]; const debouncedSearch = debounce(performSearch, 300); - let previousSearchQueryForEffect = $state(searchQuery); + let previousSearchQueryForEffect = $state(''); onMount(() => { fetchTransactions(); @@ -211,43 +213,110 @@ { key: 'type', label: 'Type', - class: 'w-[10%] min-w-[60px]', - render: (value: any) => ({ - component: 'badge', - variant: value === 'BUY' ? 'success' : 'destructive', - text: value === 'BUY' ? 'Buy' : 'Sell', - class: 'text-xs' - }) + class: 'w-[10%] min-w-[80px]', + render: (value: any, row: any) => { + if (row.isTransfer) { + return { + component: 'badge', + variant: 'default', + text: row.isIncoming ? 'Received' : 'Sent', + class: 'text-xs' + }; + } + return { + component: 'badge', + variant: value === 'BUY' ? 'success' : 'destructive', + text: value === 'BUY' ? 'Buy' : 'Sell', + class: 'text-xs' + }; + } }, { key: 'coin', - label: 'Coin', + label: 'Asset', class: 'w-[20%] min-w-[120px]', + render: (value: any, row: any) => { + if (row.isTransfer) { + if (row.isCoinTransfer && row.coin) { + return { + component: 'coin', + icon: row.coin.icon, + symbol: row.coin.symbol, + name: `*${row.coin.symbol}`, + size: 6 + }; + } + return { + component: 'text', + text: 'Cash ($)', + class: 'font-medium' + }; + } + return { + component: 'coin', + icon: row.coin.icon, + symbol: row.coin.symbol, + name: `*${row.coin.symbol}`, + size: 6 + }; + } + }, + { + key: 'sender', + label: 'Sender', + class: 'w-[12%] min-w-[80px]', render: (value: any, row: any) => ({ - component: 'coin', - icon: row.coin.icon, - symbol: row.coin.symbol, - name: `*${row.coin.symbol}`, - size: 6 + component: 'text', + text: row.isTransfer ? row.sender || 'Unknown' : '-', + class: + row.isTransfer && row.sender && row.sender !== 'Unknown' + ? 'font-medium' + : 'text-muted-foreground' + }) + }, + { + key: 'recipient', + label: 'Receiver', + class: 'w-[12%] min-w-[80px]', + render: (value: any, row: any) => ({ + component: 'text', + text: row.isTransfer ? row.recipient || 'Unknown' : '-', + class: + row.isTransfer && row.recipient && row.recipient !== 'Unknown' + ? 'font-medium' + : 'text-muted-foreground' }) }, { key: 'quantity', label: 'Quantity', class: 'w-[15%] min-w-[100px] font-mono', - render: (value: any) => formatQuantity(value) + render: (value: any, row: any) => { + if (row.isTransfer && value === 0) { + return '-'; + } + return formatQuantity(value); + } }, { key: 'pricePerCoin', label: 'Price', class: 'w-[15%] min-w-[80px] font-mono', - render: (value: any) => `$${formatPrice(value)}` + render: (value: any, row: any) => { + if (row.isTransfer || value === 0) { + return '-'; + } + return `$${formatPrice(value)}`; + } }, { key: 'totalBaseCurrencyAmount', label: 'Total', class: 'w-[15%] min-w-[80px] font-mono font-medium', - render: (value: any) => formatValue(value) + render: (value: any, row: any) => { + const prefix = row.type === 'TRANSFER_IN' || row.type === 'BUY' ? '+' : '-'; + return `${prefix}${formatValue(value)}`; + } }, { key: 'timestamp', @@ -413,7 +482,7 @@ History - Complete record of your trading activity + Complete record of your trading activity and transfers
{#if loading} @@ -426,12 +495,16 @@ goto(`/coin/${tx.coin.symbol}`)} + onRowClick={(tx) => { + if (tx.coin) { + goto(`/coin/${tx.coin.symbol}`); + } + }} emptyIcon={Receipt} emptyTitle="No transactions found" emptyDescription={hasActiveFilters ? 'No transactions match your current filters. Try adjusting your search criteria.' - : "You haven't made any trades yet. Start by buying or selling coins."} + : "You haven't made any trades or transfers yet. Start by buying coins or sending money to other users."} /> {/if} diff --git a/website/src/routes/user/[username]/+page.svelte b/website/src/routes/user/[username]/+page.svelte index 4634233..4642e34 100644 --- a/website/src/routes/user/[username]/+page.svelte +++ b/website/src/routes/user/[username]/+page.svelte @@ -21,22 +21,34 @@ } from 'lucide-svelte'; import { goto } from '$app/navigation'; import type { UserProfileData } from '$lib/types/user-profile'; + import { USER_DATA } from '$lib/stores/user-data'; let { data } = $props(); const username = data.username; let profileData = $state(null); + let recentTransactions = $state([]); let loading = $state(true); + let isOwnProfile = $derived( + $USER_DATA && profileData?.profile && $USER_DATA.username === profileData.profile.username + ); onMount(async () => { await fetchProfileData(); }); + $effect(() => { + if (isOwnProfile && profileData) { + fetchTransactions(); + } + }); async function fetchProfileData() { try { const response = await fetch(`/api/user/${username}`); if (response.ok) { profileData = await response.json(); + + recentTransactions = profileData?.recentTransactions || []; } else { toast.error('Failed to load profile data'); } @@ -48,6 +60,20 @@ } } + async function fetchTransactions() { + if (!isOwnProfile) return; + + try { + const response = await fetch('/api/transactions?limit=10'); + if (response.ok) { + const data = await response.json(); + recentTransactions = data.transactions || []; + } + } catch (e) { + console.error('Failed to fetch transactions:', e); + } + } + let memberSince = $derived( profileData?.profile ? new Date(profileData.profile.createdAt).toLocaleDateString('en-US', { @@ -144,52 +170,158 @@ render: (value: any) => formatDate(value) } ]; - const transactionsColumns = [ { key: 'type', label: 'Type', - class: 'pl-6', - render: (value: any) => ({ - component: 'badge', - variant: value === 'BUY' ? 'success' : 'destructive', - text: value - }) + class: 'w-[12%] min-w-[60px] md:w-[8%] pl-6', + render: (value: any, row: any) => { + // Handle transfer types (TRANSFER_IN, TRANSFER_OUT) from user profile API + if (value === 'TRANSFER_IN' || value === 'TRANSFER_OUT') { + return { + component: 'badge', + variant: 'default', + text: value === 'TRANSFER_IN' ? 'Received' : 'Sent', + class: 'text-xs' + }; + } + // Handle isTransfer format from transactions API + if (row.isTransfer) { + return { + component: 'badge', + variant: 'default', + text: row.isIncoming ? 'Received' : 'Sent', + class: 'text-xs' + }; + } + return { + component: 'badge', + variant: value === 'BUY' ? 'success' : 'destructive', + text: value === 'BUY' ? 'Buy' : 'Sell', + class: 'text-xs' + }; + } }, { key: 'coin', label: 'Coin', - class: 'font-medium', - render: (value: any, row: any) => ({ - component: 'coin', - icon: row.coinIcon, - symbol: row.coinSymbol, - name: row.coinName, - size: 6 - }) + class: 'w-[20%] min-w-[100px] md:w-[12%]', + render: (value: any, row: any) => { + // Handle transfer format from transactions API + if (row.isTransfer) { + if (row.isCoinTransfer && row.coin) { + return { + component: 'coin', + icon: row.coin.icon, + symbol: row.coin.symbol, + name: `*${row.coin.symbol}`, + size: 4 + }; + } + return { component: 'text', text: '-' }; + } + // Handle transfer types from user profile API + if (row.type === 'TRANSFER_IN' || row.type === 'TRANSFER_OUT') { + if (row.coinSymbol && Number(row.quantity) > 0) { + return { + component: 'coin', + icon: row.coinIcon, + symbol: row.coinSymbol, + name: `*${row.coinSymbol}`, + size: 4 + }; + } + return { component: 'text', text: '-' }; + } + // Handle regular transactions from both APIs + return { + component: 'coin', + icon: row.coinIcon || row.coin?.icon, + symbol: row.coinSymbol || row.coin?.symbol, + name: `*${row.coinSymbol || row.coin?.symbol}`, + size: 4 + }; + } + }, + { + key: 'sender', + label: 'Sender', + class: 'w-[12%] min-w-[70px] md:w-[10%]', + render: (value: any, row: any) => { + // Handle transactions API format + if (row.isTransfer) { + return { + component: 'text', + text: row.sender || 'Unknown', + class: row.sender && row.sender !== 'Unknown' ? 'font-medium' : 'text-muted-foreground' + }; + } + // Handle user profile API format (no sender/recipient data available) + if (row.type === 'TRANSFER_IN' || row.type === 'TRANSFER_OUT') { + return { + component: 'text', + text: 'Unknown', + class: 'text-muted-foreground' + }; + } + return { + component: 'text', + text: '-', + class: 'text-muted-foreground' + }; + } + }, + { + key: 'recipient', + label: 'Receiver', + class: 'w-[12%] min-w-[70px] md:w-[10%]', + render: (value: any, row: any) => { + if (row.isTransfer) { + return { + component: 'text', + text: row.recipient || 'Unknown', + class: + row.recipient && row.recipient !== 'Unknown' ? 'font-medium' : 'text-muted-foreground' + }; + } + if (row.type === 'TRANSFER_IN' || row.type === 'TRANSFER_OUT') { + return { + component: 'text', + text: 'Unknown', + class: 'text-muted-foreground' + }; + } + return { + component: 'text', + text: '-', + class: 'text-muted-foreground' + }; + } }, { key: 'quantity', label: 'Quantity', - class: 'hidden font-mono sm:table-cell', - render: (value: any) => formatQuantity(parseFloat(value)) - }, - { - key: 'pricePerCoin', - label: 'Price', - class: 'font-mono', - render: (value: any) => `$${formatPrice(parseFloat(value))}` + class: 'w-[12%] min-w-[70px] md:w-[10%] font-mono text-sm', + render: (value: any, row: any) => { + if ( + (row.isTransfer && value === 0) || + ((row.type === 'TRANSFER_IN' || row.type === 'TRANSFER_OUT') && value === 0) + ) { + return '-'; + } + return formatQuantity(parseFloat(value)); + } }, { key: 'totalBaseCurrencyAmount', - label: 'Total', - class: 'hidden font-mono font-medium md:table-cell', + label: 'Amount', + class: 'w-[12%] min-w-[70px] md:w-[10%] font-mono text-sm font-medium', render: (value: any) => formatValue(parseFloat(value)) }, { key: 'timestamp', label: 'Date', - class: 'text-muted-foreground hidden text-sm lg:table-cell', + class: 'hidden md:table-cell md:w-[18%] text-muted-foreground text-sm', render: (value: any) => formatDate(value) } ]; @@ -203,9 +335,7 @@ ? `${profileData.profile.bio} - View ${profileData.profile.name}'s simulated trading activity and virtual portfolio in the Rugplay cryptocurrency simulation game.` : `View @${username}'s profile and simulated trading activity in Rugplay - cryptocurrency trading simulation game platform.`} type="profile" - image={profileData?.profile?.image - ? getPublicUrl(profileData.profile.image) - : '/rugplay.svg'} + image={profileData?.profile?.image ? getPublicUrl(profileData.profile.image) : '/rugplay.svg'} imageAlt={profileData?.profile?.name ? `${profileData.profile.name}'s profile picture` : `@${username}'s profile`} @@ -440,7 +570,7 @@