Files
ai_web/server/db.ts
2026-01-27 14:51:35 +08:00

705 lines
20 KiB
TypeScript

import { eq, desc, and, like, or, sql, isNull, ne, asc } from "drizzle-orm";
import { drizzle } from "drizzle-orm/mysql2";
import {
InsertUser, users,
categories, InsertCategory,
websites, InsertWebsite,
products, InsertProduct,
productPrices, InsertProductPrice,
bounties, InsertBounty,
bountyApplications, InsertBountyApplication,
bountyComments, InsertBountyComment,
notifications, InsertNotification,
favorites, InsertFavorite,
favoriteTags, InsertFavoriteTag,
favoriteTagMappings, InsertFavoriteTagMapping,
priceMonitors, InsertPriceMonitor,
priceHistory, InsertPriceHistory
} from "../drizzle/schema";
import { ENV } from './_core/env';
let _db: ReturnType<typeof drizzle> | null = null;
export async function getDb() {
if (!_db && process.env.DATABASE_URL) {
try {
_db = drizzle(process.env.DATABASE_URL);
} catch (error) {
console.warn("[Database] Failed to connect:", error);
_db = null;
}
}
return _db;
}
// ============ User Functions ============
export async function upsertUser(user: InsertUser): Promise<void> {
if (!user.openId) {
throw new Error("User openId is required for upsert");
}
const db = await getDb();
if (!db) {
console.warn("[Database] Cannot upsert user: database not available");
return;
}
try {
const values: InsertUser = {
openId: user.openId,
};
const updateSet: Record<string, unknown> = {};
const textFields = ["name", "email", "loginMethod", "avatar"] as const;
type TextField = (typeof textFields)[number];
const assignNullable = (field: TextField) => {
const value = user[field];
if (value === undefined) return;
const normalized = value ?? null;
values[field] = normalized;
updateSet[field] = normalized;
};
textFields.forEach(assignNullable);
if (user.lastSignedIn !== undefined) {
values.lastSignedIn = user.lastSignedIn;
updateSet.lastSignedIn = user.lastSignedIn;
}
if (user.role !== undefined) {
values.role = user.role;
updateSet.role = user.role;
} else if (user.openId === ENV.ownerOpenId) {
values.role = 'admin';
updateSet.role = 'admin';
}
if (!values.lastSignedIn) {
values.lastSignedIn = new Date();
}
if (Object.keys(updateSet).length === 0) {
updateSet.lastSignedIn = new Date();
}
await db.insert(users).values(values).onDuplicateKeyUpdate({
set: updateSet,
});
} catch (error) {
console.error("[Database] Failed to upsert user:", error);
throw error;
}
}
export async function getUserByOpenId(openId: string) {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(users).where(eq(users.openId, openId)).limit(1);
return result.length > 0 ? result[0] : undefined;
}
export async function getUserById(id: number) {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(users).where(eq(users.id, id)).limit(1);
return result.length > 0 ? result[0] : undefined;
}
export async function updateUserStripeInfo(userId: number, stripeCustomerId?: string, stripeAccountId?: string) {
const db = await getDb();
if (!db) return;
const updateData: Record<string, unknown> = {};
if (stripeCustomerId) updateData.stripeCustomerId = stripeCustomerId;
if (stripeAccountId) updateData.stripeAccountId = stripeAccountId;
if (Object.keys(updateData).length > 0) {
await db.update(users).set(updateData).where(eq(users.id, userId));
}
}
// ============ Category Functions ============
export async function getAllCategories() {
const db = await getDb();
if (!db) return [];
return db.select().from(categories).orderBy(categories.sortOrder);
}
export async function getCategoryBySlug(slug: string) {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(categories).where(eq(categories.slug, slug)).limit(1);
return result.length > 0 ? result[0] : undefined;
}
export async function createCategory(data: InsertCategory) {
const db = await getDb();
if (!db) return;
await db.insert(categories).values(data);
}
// ============ Website Functions ============
export async function getWebsites(categoryId?: number) {
const db = await getDb();
if (!db) return [];
if (categoryId) {
return db.select().from(websites).where(eq(websites.categoryId, categoryId)).orderBy(websites.sortOrder);
}
return db.select().from(websites).orderBy(websites.sortOrder);
}
export async function getWebsiteById(id: number) {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(websites).where(eq(websites.id, id)).limit(1);
return result.length > 0 ? result[0] : undefined;
}
export async function createWebsite(data: InsertWebsite) {
const db = await getDb();
if (!db) return;
await db.insert(websites).values(data);
}
export async function searchWebsites(query: string) {
const db = await getDb();
if (!db) return [];
return db.select().from(websites).where(
or(
like(websites.name, `%${query}%`),
like(websites.description, `%${query}%`)
)
);
}
// ============ Product Functions ============
export async function getProducts(
categoryId?: number,
limit = 50,
offset = 0,
sortBy?: 'price_asc' | 'price_desc' | 'newest' | 'oldest'
) {
const database = await getDb();
if (!database) return [];
let query: any = database.select().from(products);
if (categoryId) {
query = query.where(eq(products.categoryId, categoryId));
}
// Apply sorting
if (sortBy === 'newest') {
query = query.orderBy(desc(products.createdAt));
} else if (sortBy === 'oldest') {
query = query.orderBy(asc(products.createdAt));
} else {
// Default: newest first
query = query.orderBy(desc(products.createdAt));
}
// Apply pagination
return query.limit(limit).offset(offset);
}
export async function getProductById(id: number) {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(products).where(eq(products.id, id)).limit(1);
return result.length > 0 ? result[0] : undefined;
}
export async function createProduct(data: InsertProduct) {
const db = await getDb();
if (!db) return;
await db.insert(products).values(data);
}
export async function searchProducts(query: string) {
const db = await getDb();
if (!db) return [];
return db.select().from(products).where(
or(
like(products.name, `%${query}%`),
like(products.description, `%${query}%`)
)
);
}
// ============ Product Price Functions ============
export async function getProductPrices(productId: number) {
const db = await getDb();
if (!db) return [];
return db.select().from(productPrices).where(eq(productPrices.productId, productId));
}
export async function createProductPrice(data: InsertProductPrice) {
const db = await getDb();
if (!db) return;
await db.insert(productPrices).values(data);
}
export async function getProductWithPrices(productId: number) {
const db = await getDb();
if (!db) return null;
const product = await getProductById(productId);
if (!product) return null;
const prices = await db.select({
price: productPrices,
website: websites
}).from(productPrices)
.leftJoin(websites, eq(productPrices.websiteId, websites.id))
.where(eq(productPrices.productId, productId))
.orderBy(productPrices.price);
return { product, prices };
}
// ============ Bounty Functions ============
export async function getBounties(status?: string, limit = 50, offset = 0) {
const db = await getDb();
if (!db) return [];
const baseQuery = db.select({
bounty: bounties,
publisher: {
id: users.id,
name: users.name,
avatar: users.avatar
}
}).from(bounties)
.leftJoin(users, eq(bounties.publisherId, users.id))
.orderBy(desc(bounties.createdAt))
.limit(limit)
.offset(offset);
if (status) {
return baseQuery.where(eq(bounties.status, status as any));
}
return baseQuery;
}
export async function getBountyById(id: number) {
const db = await getDb();
if (!db) return undefined;
const result = await db.select({
bounty: bounties,
publisher: {
id: users.id,
name: users.name,
avatar: users.avatar
}
}).from(bounties)
.leftJoin(users, eq(bounties.publisherId, users.id))
.where(eq(bounties.id, id))
.limit(1);
return result.length > 0 ? result[0] : undefined;
}
export async function createBounty(data: InsertBounty) {
const db = await getDb();
if (!db) return null;
const result = await db.insert(bounties).values(data);
return result[0].insertId;
}
export async function updateBounty(id: number, data: Partial<InsertBounty>) {
const db = await getDb();
if (!db) return;
await db.update(bounties).set(data).where(eq(bounties.id, id));
}
export async function getUserBounties(userId: number, type: 'published' | 'accepted') {
const db = await getDb();
if (!db) return [];
if (type === 'published') {
return db.select().from(bounties).where(eq(bounties.publisherId, userId)).orderBy(desc(bounties.createdAt));
} else {
return db.select().from(bounties).where(eq(bounties.acceptorId, userId)).orderBy(desc(bounties.createdAt));
}
}
export async function searchBounties(query: string) {
const db = await getDb();
if (!db) return [];
return db.select({
bounty: bounties,
publisher: {
id: users.id,
name: users.name,
avatar: users.avatar
}
}).from(bounties)
.leftJoin(users, eq(bounties.publisherId, users.id))
.where(
or(
like(bounties.title, `%${query}%`),
like(bounties.description, `%${query}%`)
)
)
.orderBy(desc(bounties.createdAt));
}
// ============ Bounty Application Functions ============
export async function getBountyApplications(bountyId: number) {
const db = await getDb();
if (!db) return [];
return db.select({
application: bountyApplications,
applicant: {
id: users.id,
name: users.name,
avatar: users.avatar
}
}).from(bountyApplications)
.leftJoin(users, eq(bountyApplications.applicantId, users.id))
.where(eq(bountyApplications.bountyId, bountyId))
.orderBy(desc(bountyApplications.createdAt));
}
export async function createBountyApplication(data: InsertBountyApplication) {
const db = await getDb();
if (!db) return;
await db.insert(bountyApplications).values(data);
}
export async function updateBountyApplication(id: number, status: 'pending' | 'accepted' | 'rejected') {
const db = await getDb();
if (!db) return;
await db.update(bountyApplications).set({ status }).where(eq(bountyApplications.id, id));
}
export async function getUserApplication(bountyId: number, userId: number) {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(bountyApplications)
.where(and(
eq(bountyApplications.bountyId, bountyId),
eq(bountyApplications.applicantId, userId)
)).limit(1);
return result.length > 0 ? result[0] : undefined;
}
// ============ Bounty Comment Functions ============
export async function getBountyComments(bountyId: number) {
const db = await getDb();
if (!db) return [];
return db.select({
comment: bountyComments,
user: {
id: users.id,
name: users.name,
avatar: users.avatar
}
}).from(bountyComments)
.leftJoin(users, eq(bountyComments.userId, users.id))
.where(eq(bountyComments.bountyId, bountyId))
.orderBy(bountyComments.createdAt);
}
export async function createBountyComment(data: InsertBountyComment) {
const db = await getDb();
if (!db) return;
await db.insert(bountyComments).values(data);
}
// ============ Notification Functions ============
export async function getUserNotifications(userId: number, limit = 50) {
const db = await getDb();
if (!db) return [];
return db.select().from(notifications)
.where(eq(notifications.userId, userId))
.orderBy(desc(notifications.createdAt))
.limit(limit);
}
export async function getUnreadNotificationCount(userId: number) {
const db = await getDb();
if (!db) return 0;
const result = await db.select({ count: sql<number>`count(*)` })
.from(notifications)
.where(and(
eq(notifications.userId, userId),
eq(notifications.isRead, false)
));
return result[0]?.count ?? 0;
}
export async function createNotification(data: InsertNotification) {
const db = await getDb();
if (!db) return;
await db.insert(notifications).values(data);
}
export async function markNotificationAsRead(id: number, userId: number) {
const db = await getDb();
if (!db) return;
await db.update(notifications)
.set({ isRead: true })
.where(and(
eq(notifications.id, id),
eq(notifications.userId, userId)
));
}
export async function markAllNotificationsAsRead(userId: number) {
const db = await getDb();
if (!db) return;
await db.update(notifications)
.set({ isRead: true })
.where(eq(notifications.userId, userId));
}
// ============ Favorite Functions ============
export async function getUserFavorites(userId: number) {
const db = await getDb();
if (!db) return [];
return db.select({
id: favorites.id,
productId: favorites.productId,
websiteId: favorites.websiteId,
createdAt: favorites.createdAt,
product: {
id: products.id,
name: products.name,
description: products.description,
image: products.image,
},
website: {
id: websites.id,
name: websites.name,
url: websites.url,
logo: websites.logo,
}
})
.from(favorites)
.innerJoin(products, eq(favorites.productId, products.id))
.innerJoin(websites, eq(favorites.websiteId, websites.id))
.where(eq(favorites.userId, userId))
.orderBy(desc(favorites.createdAt));
}
export async function isFavorited(userId: number, productId: number, websiteId: number) {
const db = await getDb();
if (!db) return false;
const result = await db.select({ id: favorites.id })
.from(favorites)
.where(and(
eq(favorites.userId, userId),
eq(favorites.productId, productId),
eq(favorites.websiteId, websiteId)
))
.limit(1);
return result.length > 0;
}
export async function addFavorite(userId: number, productId: number, websiteId: number) {
const db = await getDb();
if (!db) return;
await db.insert(favorites).values({
userId,
productId,
websiteId,
});
}
export async function removeFavorite(userId: number, productId: number, websiteId: number) {
const db = await getDb();
if (!db) return;
await db.delete(favorites)
.where(and(
eq(favorites.userId, userId),
eq(favorites.productId, productId),
eq(favorites.websiteId, websiteId)
));
}
// ============ Favorite Tag Functions ============
export async function getUserFavoriteTags(userId: number) {
const db = await getDb();
if (!db) return [];
return db.select().from(favoriteTags)
.where(eq(favoriteTags.userId, userId))
.orderBy(desc(favoriteTags.createdAt));
}
export async function createFavoriteTag(userId: number, name: string, color?: string, description?: string) {
const db = await getDb();
if (!db) return;
await db.insert(favoriteTags).values({
userId,
name,
color: color || '#6366f1',
description,
});
}
export async function updateFavoriteTag(id: number, userId: number, data: Partial<InsertFavoriteTag>) {
const db = await getDb();
if (!db) return;
await db.update(favoriteTags)
.set(data)
.where(and(
eq(favoriteTags.id, id),
eq(favoriteTags.userId, userId)
));
}
export async function deleteFavoriteTag(id: number, userId: number) {
const db = await getDb();
if (!db) return;
await db.delete(favoriteTags)
.where(and(
eq(favoriteTags.id, id),
eq(favoriteTags.userId, userId)
));
}
export async function addTagToFavorite(favoriteId: number, tagId: number) {
const db = await getDb();
if (!db) return;
const exists = await db.select({ id: favoriteTagMappings.id })
.from(favoriteTagMappings)
.where(and(
eq(favoriteTagMappings.favoriteId, favoriteId),
eq(favoriteTagMappings.tagId, tagId)
))
.limit(1);
if (exists.length === 0) {
await db.insert(favoriteTagMappings).values({
favoriteId,
tagId,
});
}
}
export async function removeTagFromFavorite(favoriteId: number, tagId: number) {
const db = await getDb();
if (!db) return;
await db.delete(favoriteTagMappings)
.where(and(
eq(favoriteTagMappings.favoriteId, favoriteId),
eq(favoriteTagMappings.tagId, tagId)
));
}
export async function getFavoritesByTag(userId: number, tagId: number) {
const db = await getDb();
if (!db) return [];
return db.select({
id: favorites.id,
productId: favorites.productId,
websiteId: favorites.websiteId,
createdAt: favorites.createdAt,
product: {
id: products.id,
name: products.name,
description: products.description,
image: products.image,
},
website: {
id: websites.id,
name: websites.name,
url: websites.url,
logo: websites.logo,
}
})
.from(favorites)
.innerJoin(products, eq(favorites.productId, products.id))
.innerJoin(websites, eq(favorites.websiteId, websites.id))
.innerJoin(favoriteTagMappings, eq(favorites.id, favoriteTagMappings.favoriteId))
.where(and(
eq(favorites.userId, userId),
eq(favoriteTagMappings.tagId, tagId)
))
.orderBy(desc(favorites.createdAt));
}
// ============ Price Monitor Functions ============
export async function createPriceMonitor(userId: number, favoriteId: number, currentPrice?: number, targetPrice?: number) {
const db = await getDb();
if (!db) return;
await db.insert(priceMonitors).values({
userId,
favoriteId,
currentPrice: currentPrice ? currentPrice.toString() : undefined,
targetPrice: targetPrice ? targetPrice.toString() : undefined,
lowestPrice: currentPrice ? currentPrice.toString() : undefined,
highestPrice: currentPrice ? currentPrice.toString() : undefined,
isActive: true,
});
}
export async function getPriceMonitor(userId: number, favoriteId: number) {
const db = await getDb();
if (!db) return null;
const result = await db.select().from(priceMonitors)
.where(and(
eq(priceMonitors.userId, userId),
eq(priceMonitors.favoriteId, favoriteId)
))
.limit(1);
return result.length > 0 ? result[0] : null;
}
export async function getUserPriceMonitors(userId: number) {
const db = await getDb();
if (!db) return [];
return db.select().from(priceMonitors)
.where(eq(priceMonitors.userId, userId))
.orderBy(desc(priceMonitors.createdAt));
}
export async function updatePriceMonitor(monitorId: number, data: Partial<InsertPriceMonitor>) {
const db = await getDb();
if (!db) return;
await db.update(priceMonitors)
.set(data)
.where(eq(priceMonitors.id, monitorId));
}
export async function deletePriceMonitor(monitorId: number) {
const db = await getDb();
if (!db) return;
await db.delete(priceMonitors)
.where(eq(priceMonitors.id, monitorId));
}
export async function recordPriceHistory(monitorId: number, price: number, previousPrice?: number) {
const db = await getDb();
if (!db) return;
const priceChange = previousPrice ? price - previousPrice : 0;
const percentChange = previousPrice ? ((priceChange / previousPrice) * 100) : 0;
await db.insert(priceHistory).values({
monitorId,
price: price.toString(),
priceChange: priceChange.toString(),
percentChange: percentChange.toString(),
});
}
export async function getPriceHistory(monitorId: number, limit: number = 30) {
const db = await getDb();
if (!db) return [];
return db.select().from(priceHistory)
.where(eq(priceHistory.monitorId, monitorId))
.orderBy(desc(priceHistory.recordedAt))
.limit(limit);
}
export async function getActivePriceMonitors() {
const db = await getDb();
if (!db) return [];
return db.select().from(priceMonitors)
.where(eq(priceMonitors.isActive, true));
}