1729 lines
49 KiB
TypeScript
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;
|
|
}
|