feat: add username availability check API endpoint
feat: create user image retrieval API endpoint feat: enhance coin page with dynamic data fetching and improved UI feat: implement coin creation form with validation and submission logic feat: add user settings page with profile update functionality
This commit is contained in:
parent
9aa4ba157b
commit
16ad425bb5
48 changed files with 3030 additions and 326 deletions
73
website/src/routes/api/coin/[coinSymbol]/+server.ts
Normal file
73
website/src/routes/api/coin/[coinSymbol]/+server.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { error, json } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { coin, user, priceHistory } from '$lib/server/db/schema';
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
|
||||
export async function GET({ params }) {
|
||||
const { coinSymbol } = params;
|
||||
|
||||
if (!coinSymbol) {
|
||||
throw error(400, 'Coin symbol is required');
|
||||
}
|
||||
|
||||
const normalizedSymbol = coinSymbol.toUpperCase();
|
||||
|
||||
const [coinData] = await db
|
||||
.select({
|
||||
id: coin.id,
|
||||
name: coin.name,
|
||||
symbol: coin.symbol,
|
||||
creatorId: coin.creatorId,
|
||||
creatorName: user.name,
|
||||
creatorUsername: user.username,
|
||||
creatorBio: user.bio,
|
||||
creatorImage: user.image,
|
||||
initialSupply: coin.initialSupply,
|
||||
circulatingSupply: coin.circulatingSupply,
|
||||
currentPrice: coin.currentPrice,
|
||||
marketCap: coin.marketCap,
|
||||
icon: coin.icon,
|
||||
volume24h: coin.volume24h,
|
||||
change24h: coin.change24h,
|
||||
poolCoinAmount: coin.poolCoinAmount,
|
||||
poolBaseCurrencyAmount: coin.poolBaseCurrencyAmount,
|
||||
createdAt: coin.createdAt,
|
||||
isListed: coin.isListed
|
||||
})
|
||||
.from(coin)
|
||||
.leftJoin(user, eq(coin.creatorId, user.id))
|
||||
.where(eq(coin.symbol, normalizedSymbol))
|
||||
.limit(1);
|
||||
|
||||
if (!coinData) {
|
||||
throw error(404, 'Coin not found');
|
||||
}
|
||||
|
||||
const priceHistoryData = await db
|
||||
.select({
|
||||
price: priceHistory.price,
|
||||
timestamp: priceHistory.timestamp
|
||||
})
|
||||
.from(priceHistory)
|
||||
.where(eq(priceHistory.coinId, coinData.id))
|
||||
.orderBy(desc(priceHistory.timestamp))
|
||||
.limit(720);
|
||||
|
||||
return json({
|
||||
coin: {
|
||||
...coinData,
|
||||
currentPrice: Number(coinData.currentPrice),
|
||||
marketCap: Number(coinData.marketCap),
|
||||
volume24h: Number(coinData.volume24h || 0),
|
||||
change24h: Number(coinData.change24h || 0),
|
||||
initialSupply: Number(coinData.initialSupply),
|
||||
circulatingSupply: Number(coinData.circulatingSupply),
|
||||
poolCoinAmount: Number(coinData.poolCoinAmount),
|
||||
poolBaseCurrencyAmount: Number(coinData.poolBaseCurrencyAmount)
|
||||
},
|
||||
priceHistory: priceHistoryData.map(p => ({
|
||||
price: Number(p.price),
|
||||
timestamp: p.timestamp
|
||||
}))
|
||||
});
|
||||
}
|
||||
143
website/src/routes/api/coin/create/+server.ts
Normal file
143
website/src/routes/api/coin/create/+server.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { auth } from '$lib/auth';
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { coin, userPortfolio, user, priceHistory } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { uploadCoinIcon } from '$lib/server/s3';
|
||||
import { CREATION_FEE, FIXED_SUPPLY, STARTING_PRICE, INITIAL_LIQUIDITY, TOTAL_COST, MAX_FILE_SIZE } from '$lib/data/constants';
|
||||
|
||||
function validateInputs(name: string, symbol: string, iconFile: File | null) {
|
||||
if (!name || name.length < 2 || name.length > 255) {
|
||||
throw error(400, 'Name must be between 2 and 255 characters');
|
||||
}
|
||||
|
||||
if (!symbol || symbol.length < 2 || symbol.length > 10) {
|
||||
throw error(400, 'Symbol must be between 2 and 10 characters');
|
||||
}
|
||||
|
||||
if (iconFile && iconFile.size > MAX_FILE_SIZE) {
|
||||
throw error(400, 'Icon file must be smaller than 1MB');
|
||||
}
|
||||
}
|
||||
|
||||
async function validateUserBalance(userId: number) {
|
||||
const [userData] = await db
|
||||
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
|
||||
.from(user)
|
||||
.where(eq(user.id, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!userData) {
|
||||
throw error(404, 'User not found');
|
||||
}
|
||||
|
||||
const currentBalance = Number(userData.baseCurrencyBalance);
|
||||
if (currentBalance < TOTAL_COST) {
|
||||
throw error(400, `Insufficient funds. You need $${TOTAL_COST.toFixed(2)} but only have $${currentBalance.toFixed(2)}.`);
|
||||
}
|
||||
|
||||
return currentBalance;
|
||||
}
|
||||
|
||||
async function validateSymbolUnique(symbol: string) {
|
||||
const existingCoin = await db.select().from(coin).where(eq(coin.symbol, symbol)).limit(1);
|
||||
if (existingCoin.length > 0) {
|
||||
throw error(400, 'A coin with this symbol already exists');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleIconUpload(iconFile: File | null, symbol: string): Promise<string | null> {
|
||||
if (!iconFile || iconFile.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const arrayBuffer = await iconFile.arrayBuffer();
|
||||
return await uploadCoinIcon(
|
||||
symbol,
|
||||
new Uint8Array(arrayBuffer),
|
||||
iconFile.type,
|
||||
iconFile.size
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST({ request }) {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers
|
||||
});
|
||||
|
||||
if (!session?.user) {
|
||||
throw error(401, 'Not authenticated');
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const name = formData.get('name') as string;
|
||||
const symbol = formData.get('symbol') as string;
|
||||
const iconFile = formData.get('icon') as File | null;
|
||||
|
||||
const normalizedSymbol = symbol?.toUpperCase();
|
||||
const userId = Number(session.user.id);
|
||||
|
||||
validateInputs(name, normalizedSymbol, iconFile);
|
||||
|
||||
const [currentBalance] = await Promise.all([
|
||||
validateUserBalance(userId),
|
||||
validateSymbolUnique(normalizedSymbol)
|
||||
]);
|
||||
|
||||
let iconKey: string | null = null;
|
||||
try {
|
||||
iconKey = await handleIconUpload(iconFile, normalizedSymbol);
|
||||
} catch (e) {
|
||||
console.error('Icon upload failed, continuing without icon:', e);
|
||||
}
|
||||
|
||||
let createdCoin: any;
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.update(user)
|
||||
.set({
|
||||
baseCurrencyBalance: (currentBalance - TOTAL_COST).toString(),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(user.id, userId));
|
||||
|
||||
const [newCoin] = await tx.insert(coin).values({
|
||||
name,
|
||||
symbol: normalizedSymbol,
|
||||
icon: iconKey,
|
||||
creatorId: userId,
|
||||
initialSupply: FIXED_SUPPLY.toString(),
|
||||
circulatingSupply: FIXED_SUPPLY.toString(),
|
||||
currentPrice: STARTING_PRICE.toString(),
|
||||
marketCap: (FIXED_SUPPLY * STARTING_PRICE).toString(),
|
||||
poolCoinAmount: FIXED_SUPPLY.toString(),
|
||||
poolBaseCurrencyAmount: INITIAL_LIQUIDITY.toString()
|
||||
}).returning();
|
||||
|
||||
createdCoin = newCoin;
|
||||
|
||||
await tx.insert(userPortfolio).values({
|
||||
userId,
|
||||
coinId: newCoin.id,
|
||||
quantity: FIXED_SUPPLY.toString()
|
||||
});
|
||||
|
||||
await tx.insert(priceHistory).values({
|
||||
coinId: newCoin.id,
|
||||
price: STARTING_PRICE.toString()
|
||||
});
|
||||
});
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
coin: {
|
||||
id: createdCoin.id,
|
||||
name: createdCoin.name,
|
||||
symbol: createdCoin.symbol,
|
||||
icon: createdCoin.icon
|
||||
},
|
||||
feePaid: CREATION_FEE,
|
||||
liquidityDeposited: INITIAL_LIQUIDITY,
|
||||
initialPrice: STARTING_PRICE,
|
||||
supply: FIXED_SUPPLY
|
||||
});
|
||||
}
|
||||
37
website/src/routes/api/coins/top/+server.ts
Normal file
37
website/src/routes/api/coins/top/+server.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { json } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { coin } from '$lib/server/db/schema';
|
||||
import { desc, eq } from 'drizzle-orm';
|
||||
|
||||
export async function GET() {
|
||||
const topCoins = await db
|
||||
.select({
|
||||
id: coin.id,
|
||||
name: coin.name,
|
||||
symbol: coin.symbol,
|
||||
icon: coin.icon,
|
||||
currentPrice: coin.currentPrice,
|
||||
marketCap: coin.marketCap,
|
||||
volume24h: coin.volume24h,
|
||||
change24h: coin.change24h,
|
||||
isListed: coin.isListed
|
||||
})
|
||||
.from(coin)
|
||||
.where(eq(coin.isListed, true))
|
||||
.orderBy(desc(coin.marketCap))
|
||||
.limit(20);
|
||||
|
||||
return json({
|
||||
coins: topCoins.map(c => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
symbol: c.symbol,
|
||||
icon: c.icon,
|
||||
price: Number(c.currentPrice),
|
||||
marketCap: Number(c.marketCap),
|
||||
volume24h: Number(c.volume24h || 0),
|
||||
change24h: Number(c.change24h || 0),
|
||||
isListed: c.isListed
|
||||
}))
|
||||
});
|
||||
}
|
||||
62
website/src/routes/api/portfolio/total/+server.ts
Normal file
62
website/src/routes/api/portfolio/total/+server.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { auth } from '$lib/auth';
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { user, userPortfolio, coin } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export async function GET({ 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 [userData] = await db
|
||||
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
|
||||
.from(user)
|
||||
.where(eq(user.id, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!userData) {
|
||||
throw error(404, 'User not found');
|
||||
}
|
||||
|
||||
const holdings = await db
|
||||
.select({
|
||||
quantity: userPortfolio.quantity,
|
||||
currentPrice: coin.currentPrice,
|
||||
symbol: coin.symbol
|
||||
})
|
||||
.from(userPortfolio)
|
||||
.innerJoin(coin, eq(userPortfolio.coinId, coin.id))
|
||||
.where(eq(userPortfolio.userId, userId));
|
||||
|
||||
let totalCoinValue = 0;
|
||||
const coinHoldings = holdings.map(holding => {
|
||||
const quantity = Number(holding.quantity);
|
||||
const price = Number(holding.currentPrice);
|
||||
const value = quantity * price;
|
||||
totalCoinValue += value;
|
||||
|
||||
return {
|
||||
symbol: holding.symbol,
|
||||
quantity,
|
||||
currentPrice: price,
|
||||
value
|
||||
};
|
||||
});
|
||||
|
||||
const baseCurrencyBalance = Number(userData.baseCurrencyBalance);
|
||||
|
||||
return json({
|
||||
baseCurrencyBalance,
|
||||
totalCoinValue,
|
||||
totalValue: baseCurrencyBalance + totalCoinValue,
|
||||
coinHoldings,
|
||||
currency: '$'
|
||||
});
|
||||
}
|
||||
71
website/src/routes/api/settings/+server.ts
Normal file
71
website/src/routes/api/settings/+server.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { auth } from '$lib/auth';
|
||||
import { uploadProfilePicture } from '$lib/server/s3';
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { user } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { MAX_FILE_SIZE } from '$lib/data/constants';
|
||||
|
||||
function validateInputs(name: string, bio: string, username: string, avatarFile: File | null) {
|
||||
if (name && name.length < 1) {
|
||||
throw error(400, 'Name cannot be empty');
|
||||
}
|
||||
|
||||
if (bio && bio.length > 160) {
|
||||
throw error(400, 'Bio must be 160 characters or less');
|
||||
}
|
||||
|
||||
if (username && (username.length < 3 || username.length > 30)) {
|
||||
throw error(400, 'Username must be between 3 and 30 characters');
|
||||
}
|
||||
|
||||
if (avatarFile && avatarFile.size > MAX_FILE_SIZE) {
|
||||
throw error(400, 'Avatar file must be smaller than 1MB');
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST({ request }) {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers
|
||||
});
|
||||
|
||||
if (!session?.user) {
|
||||
throw error(401, 'Not authenticated');
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const name = formData.get('name') as string;
|
||||
const bio = formData.get('bio') as string;
|
||||
const username = formData.get('username') as string;
|
||||
const avatarFile = formData.get('avatar') as File | null;
|
||||
|
||||
validateInputs(name, bio, username, avatarFile);
|
||||
|
||||
const updates: Record<string, any> = {
|
||||
name,
|
||||
bio,
|
||||
username,
|
||||
updatedAt: new Date()
|
||||
};
|
||||
|
||||
if (avatarFile && avatarFile.size > 0) {
|
||||
try {
|
||||
const arrayBuffer = await avatarFile.arrayBuffer();
|
||||
const key = await uploadProfilePicture(
|
||||
session.user.id,
|
||||
new Uint8Array(arrayBuffer),
|
||||
avatarFile.type,
|
||||
avatarFile.size
|
||||
);
|
||||
updates.image = key;
|
||||
} catch (e) {
|
||||
console.error('Avatar upload failed, continuing without update:', e);
|
||||
}
|
||||
}
|
||||
|
||||
await db.update(user)
|
||||
.set(updates)
|
||||
.where(eq(user.id, Number(session.user.id)));
|
||||
|
||||
return json({ success: true });
|
||||
}
|
||||
17
website/src/routes/api/settings/check-username/+server.ts
Normal file
17
website/src/routes/api/settings/check-username/+server.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { json } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { user } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export async function GET({ url }) {
|
||||
const username = url.searchParams.get('username');
|
||||
if (!username) {
|
||||
return json({ available: false });
|
||||
}
|
||||
|
||||
const exists = await db.query.user.findFirst({
|
||||
where: eq(user.username, username)
|
||||
});
|
||||
|
||||
return json({ available: !exists });
|
||||
}
|
||||
28
website/src/routes/api/user/[userId]/image/+server.ts
Normal file
28
website/src/routes/api/user/[userId]/image/+server.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { error, json } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { user } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getPublicUrl } from '$lib/utils';
|
||||
|
||||
export async function GET({ params }) {
|
||||
const { userId } = params;
|
||||
|
||||
try {
|
||||
const [userData] = await db
|
||||
.select({ image: user.image })
|
||||
.from(user)
|
||||
.where(eq(user.id, Number(userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!userData) {
|
||||
throw error(404, 'User not found');
|
||||
}
|
||||
|
||||
const url = getPublicUrl(userData.image);
|
||||
|
||||
return json({ url });
|
||||
} catch (e) {
|
||||
console.error('Failed to get user image:', e);
|
||||
throw error(500, 'Failed to get user image');
|
||||
}
|
||||
}
|
||||
Reference in a new issue