Files
ai_web/server/db.ts
2026-01-27 13:41:31 +08:00

1729 lines
49 KiB
TypeScript

import { eq, desc, and, like, or, sql, isNull, ne, asc, gte } 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,
websiteTags, InsertWebsiteTag,
websiteTagMappings, InsertWebsiteTagMapping,
tagStats, InsertTagStat
} 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));
}
// ============ Admin Product Management Functions ============
export async function createAdminProduct(product: InsertProduct) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.insert(products).values(product);
return result;
}
export async function getAdminProducts(limit = 50, offset = 0) {
const db = await getDb();
if (!db) throw new Error("Database not available");
return await db.select().from(products).limit(limit).offset(offset);
}
export async function updateAdminProduct(id: number, product: Partial<InsertProduct>) {
const db = await getDb();
if (!db) throw new Error("Database not available");
return await db.update(products).set(product).where(eq(products.id, id));
}
export async function deleteAdminProduct(id: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
return await db.delete(products).where(eq(products.id, id));
}
// ============ Admin Website Management Functions ============
export async function createAdminWebsite(website: InsertWebsite) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.insert(websites).values(website);
return result;
}
export async function getAdminWebsites(limit = 50, offset = 0) {
const db = await getDb();
if (!db) throw new Error("Database not available");
return await db.select().from(websites).limit(limit).offset(offset);
}
export async function updateAdminWebsite(id: number, website: Partial<InsertWebsite>) {
const db = await getDb();
if (!db) throw new Error("Database not available");
return await db.update(websites).set(website).where(eq(websites.id, id));
}
export async function deleteAdminWebsite(id: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
return await db.delete(websites).where(eq(websites.id, id));
}
// ============ Admin Category Management Functions ============
export async function getAdminCategories() {
const db = await getDb();
if (!db) throw new Error("Database not available");
return await db.select().from(categories);
}
export async function createAdminCategory(category: InsertCategory) {
const db = await getDb();
if (!db) throw new Error("Database not available");
return await db.insert(categories).values(category);
}
// ============ Admin Statistics Functions ============
export async function getProductCount() {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db
.select({ count: sql<number>`count(*)` })
.from(products);
return result[0]?.count || 0;
}
export async function getWebsiteCount() {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db
.select({ count: sql<number>`count(*)` })
.from(websites);
return result[0]?.count || 0;
}
export async function getCategoryCount() {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db
.select({ count: sql<number>`count(*)` })
.from(categories);
return result[0]?.count || 0;
}
export async function getProductsByDateRange(days: number = 30) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
return await db
.select({
date: sql<string>`DATE(createdAt)`,
count: sql<number>`count(*)`,
})
.from(products)
.where(gte(products.createdAt, startDate))
.groupBy(sql`DATE(createdAt)`)
.orderBy(sql`DATE(createdAt)`);
}
export async function getWebsitesByDateRange(days: number = 30) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
return await db
.select({
date: sql<string>`DATE(createdAt)`,
count: sql<number>`count(*)`,
})
.from(websites)
.where(gte(websites.createdAt, startDate))
.groupBy(sql`DATE(createdAt)`)
.orderBy(sql`DATE(createdAt)`);
}
export async function getCategoryDistribution() {
const db = await getDb();
if (!db) throw new Error("Database not available");
return await db
.select({
categoryName: categories.name,
productCount: sql<number>`count(${products.id})`,
})
.from(categories)
.leftJoin(products, eq(products.categoryId, categories.id))
.groupBy(categories.id)
.orderBy(desc(sql<number>`count(${products.id})`));
}
export async function getRecentProducts(limit: number = 5) {
const db = await getDb();
if (!db) throw new Error("Database not available");
return await db
.select()
.from(products)
.orderBy(desc(products.createdAt))
.limit(limit);
}
export async function getRecentWebsites(limit: number = 5) {
const db = await getDb();
if (!db) throw new Error("Database not available");
return await db
.select()
.from(websites)
.orderBy(desc(websites.createdAt))
.limit(limit);
}
// ============ Advanced Filtering Functions ============
export async function getProductsByDateRangeAndCategory(
startDate?: Date,
endDate?: Date,
categoryId?: number
) {
const db = await getDb();
if (!db) throw new Error("Database not available");
let query: any = db.select().from(products);
if (startDate) {
query = query.where(gte(products.createdAt, startDate));
}
if (endDate) {
const nextDay = new Date(endDate);
nextDay.setDate(nextDay.getDate() + 1);
query = query.where(sql`${products.createdAt} < ${nextDay}`);
}
if (categoryId) {
query = query.where(eq(products.categoryId, categoryId));
}
return query.orderBy(desc(products.createdAt));
}
export async function getWebsitesByDateRangeAndCategory(
startDate?: Date,
endDate?: Date,
categoryId?: number
) {
const db = await getDb();
if (!db) throw new Error("Database not available");
let query: any = db.select().from(websites);
if (startDate) {
query = query.where(gte(websites.createdAt, startDate));
}
if (endDate) {
const nextDay = new Date(endDate);
nextDay.setDate(nextDay.getDate() + 1);
query = query.where(sql`${websites.createdAt} < ${nextDay}`);
}
if (categoryId) {
query = query.where(eq(websites.categoryId, categoryId));
}
return query.orderBy(desc(websites.createdAt));
}
export async function getProductTrendByDateRangeAndCategory(
startDate: Date,
endDate: Date,
categoryId?: number
) {
const db = await getDb();
if (!db) throw new Error("Database not available");
let query: any = db
.select({
date: sql<string>`DATE(createdAt)`,
count: sql<number>`count(*)`,
})
.from(products)
.where(and(
gte(products.createdAt, startDate),
sql`${products.createdAt} < ${new Date(endDate.getTime() + 24 * 60 * 60 * 1000)}`
));
if (categoryId) {
query = query.where(eq(products.categoryId, categoryId));
}
return query
.groupBy(sql`DATE(createdAt)`)
.orderBy(sql`DATE(createdAt)`);
}
export async function getWebsiteTrendByDateRangeAndCategory(
startDate: Date,
endDate: Date,
categoryId?: number
) {
const db = await getDb();
if (!db) throw new Error("Database not available");
let query: any = db
.select({
date: sql<string>`DATE(createdAt)`,
count: sql<number>`count(*)`,
})
.from(websites)
.where(and(
gte(websites.createdAt, startDate),
sql`${websites.createdAt} < ${new Date(endDate.getTime() + 24 * 60 * 60 * 1000)}`
));
if (categoryId) {
query = query.where(eq(websites.categoryId, categoryId));
}
return query
.groupBy(sql`DATE(createdAt)`)
.orderBy(sql`DATE(createdAt)`);
}
export async function getCategoryDistributionByDateRange(
startDate: Date,
endDate: Date
) {
const db = await getDb();
if (!db) throw new Error("Database not available");
return await db
.select({
categoryName: categories.name,
productCount: sql<number>`count(${products.id})`,
})
.from(categories)
.leftJoin(
products,
and(
eq(products.categoryId, categories.id),
gte(products.createdAt, startDate),
sql`${products.createdAt} < ${new Date(endDate.getTime() + 24 * 60 * 60 * 1000)}`
)
)
.groupBy(categories.id)
.orderBy(desc(sql<number>`count(${products.id})`));
}
// ============ Website Tags Functions ============
export async function getWebsiteTags() {
const db = await getDb();
if (!db) throw new Error("Database not available");
return await db
.select()
.from(websiteTags)
.where(eq(websiteTags.isActive, true))
.orderBy(websiteTags.sortOrder);
}
export async function getAllWebsiteTags() {
const db = await getDb();
if (!db) throw new Error("Database not available");
return await db
.select()
.from(websiteTags)
.orderBy(websiteTags.sortOrder);
}
export async function getWebsiteTagById(id: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db
.select()
.from(websiteTags)
.where(eq(websiteTags.id, id))
.limit(1);
return result[0] || null;
}
export async function createWebsiteTag(tag: InsertWebsiteTag) {
const db = await getDb();
if (!db) throw new Error("Database not available");
return await db.insert(websiteTags).values(tag);
}
export async function updateWebsiteTag(id: number, tag: Partial<InsertWebsiteTag>) {
const db = await getDb();
if (!db) throw new Error("Database not available");
return await db
.update(websiteTags)
.set(tag)
.where(eq(websiteTags.id, id));
}
export async function deleteWebsiteTag(id: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
// First delete all mappings
await db.delete(websiteTagMappings).where(eq(websiteTagMappings.tagId, id));
// Then delete the tag
return await db.delete(websiteTags).where(eq(websiteTags.id, id));
}
// ============ Website Tag Mappings Functions ============
export async function getWebsiteTagsByWebsiteId(websiteId: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
return await db
.select({
id: websiteTags.id,
name: websiteTags.name,
slug: websiteTags.slug,
color: websiteTags.color,
icon: websiteTags.icon,
})
.from(websiteTagMappings)
.innerJoin(websiteTags, eq(websiteTagMappings.tagId, websiteTags.id))
.where(eq(websiteTagMappings.websiteId, websiteId));
}
export async function getWebsitesByTagId(tagId: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
return await db
.select({
id: websites.id,
name: websites.name,
url: websites.url,
logo: websites.logo,
description: websites.description,
categoryId: websites.categoryId,
rating: websites.rating,
isVerified: websites.isVerified,
})
.from(websiteTagMappings)
.innerJoin(websites, eq(websiteTagMappings.websiteId, websites.id))
.where(eq(websiteTagMappings.tagId, tagId));
}
export async function getWebsitesByTagSlug(slug: string) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const tag = await db
.select()
.from(websiteTags)
.where(eq(websiteTags.slug, slug))
.limit(1);
if (!tag[0]) return [];
return await getWebsitesByTagId(tag[0].id);
}
export async function addTagToWebsite(websiteId: number, tagId: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
// Check if mapping already exists
const existing = await db
.select()
.from(websiteTagMappings)
.where(and(
eq(websiteTagMappings.websiteId, websiteId),
eq(websiteTagMappings.tagId, tagId)
))
.limit(1);
if (existing.length > 0) return existing[0];
return await db.insert(websiteTagMappings).values({ websiteId, tagId });
}
export async function removeTagFromWebsite(websiteId: number, tagId: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
return await db
.delete(websiteTagMappings)
.where(and(
eq(websiteTagMappings.websiteId, websiteId),
eq(websiteTagMappings.tagId, tagId)
));
}
export async function setWebsiteTags(websiteId: number, tagIds: number[]) {
const db = await getDb();
if (!db) throw new Error("Database not available");
// Delete all existing mappings
await db.delete(websiteTagMappings).where(eq(websiteTagMappings.websiteId, websiteId));
// Add new mappings
if (tagIds.length > 0) {
const mappings = tagIds.map(tagId => ({ websiteId, tagId }));
await db.insert(websiteTagMappings).values(mappings);
}
return { success: true };
}
export async function getWebsitesWithTags() {
const db = await getDb();
if (!db) throw new Error("Database not available");
const allWebsites = await db.select().from(websites).orderBy(websites.sortOrder);
// Get tags for each website
const websitesWithTags = await Promise.all(
allWebsites.map(async (website) => {
const tags = await getWebsiteTagsByWebsiteId(website.id);
return { ...website, tags };
})
);
return websitesWithTags;
}
export async function getWebsitesFilteredByTags(tagIds: number[]) {
const db = await getDb();
if (!db) throw new Error("Database not available");
if (tagIds.length === 0) {
return await getWebsitesWithTags();
}
// Get websites that have ALL the specified tags
const websiteIds = await db
.select({ websiteId: websiteTagMappings.websiteId })
.from(websiteTagMappings)
.where(sql`${websiteTagMappings.tagId} IN (${sql.join(tagIds.map(id => sql`${id}`), sql`, `)})`)
.groupBy(websiteTagMappings.websiteId)
.having(sql`COUNT(DISTINCT ${websiteTagMappings.tagId}) = ${tagIds.length}`);
if (websiteIds.length === 0) return [];
const ids = websiteIds.map(w => w.websiteId);
const filteredWebsites = await db
.select()
.from(websites)
.where(sql`${websites.id} IN (${sql.join(ids.map(id => sql`${id}`), sql`, `)})`);
// Get tags for each website
const websitesWithTags = await Promise.all(
filteredWebsites.map(async (website) => {
const tags = await getWebsiteTagsByWebsiteId(website.id);
return { ...website, tags };
})
);
return websitesWithTags;
}
export async function getTagsWithWebsiteCount() {
const db = await getDb();
if (!db) throw new Error("Database not available");
return await db
.select({
id: websiteTags.id,
name: websiteTags.name,
slug: websiteTags.slug,
color: websiteTags.color,
icon: websiteTags.icon,
sortOrder: websiteTags.sortOrder,
isActive: websiteTags.isActive,
websiteCount: sql<number>`COUNT(${websiteTagMappings.websiteId})`,
})
.from(websiteTags)
.leftJoin(websiteTagMappings, eq(websiteTags.id, websiteTagMappings.tagId))
.where(eq(websiteTags.isActive, true))
.groupBy(websiteTags.id)
.orderBy(websiteTags.sortOrder);
}
// ============ Tag Statistics Functions ============
export async function recordTagEvent(tagId: number, eventType: 'click' | 'filter', userId?: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
return await db.insert(tagStats).values({
tagId,
eventType,
userId: userId || null,
});
}
export async function getTagStatsOverview(days: number = 30) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
return await db
.select({
tagId: tagStats.tagId,
tagName: websiteTags.name,
tagColor: websiteTags.color,
clickCount: sql<number>`SUM(CASE WHEN ${tagStats.eventType} = 'click' THEN 1 ELSE 0 END)`,
filterCount: sql<number>`SUM(CASE WHEN ${tagStats.eventType} = 'filter' THEN 1 ELSE 0 END)`,
totalCount: sql<number>`COUNT(*)`,
})
.from(tagStats)
.innerJoin(websiteTags, eq(tagStats.tagId, websiteTags.id))
.where(gte(tagStats.createdAt, startDate))
.groupBy(tagStats.tagId)
.orderBy(desc(sql<number>`COUNT(*)`));
}
export async function getTagStatsTrend(tagId: number, days: number = 30) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
return await db
.select({
date: sql<string>`DATE(createdAt)`,
clickCount: sql<number>`SUM(CASE WHEN ${tagStats.eventType} = 'click' THEN 1 ELSE 0 END)`,
filterCount: sql<number>`SUM(CASE WHEN ${tagStats.eventType} = 'filter' THEN 1 ELSE 0 END)`,
})
.from(tagStats)
.where(and(
eq(tagStats.tagId, tagId),
gte(tagStats.createdAt, startDate)
))
.groupBy(sql`DATE(createdAt)`)
.orderBy(sql`DATE(createdAt)`);
}
export async function getAllTagsTrend(days: number = 30) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
return await db
.select({
date: sql<string>`DATE(createdAt)`,
clickCount: sql<number>`SUM(CASE WHEN ${tagStats.eventType} = 'click' THEN 1 ELSE 0 END)`,
filterCount: sql<number>`SUM(CASE WHEN ${tagStats.eventType} = 'filter' THEN 1 ELSE 0 END)`,
totalCount: sql<number>`COUNT(*)`,
})
.from(tagStats)
.where(gte(tagStats.createdAt, startDate))
.groupBy(sql`DATE(createdAt)`)
.orderBy(sql`DATE(createdAt)`);
}
export async function getTagEventTypeDistribution(days: number = 30) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
const result = await db
.select({
eventType: tagStats.eventType,
count: sql<number>`COUNT(*)`,
})
.from(tagStats)
.where(gte(tagStats.createdAt, startDate))
.groupBy(tagStats.eventType);
return result;
}
export async function getTopTags(limit: number = 10, days: number = 30) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
return await db
.select({
tagId: tagStats.tagId,
tagName: websiteTags.name,
tagColor: websiteTags.color,
totalCount: sql<number>`COUNT(*)`,
})
.from(tagStats)
.innerJoin(websiteTags, eq(tagStats.tagId, websiteTags.id))
.where(gte(tagStats.createdAt, startDate))
.groupBy(tagStats.tagId)
.orderBy(desc(sql<number>`COUNT(*)`))
.limit(limit);
}
export async function getTagStatsCount(days: number = 30) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
const result = await db
.select({
count: sql<number>`COUNT(*)`,
})
.from(tagStats)
.where(gte(tagStats.createdAt, startDate));
return result[0]?.count || 0;
}
// ============ Public Hot Tags Recommendation Functions ============
export async function getHotTagsRecommendation(limit: number = 8, days: number = 7) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
// Get tags with heat score based on recent activity
const hotTags = await db
.select({
tagId: tagStats.tagId,
tagName: websiteTags.name,
tagSlug: websiteTags.slug,
tagColor: websiteTags.color,
tagIcon: websiteTags.icon,
clickCount: sql<number>`SUM(CASE WHEN ${tagStats.eventType} = 'click' THEN 1 ELSE 0 END)`,
filterCount: sql<number>`SUM(CASE WHEN ${tagStats.eventType} = 'filter' THEN 1 ELSE 0 END)`,
totalCount: sql<number>`COUNT(*)`,
})
.from(tagStats)
.innerJoin(websiteTags, eq(tagStats.tagId, websiteTags.id))
.where(and(
gte(tagStats.createdAt, startDate),
eq(websiteTags.isActive, true)
))
.groupBy(tagStats.tagId)
.orderBy(desc(sql<number>`COUNT(*)`))
.limit(limit);
// Calculate heat score (0-100) based on relative popularity
const maxCount = hotTags[0]?.totalCount || 1;
return hotTags.map((tag, index) => ({
id: tag.tagId,
name: tag.tagName,
slug: tag.tagSlug,
color: tag.tagColor,
icon: tag.tagIcon,
clickCount: Number(tag.clickCount),
filterCount: Number(tag.filterCount),
totalCount: Number(tag.totalCount),
heatScore: Math.round((Number(tag.totalCount) / maxCount) * 100),
rank: index + 1,
}));
}
export async function getRecommendedTagsWithFallback(limit: number = 8, days: number = 7) {
const db = await getDb();
if (!db) throw new Error("Database not available");
// First try to get hot tags from statistics
const hotTags = await getHotTagsRecommendation(limit, days);
// If not enough hot tags, fill with popular tags by website count
if (hotTags.length < limit) {
const existingTagIds = hotTags.map(t => t.id);
const remainingLimit = limit - hotTags.length;
// Get tags with most websites as fallback
const popularTags = await db
.select({
id: websiteTags.id,
name: websiteTags.name,
slug: websiteTags.slug,
color: websiteTags.color,
icon: websiteTags.icon,
websiteCount: sql<number>`COUNT(${websiteTagMappings.websiteId})`,
})
.from(websiteTags)
.leftJoin(websiteTagMappings, eq(websiteTags.id, websiteTagMappings.tagId))
.where(and(
eq(websiteTags.isActive, true),
existingTagIds.length > 0
? sql`${websiteTags.id} NOT IN (${existingTagIds.join(',')})`
: sql`1=1`
))
.groupBy(websiteTags.id)
.orderBy(desc(sql<number>`COUNT(${websiteTagMappings.websiteId})`))
.limit(remainingLimit);
// Merge hot tags with popular tags
const fallbackTags = popularTags.map((tag, index) => ({
id: tag.id,
name: tag.name,
slug: tag.slug,
color: tag.color,
icon: tag.icon,
clickCount: 0,
filterCount: 0,
totalCount: 0,
heatScore: 0,
rank: hotTags.length + index + 1,
isNew: true, // Mark as new/recommended based on content
}));
return [...hotTags, ...fallbackTags];
}
return hotTags;
}
// ============ Personalized Tag Recommendation Functions ============
/**
* Get user's tag preference based on their historical interactions
*/
export async function getUserTagPreferences(userId: number, days: number = 30) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
return await db
.select({
tagId: tagStats.tagId,
tagName: websiteTags.name,
tagSlug: websiteTags.slug,
tagColor: websiteTags.color,
tagIcon: websiteTags.icon,
clickCount: sql<number>`SUM(CASE WHEN ${tagStats.eventType} = 'click' THEN 1 ELSE 0 END)`,
filterCount: sql<number>`SUM(CASE WHEN ${tagStats.eventType} = 'filter' THEN 1 ELSE 0 END)`,
totalCount: sql<number>`COUNT(*)`,
})
.from(tagStats)
.innerJoin(websiteTags, eq(tagStats.tagId, websiteTags.id))
.where(and(
eq(tagStats.userId, userId),
gte(tagStats.createdAt, startDate),
eq(websiteTags.isActive, true)
))
.groupBy(tagStats.tagId)
.orderBy(desc(sql<number>`COUNT(*)`));
}
/**
* Calculate personalized preference score for a user
* Score = (clickCount * 1) + (filterCount * 2) - filter actions are more intentional
*/
export async function getPersonalizedTagRecommendation(
userId: number,
limit: number = 8,
days: number = 30
) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
// Get user's personal preferences with weighted score
const userPreferences = await db
.select({
tagId: tagStats.tagId,
tagName: websiteTags.name,
tagSlug: websiteTags.slug,
tagColor: websiteTags.color,
tagIcon: websiteTags.icon,
clickCount: sql<number>`SUM(CASE WHEN ${tagStats.eventType} = 'click' THEN 1 ELSE 0 END)`,
filterCount: sql<number>`SUM(CASE WHEN ${tagStats.eventType} = 'filter' THEN 1 ELSE 0 END)`,
// Weighted score: filter actions count more (2x) than clicks
preferenceScore: sql<number>`SUM(CASE WHEN ${tagStats.eventType} = 'click' THEN 1 ELSE 0 END) + SUM(CASE WHEN ${tagStats.eventType} = 'filter' THEN 2 ELSE 0 END)`,
})
.from(tagStats)
.innerJoin(websiteTags, eq(tagStats.tagId, websiteTags.id))
.where(and(
eq(tagStats.userId, userId),
gte(tagStats.createdAt, startDate),
eq(websiteTags.isActive, true)
))
.groupBy(tagStats.tagId)
.orderBy(desc(sql<number>`SUM(CASE WHEN ${tagStats.eventType} = 'click' THEN 1 ELSE 0 END) + SUM(CASE WHEN ${tagStats.eventType} = 'filter' THEN 2 ELSE 0 END)`))
.limit(limit);
// Calculate normalized preference score (0-100)
const maxScore = userPreferences[0]?.preferenceScore || 1;
return userPreferences.map((tag, index) => {
const clickCount = Number(tag.clickCount);
const filterCount = Number(tag.filterCount);
// Generate recommendation reason based on user behavior
let reason = '';
if (filterCount > 0 && clickCount > 0) {
reason = `你筛选过 ${filterCount} 次,点击过 ${clickCount}`;
} else if (filterCount > 0) {
reason = `你筛选过 ${filterCount}`;
} else if (clickCount > 0) {
reason = `你点击过 ${clickCount}`;
}
return {
id: tag.tagId,
name: tag.tagName,
slug: tag.tagSlug,
color: tag.tagColor,
icon: tag.tagIcon,
clickCount,
filterCount,
preferenceScore: Math.round((Number(tag.preferenceScore) / Number(maxScore)) * 100),
rank: index + 1,
isPersonalized: true,
reason,
};
});
}
/**
* Get hybrid recommendation combining personal preferences and global hot tags
* For logged-in users: 60% personal + 40% hot tags
* For anonymous users: 100% hot tags
*/
export async function getHybridTagRecommendation(
userId: number | null,
limit: number = 8,
days: number = 30
) {
const db = await getDb();
if (!db) throw new Error("Database not available");
// If no user, return hot tags only
if (!userId) {
return await getRecommendedTagsWithFallback(limit, days);
}
// Get personalized recommendations (60% of limit)
const personalLimit = Math.ceil(limit * 0.6);
const personalTags = await getPersonalizedTagRecommendation(userId, personalLimit, days);
// If user has no history, return hot tags
if (personalTags.length === 0) {
const hotTags = await getRecommendedTagsWithFallback(limit, days);
return hotTags.map(tag => ({
...tag,
isPersonalized: false,
recommendationType: 'hot' as const,
}));
}
// Get hot tags to fill remaining slots (excluding already recommended)
const personalTagIds = personalTags.map(t => t.id);
const remainingLimit = limit - personalTags.length;
let hotTags: any[] = [];
if (remainingLimit > 0) {
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
hotTags = await db
.select({
tagId: tagStats.tagId,
tagName: websiteTags.name,
tagSlug: websiteTags.slug,
tagColor: websiteTags.color,
tagIcon: websiteTags.icon,
totalCount: sql<number>`COUNT(*)`,
})
.from(tagStats)
.innerJoin(websiteTags, eq(tagStats.tagId, websiteTags.id))
.where(and(
gte(tagStats.createdAt, startDate),
eq(websiteTags.isActive, true),
personalTagIds.length > 0
? sql`${tagStats.tagId} NOT IN (${personalTagIds.join(',')})`
: sql`1=1`
))
.groupBy(tagStats.tagId)
.orderBy(desc(sql<number>`COUNT(*)`))
.limit(remainingLimit);
}
// Combine and format results
const personalResults = personalTags.map((tag, index) => ({
id: tag.id,
name: tag.name,
slug: tag.slug,
color: tag.color,
icon: tag.icon,
clickCount: tag.clickCount,
filterCount: tag.filterCount,
preferenceScore: tag.preferenceScore,
heatScore: 0,
rank: index + 1,
isPersonalized: true,
recommendationType: 'personal' as const,
reason: tag.reason || '',
}));
const hotResults = hotTags.map((tag, index) => ({
id: tag.tagId,
name: tag.tagName,
slug: tag.tagSlug,
color: tag.tagColor,
icon: tag.tagIcon,
clickCount: 0,
filterCount: 0,
preferenceScore: 0,
heatScore: Number(tag.totalCount),
rank: personalResults.length + index + 1,
isPersonalized: false,
recommendationType: 'hot' as const,
reason: `热门标签,${tag.totalCount} 人关注`,
}));
return [...personalResults, ...hotResults];
}
/**
* Check if user has enough interaction history for personalized recommendations
*/
export async function hasUserTagHistory(userId: number, minInteractions: number = 3) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db
.select({
count: sql<number>`COUNT(*)`,
})
.from(tagStats)
.where(eq(tagStats.userId, userId));
return (result[0]?.count || 0) >= minInteractions;
}