diff --git a/basango/packages/db/src/queries/activities.ts b/basango/packages/db/src/queries/activities.ts deleted file mode 100644 index debea05..0000000 --- a/basango/packages/db/src/queries/activities.ts +++ /dev/null @@ -1,164 +0,0 @@ -import type { Database } from "@db/client"; -import { activities } from "@db/schema"; -import type { activityStatusEnum, activityTypeEnum } from "@db/schema"; -import { and, desc, eq, inArray, lte, ne } from "drizzle-orm"; -import type { SQL } from "drizzle-orm/sql/sql"; - -type CreateActivityParams = { - teamId: string; - userId?: string; - type: (typeof activityTypeEnum.enumValues)[number]; - source: "system" | "user"; - status?: (typeof activityStatusEnum.enumValues)[number]; - priority?: number; - groupId?: string; - metadata: Record; -}; - -export async function createActivity( - db: Database, - params: CreateActivityParams, -) { - const [result] = await db - .insert(activities) - .values({ - teamId: params.teamId, - userId: params.userId, - type: params.type, - source: params.source, - status: params.status, - priority: params.priority ?? 5, - groupId: params.groupId, - metadata: params.metadata, - }) - .returning(); - - return result; -} - -export async function updateActivityStatus( - db: Database, - activityId: string, - status: (typeof activityStatusEnum.enumValues)[number], - teamId: string, -) { - const [result] = await db - .update(activities) - .set({ status }) - .where(and(eq(activities.id, activityId), eq(activities.teamId, teamId))) - .returning(); - - return result; -} - -export async function updateAllActivitiesStatus( - db: Database, - teamId: string, - status: (typeof activityStatusEnum.enumValues)[number], - options: { userId: string }, -) { - const conditions = [ - eq(activities.teamId, teamId), - eq(activities.userId, options.userId), - ]; - - // Only update specific statuses based on the target status - if (status === "archived") { - // When archiving, update unread and read notifications - conditions.push(inArray(activities.status, ["unread", "read"])); - } else if (status === "read") { - // When marking as read, only update unread notifications (never archived) - conditions.push(eq(activities.status, "unread")); - } else { - // For other statuses, use the original logic but exclude archived - conditions.push(ne(activities.status, status)); - conditions.push(ne(activities.status, "archived")); - } - - const result = await db - .update(activities) - .set({ status }) - .where(and(...conditions)) - .returning(); - - return result; -} - -export type GetActivitiesParams = { - teamId: string; - cursor?: string | null; - pageSize?: number; - status?: - | (typeof activityStatusEnum.enumValues)[number][] - | (typeof activityStatusEnum.enumValues)[number] - | null; - userId?: string | null; - priority?: number | null; - maxPriority?: number | null; // For filtering notifications (priority <= 3) -}; - -export async function getActivities(db: Database, params: GetActivitiesParams) { - const { - teamId, - cursor, - pageSize = 20, - status, - userId, - priority, - maxPriority, - } = params; - - // Convert cursor to offset - const offset = cursor ? Number.parseInt(cursor, 10) : 0; - - // Base conditions for the WHERE clause - const whereConditions: SQL[] = [eq(activities.teamId, teamId)]; - - // Filter by status - support both single status and array of statuses - if (status) { - if (Array.isArray(status)) { - whereConditions.push(inArray(activities.status, status)); - } else { - whereConditions.push(eq(activities.status, status)); - } - } - - // Filter by user if specified - if (userId) { - whereConditions.push(eq(activities.userId, userId)); - } - - // Filter by priority if specified - if (priority) { - whereConditions.push(eq(activities.priority, priority)); - } - - // Filter by max priority if specified (for notifications: priority <= 3) - if (maxPriority) { - whereConditions.push(lte(activities.priority, maxPriority)); - } - - // Execute the query with proper ordering and pagination - const data = await db - .select() - .from(activities) - .where(and(...whereConditions)) - .orderBy(desc(activities.createdAt)) // Most recent first - .limit(pageSize) - .offset(offset); - - // Calculate next cursor - const nextCursor = - data && data.length === pageSize - ? (offset + pageSize).toString() - : undefined; - - return { - meta: { - cursor: nextCursor ?? null, - hasPreviousPage: offset > 0, - hasNextPage: data && data.length === pageSize, - }, - data, - }; -} diff --git a/basango/packages/db/src/queries/aggregator/articles.ts b/basango/packages/db/src/queries/aggregator/articles.ts new file mode 100644 index 0000000..6166ec3 --- /dev/null +++ b/basango/packages/db/src/queries/aggregator/articles.ts @@ -0,0 +1,84 @@ +import type { SQL } from "drizzle-orm"; +import { and, desc, eq, sql } from "drizzle-orm"; + +import type { Database } from "@db/client"; +import { articles, sources } from "@db/schema"; + +export interface ArticleExportRow { + articleId: string; + articleTitle: string; + articleLink: string; + articleCategories: string | null; + articleBody: string; + articleSource: string; + articleHash: string; + articlePublishedAt: string; + articleCrawledAt: string; +} + +export interface ArticleExportParams { + source?: string | null; + dateRange?: { start: number; end: number } | null; + batchSize?: number; +} + +export async function* getArticlesForExport( + db: Database, + params: ArticleExportParams = {}, +): AsyncGenerator { + const batchSize = params.batchSize && params.batchSize > 0 + ? params.batchSize + : 1000; + + const filters: SQL[] = []; + + if (params.source) { + filters.push(eq(sources.name, params.source)); + } + + if (params.dateRange) { + filters.push( + sql`${articles.publishedAt} BETWEEN to_timestamp(${params.dateRange.start}) AND to_timestamp(${params.dateRange.end})`, + ); + } + + let query = db + .select({ + articleId: articles.id, + articleTitle: articles.title, + articleLink: articles.link, + articleCategories: sql`array_to_string(${articles.categories}, ',')`, + articleBody: articles.body, + articleSource: sources.name, + articleHash: articles.hash, + articlePublishedAt: articles.publishedAt, + articleCrawledAt: articles.crawledAt, + }) + .from(articles) + .innerJoin(sources, eq(articles.sourceId, sources.id)); + + if (filters.length === 1) { + query = query.where(filters[0]); + } else if (filters.length > 1) { + query = query.where(and(...filters)); + } + + query = query.orderBy(desc(articles.publishedAt), desc(articles.id)); + + let offset = 0; + while (true) { + const rows = await query.limit(batchSize).offset(offset); + if (rows.length === 0) { + break; + } + + for (const row of rows) { + yield { + ...row, + articleCategories: row.articleCategories ?? null, + }; + } + + offset += batchSize; + } +} diff --git a/basango/packages/db/src/queries/aggregator/sources.ts b/basango/packages/db/src/queries/aggregator/sources.ts new file mode 100644 index 0000000..431b448 --- /dev/null +++ b/basango/packages/db/src/queries/aggregator/sources.ts @@ -0,0 +1,87 @@ +import { and, eq, sql } from "drizzle-orm"; + +import type { Database } from "@db/client"; +import { articles, sources } from "@db/schema"; + +export interface SourceStatisticsRow { + sourceId: string; + sourceName: string; + sourceCrawledAt: string | null; + articlesCount: number; + articleMetadataAvailable: number; +} + +export async function getSourceStatisticsList( + db: Database, +): Promise { + const rows = await db + .select({ + sourceId: sources.id, + sourceName: sources.name, + sourceCrawledAt: sql`max(${articles.crawledAt})`, + articlesCount: sql`count(${articles.id})`, + articleMetadataAvailable: sql`sum(CASE WHEN ${articles.metadata} IS NOT NULL THEN 1 ELSE 0 END)`, + }) + .from(sources) + .leftJoin(articles, eq(articles.sourceId, sources.id)) + .groupBy(sources.id, sources.name) + .orderBy(sources.name.asc()); + + return rows.map((row) => ({ + sourceId: row.sourceId, + sourceName: row.sourceName, + sourceCrawledAt: row.sourceCrawledAt, + articlesCount: Number(row.articlesCount ?? 0), + articleMetadataAvailable: Number(row.articleMetadataAvailable ?? 0), + })); +} + +export interface PublicationDateParams { + source: string; + category?: string | null; +} + +async function selectPublicationBoundary( + db: Database, + fn: "min" | "max", + params: PublicationDateParams, +): Promise { + const conditions = [eq(sources.name, params.source)]; + + if (params.category) { + conditions.push( + sql`${params.category} = ANY(${articles.categories})`, + ); + } + + const whereClause = conditions.length > 1 + ? and(...conditions) + : conditions[0]; + + const [result] = await db + .select({ + boundary: + fn === "min" + ? sql`min(${articles.publishedAt})` + : sql`max(${articles.publishedAt})`, + }) + .from(articles) + .innerJoin(sources, eq(articles.sourceId, sources.id)) + .where(whereClause); + + return result?.boundary ?? new Date().toISOString(); +} + +export async function getEarliestPublicationDate( + db: Database, + params: PublicationDateParams, +): Promise { + return selectPublicationBoundary(db, "min", params); +} + +export async function getLatestPublicationDate( + db: Database, + params: PublicationDateParams, +): Promise { + return selectPublicationBoundary(db, "max", params); +} diff --git a/basango/packages/db/src/queries/feed-management/articles.ts b/basango/packages/db/src/queries/feed-management/articles.ts new file mode 100644 index 0000000..488a050 --- /dev/null +++ b/basango/packages/db/src/queries/feed-management/articles.ts @@ -0,0 +1,479 @@ +import type { SQL, AnyColumn } from "drizzle-orm"; +import { + and, + asc, + desc, + eq, + gt, + lt, + or, + sql, +} from "drizzle-orm"; + +import type { Database } from "@db/client"; +import { + appUsers, + articles, + bookmarkArticles, + bookmarks, + comments, + sources, +} from "@db/schema"; +import { + buildPaginationResult, + createPageState, + decodeCursor, + type PageRequest, + type PaginationMeta, + type PageState, + type SortDirection, +} from "@db/utils/pagination"; + +export interface ArticleFilters { + search?: string | null; + category?: string | null; + dateRange?: { start: number; end: number } | null; + sortDirection?: SortDirection; +} + +export interface ArticleOverviewRow { + article_id: string; + article_title: string; + article_link: string; + article_categories: string | null; + article_excerpt: string | null; + article_published_at: string; + article_image: string | null; + article_reading_time: number | null; + source_id: string; + source_display_name: string | null; + source_image: string; + source_url: string; + source_name: string; + source_created_at: string; + article_is_bookmarked: boolean; +} + +export interface ArticleOverviewResult { + data: ArticleOverviewRow[]; + pagination: PaginationMeta; +} + +export interface ArticleDetailsRow { + article_id: string; + article_title: string; + article_link: string; + article_categories: string | null; + article_body: string; + article_hash: string; + article_published_at: string; + article_crawled_at: string; + article_updated_at: string | null; + article_bias: string; + article_reliability: string; + article_transparency: string; + article_sentiment: string; + article_metadata: unknown; + article_reading_time: number | null; + source_id: string; + source_name: string; + source_description: string | null; + source_url: string; + source_updated_at: string | null; + source_display_name: string | null; + source_bias: string; + source_reliability: string; + source_transparency: string; + source_image: string; + article_is_bookmarked: boolean; +} + +export interface ArticleCommentRow { + comment_id: string; + comment_content: string; + comment_sentiment: string; + comment_created_at: string; + user_id: string; + user_name: string; +} + +interface NormalizedArticleFilters { + search?: string; + category?: string; + dateRange?: { start: number; end: number } | null; + sortDirection: SortDirection; +} + +const SOURCE_IMAGE_BASE = "https://devscast.org/images/sources/"; + +function normalizeArticleFilters( + filters?: ArticleFilters, +): NormalizedArticleFilters { + const trimmedSearch = filters?.search?.trim(); + const trimmedCategory = filters?.category?.trim(); + + return { + search: trimmedSearch && trimmedSearch.length > 0 ? trimmedSearch : undefined, + category: + trimmedCategory && trimmedCategory.length > 0 + ? trimmedCategory + : undefined, + dateRange: filters?.dateRange ?? null, + sortDirection: filters?.sortDirection ?? "desc", + }; +} + +function buildArticleFilterConditions( + filters: NormalizedArticleFilters, +): { conditions: SQL[]; searchQuery?: string } { + const conditions: SQL[] = []; + let searchQuery: string | undefined; + + if (filters.category) { + conditions.push(sql`${filters.category} = ANY(${articles.categories})`); + } + + if (filters.search) { + const sanitized = filters.search.replace(/\s+/g, " & "); + if (sanitized.length > 0) { + searchQuery = sanitized; + conditions.push( + sql`${articles.tsv} @@ to_tsquery('french', ${sanitized})`, + ); + } + } + + if (filters.dateRange) { + conditions.push( + sql`${articles.publishedAt} BETWEEN to_timestamp(${filters.dateRange.start}) AND to_timestamp(${filters.dateRange.end})`, + ); + } + + return { conditions, searchQuery }; +} + +function buildBookmarkExistsExpression(userId: string): SQL { + return sql`EXISTS ( + SELECT 1 + FROM ${bookmarkArticles} ba + INNER JOIN ${bookmarks} b ON ba.bookmark_id = b.id + WHERE ba.article_id = ${articles.id} AND b.user_id = ${userId} + )`; +} + +async function fetchArticleOverview( + db: Database, + options: { + userId: string; + page: PageState; + filters: NormalizedArticleFilters; + baseConditions?: SQL[]; + }, +): Promise { + const baseConditions = options.baseConditions ?? []; + const { conditions: filterConditions, searchQuery } = + buildArticleFilterConditions(options.filters); + const whereConditions = [...baseConditions, ...filterConditions]; + + const bookmarkExpression = buildBookmarkExistsExpression(options.userId); + + const selectFields = { + article_id: articles.id, + article_title: articles.title, + article_link: articles.link, + article_categories: sql`array_to_string(${articles.categories}, ',')`, + article_excerpt: articles.excerpt, + article_published_at: articles.publishedAt, + article_image: articles.image, + article_reading_time: articles.readingTime, + source_id: sources.id, + source_display_name: sources.displayName, + source_image: sql`('${SOURCE_IMAGE_BASE}' || ${sources.name} || '.png')`, + source_url: sources.url, + source_name: sources.name, + source_created_at: sources.createdAt, + article_is_bookmarked: bookmarkExpression, + } satisfies Record; + + let query = db + .select(selectFields) + .from(articles) + .innerJoin(sources, eq(articles.sourceId, sources.id)); + + const cursor = decodeCursor(options.page.cursor); + if (cursor?.date) { + const comparison = + options.filters.sortDirection === "asc" + ? or( + gt(articles.publishedAt, cursor.date), + and( + eq(articles.publishedAt, cursor.date), + gt(articles.id, cursor.id), + ), + ) + : or( + lt(articles.publishedAt, cursor.date), + and( + eq(articles.publishedAt, cursor.date), + lt(articles.id, cursor.id), + ), + ); + whereConditions.push(comparison); + } + + if (whereConditions.length === 1) { + query = query.where(whereConditions[0]); + } else if (whereConditions.length > 1) { + query = query.where(and(...whereConditions)); + } + + const orderings: (SQL | AnyColumn)[] = []; + if (searchQuery) { + orderings.push( + options.filters.sortDirection === "asc" + ? sql`ts_rank(${articles.tsv}, to_tsquery('french', ${searchQuery})) ASC` + : sql`ts_rank(${articles.tsv}, to_tsquery('french', ${searchQuery})) DESC`, + ); + } + + if (options.filters.sortDirection === "asc") { + orderings.push(asc(articles.publishedAt), asc(articles.id)); + } else { + orderings.push(desc(articles.publishedAt), desc(articles.id)); + } + + const rows = await query + .orderBy(...orderings) + .limit(options.page.limit + 1); + + return buildPaginationResult(rows, options.page, { + id: "article_id", + date: "article_published_at", + }); +} + +export async function getArticleOverviewList( + db: Database, + params: { + userId: string; + page?: PageRequest; + filters?: ArticleFilters; + }, +): Promise { + const page = createPageState(params.page); + const filters = normalizeArticleFilters(params.filters); + + return fetchArticleOverview(db, { + userId: params.userId, + page, + filters, + }); +} + +export async function getSourceArticleOverviewList( + db: Database, + params: { + sourceId: string; + userId: string; + page?: PageRequest; + filters?: ArticleFilters; + }, +): Promise { + const page = createPageState(params.page); + const filters = normalizeArticleFilters(params.filters); + + return fetchArticleOverview(db, { + userId: params.userId, + page, + filters, + baseConditions: [eq(sources.id, params.sourceId)], + }); +} + +export async function getBookmarkedArticleList( + db: Database, + params: { + userId: string; + bookmarkId: string; + page?: PageRequest; + filters?: ArticleFilters; + }, +): Promise { + const page = createPageState(params.page); + const filters = normalizeArticleFilters(params.filters); + const { conditions: filterConditions, searchQuery } = + buildArticleFilterConditions(filters); + + const whereConditions: SQL[] = [ + eq(bookmarks.id, params.bookmarkId), + eq(bookmarks.userId, params.userId), + ...filterConditions, + ]; + + const selectFields = { + article_id: articles.id, + article_title: articles.title, + article_link: articles.link, + article_categories: sql`array_to_string(${articles.categories}, ',')`, + article_excerpt: articles.excerpt, + article_published_at: articles.publishedAt, + article_image: articles.image, + article_reading_time: articles.readingTime, + source_id: sources.id, + source_display_name: sources.displayName, + source_image: sql`('${SOURCE_IMAGE_BASE}' || ${sources.name} || '.png')`, + source_url: sources.url, + source_name: sources.name, + source_created_at: sources.createdAt, + article_is_bookmarked: sql`true`, + } satisfies Record; + + let query = db + .select(selectFields) + .from(bookmarkArticles) + .innerJoin(articles, eq(bookmarkArticles.articleId, articles.id)) + .innerJoin(bookmarks, eq(bookmarkArticles.bookmarkId, bookmarks.id)) + .innerJoin(sources, eq(articles.sourceId, sources.id)); + + const cursor = decodeCursor(page.cursor); + if (cursor?.date) { + const comparison = + filters.sortDirection === "asc" + ? or( + gt(articles.publishedAt, cursor.date), + and( + eq(articles.publishedAt, cursor.date), + gt(articles.id, cursor.id), + ), + ) + : or( + lt(articles.publishedAt, cursor.date), + and( + eq(articles.publishedAt, cursor.date), + lt(articles.id, cursor.id), + ), + ); + whereConditions.push(comparison); + } + + if (whereConditions.length === 1) { + query = query.where(whereConditions[0]); + } else if (whereConditions.length > 1) { + query = query.where(and(...whereConditions)); + } + + const orderings: (SQL | AnyColumn)[] = []; + if (searchQuery) { + orderings.push( + filters.sortDirection === "asc" + ? sql`ts_rank(${articles.tsv}, to_tsquery('french', ${searchQuery})) ASC` + : sql`ts_rank(${articles.tsv}, to_tsquery('french', ${searchQuery})) DESC`, + ); + } + + if (filters.sortDirection === "asc") { + orderings.push(asc(articles.publishedAt), asc(articles.id)); + } else { + orderings.push(desc(articles.publishedAt), desc(articles.id)); + } + + const rows = await query + .orderBy(...orderings) + .limit(page.limit + 1); + + return buildPaginationResult(rows, page, { + id: "article_id", + date: "article_published_at", + }); +} + +export async function getArticleDetails( + db: Database, + params: { id: string; userId: string }, +): Promise { + const bookmarkExpression = buildBookmarkExistsExpression(params.userId); + + const [row] = await db + .select({ + article_id: articles.id, + article_title: articles.title, + article_link: articles.link, + article_categories: sql`array_to_string(${articles.categories}, ',')`, + article_body: articles.body, + article_hash: articles.hash, + article_published_at: articles.publishedAt, + article_crawled_at: articles.crawledAt, + article_updated_at: articles.updatedAt, + article_bias: articles.bias, + article_reliability: articles.reliability, + article_transparency: articles.transparency, + article_sentiment: articles.sentiment, + article_metadata: articles.metadata, + article_reading_time: articles.readingTime, + source_id: sources.id, + source_name: sources.name, + source_description: sources.description, + source_url: sources.url, + source_updated_at: sources.updatedAt, + source_display_name: sources.displayName, + source_bias: sources.bias, + source_reliability: sources.reliability, + source_transparency: sources.transparency, + source_image: sql`('${SOURCE_IMAGE_BASE}' || ${sources.name} || '.png')`, + article_is_bookmarked: bookmarkExpression, + }) + .from(articles) + .innerJoin(sources, eq(articles.sourceId, sources.id)) + .where(eq(articles.id, params.id)) + .limit(1); + + return row ?? null; +} + +export async function getArticleCommentList( + db: Database, + params: { articleId: string; page?: PageRequest }, +): Promise<{ data: ArticleCommentRow[]; pagination: PaginationMeta }> { + const page = createPageState(params.page); + const whereConditions: SQL[] = [eq(comments.articleId, params.articleId)]; + + const cursor = decodeCursor(page.cursor); + if (cursor?.date) { + whereConditions.push( + or( + lt(comments.createdAt, cursor.date), + and( + eq(comments.createdAt, cursor.date), + lt(comments.id, cursor.id), + ), + ), + ); + } + + let query = db + .select({ + comment_id: comments.id, + comment_content: comments.content, + comment_sentiment: comments.sentiment, + comment_created_at: comments.createdAt, + user_id: appUsers.id, + user_name: appUsers.name, + }) + .from(comments) + .innerJoin(appUsers, eq(comments.userId, appUsers.id)); + + if (whereConditions.length === 1) { + query = query.where(whereConditions[0]); + } else if (whereConditions.length > 1) { + query = query.where(and(...whereConditions)); + } + + const rows = await query + .orderBy(desc(comments.createdAt), desc(comments.id)) + .limit(page.limit + 1); + + return buildPaginationResult(rows, page, { + id: "comment_id", + date: "comment_created_at", + }); +} diff --git a/basango/packages/db/src/queries/feed-management/bookmarks.ts b/basango/packages/db/src/queries/feed-management/bookmarks.ts new file mode 100644 index 0000000..4f06864 --- /dev/null +++ b/basango/packages/db/src/queries/feed-management/bookmarks.ts @@ -0,0 +1,66 @@ +import type { SQL } from "drizzle-orm"; +import { and, desc, eq, lt, sql } from "drizzle-orm"; + +import type { Database } from "@db/client"; +import { bookmarkArticles, bookmarks } from "@db/schema"; +import { + buildPaginationResult, + createPageState, + decodeCursor, + type PageRequest, + type PaginationMeta, +} from "@db/utils/pagination"; + +export interface BookmarkRow { + bookmark_id: string; + bookmark_name: string; + bookmark_description: string | null; + bookmark_created_at: string; + bookmark_updated_at: string | null; + bookmark_articles_count: number; + bookmark_is_public: boolean; +} + +export interface BookmarkListResult { + data: BookmarkRow[]; + pagination: PaginationMeta; +} + +export async function getBookmarkList( + db: Database, + params: { userId: string; page?: PageRequest }, +): Promise { + const page = createPageState(params.page); + const whereConditions: SQL[] = [eq(bookmarks.userId, params.userId)]; + + const cursor = decodeCursor(page.cursor); + if (cursor?.id) { + whereConditions.push(lt(bookmarks.id, cursor.id)); + } + + let query = db + .select({ + bookmark_id: bookmarks.id, + bookmark_name: bookmarks.name, + bookmark_description: bookmarks.description, + bookmark_created_at: bookmarks.createdAt, + bookmark_updated_at: bookmarks.updatedAt, + bookmark_articles_count: sql`count(${bookmarkArticles.articleId})`, + bookmark_is_public: bookmarks.isPublic, + }) + .from(bookmarks) + .leftJoin(bookmarkArticles, eq(bookmarkArticles.bookmarkId, bookmarks.id)) + .groupBy(bookmarks.id); + + if (whereConditions.length === 1) { + query = query.where(whereConditions[0]); + } else if (whereConditions.length > 1) { + query = query.where(and(...whereConditions)); + } + + const rows = await query + .orderBy(desc(bookmarks.createdAt), desc(bookmarks.id)) + .limit(page.limit + 1); + + return buildPaginationResult(rows, page, { id: "bookmark_id" }); +} diff --git a/basango/packages/db/src/queries/feed-management/sources.ts b/basango/packages/db/src/queries/feed-management/sources.ts new file mode 100644 index 0000000..b15f95e --- /dev/null +++ b/basango/packages/db/src/queries/feed-management/sources.ts @@ -0,0 +1,253 @@ +import type { SQL } from "drizzle-orm"; +import { and, desc, eq, lt, or, sql } from "drizzle-orm"; + +import type { Database } from "@db/client"; +import { articles, followedSources, sources } from "@db/schema"; +import { + buildPaginationResult, + createPageState, + decodeCursor, + type PageRequest, + type PaginationMeta, + type PageState, +} from "@db/utils/pagination"; + +const SOURCE_IMAGE_BASE = "https://devscast.org/images/sources/"; +const PUBLICATION_GRAPH_DAYS = 180; + +export interface SourceOverviewRow { + source_id: string; + source_display_name: string | null; + source_image: string; + source_url: string; + source_name: string; + source_created_at: string; + source_is_followed: boolean; +} + +export interface SourceOverviewResult { + data: SourceOverviewRow[]; + pagination: PaginationMeta; +} + +export interface PublicationEntry { + day: string; + count: number; +} + +export interface CategoryShare { + category: string; + count: number; + percentage: number; +} + +export interface SourceDetailsResult { + source: { + source_id: string; + source_name: string; + source_description: string | null; + source_url: string; + source_updated_at: string | null; + source_display_name: string | null; + source_bias: string; + source_reliability: string; + source_transparency: string; + source_image: string; + articles_count: number; + source_crawled_at: string | null; + articles_metadata_available: number; + source_is_followed: boolean; + }; + publicationGraph: PublicationEntry[]; + categoryShares: CategoryShare[]; +} + +function buildFollowExistsExpression(userId: string): SQL { + return sql`EXISTS ( + SELECT 1 + FROM ${followedSources} f + WHERE f.source_id = ${sources.id} AND f.follower_id = ${userId} + )`; +} + +export async function getSourceOverviewList( + db: Database, + params: { userId: string; page?: PageRequest }, +): Promise { + const page = createPageState(params.page); + const followExpression = buildFollowExistsExpression(params.userId); + + let query = db + .select({ + source_id: sources.id, + source_display_name: sources.displayName, + source_image: sql`('${SOURCE_IMAGE_BASE}' || ${sources.name} || '.png')`, + source_url: sources.url, + source_name: sources.name, + source_created_at: sources.createdAt, + source_is_followed: followExpression, + }) + .from(sources); + + const cursor = decodeCursor(page.cursor); + if (cursor?.date) { + query = query.where( + or( + lt(sources.createdAt, cursor.date), + and(eq(sources.createdAt, cursor.date), lt(sources.id, cursor.id)), + ), + ); + } + + const rows = await query + .orderBy(desc(sources.createdAt), desc(sources.id)) + .limit(page.limit + 1); + + return buildPaginationResult(rows, page, { + id: "source_id", + date: "source_created_at", + }); +} + +function createBackwardDateRange(days: number): { start: number; end: number } { + const now = new Date(); + const end = Math.floor((now.getTime() + 86_400_000) / 1000); + const startDate = new Date(now.getTime() - days * 86_400_000); + const start = Math.floor(startDate.getTime() / 1000); + + return { start, end }; +} + +async function fetchPublicationGraph( + db: Database, + sourceId: string, +): Promise { + const range = createBackwardDateRange(PUBLICATION_GRAPH_DAYS); + + const rows = await db + .select({ + day: sql`date(${articles.publishedAt})`, + count: sql`count(${articles.id})`, + }) + .from(articles) + .where(eq(articles.sourceId, sourceId)) + .where( + sql`${articles.publishedAt} BETWEEN to_timestamp(${range.start}) AND to_timestamp(${range.end})`, + ) + .groupBy(sql`date(${articles.publishedAt})`) + .orderBy(sql`date(${articles.publishedAt})`); + + const counts = new Map(); + for (const row of rows) { + counts.set(row.day, Number(row.count ?? 0)); + } + + const entries: PublicationEntry[] = []; + const start = new Date(range.start * 1000); + const end = new Date(range.end * 1000); + + for ( + let date = new Date(start.getTime()); + date < end; + date.setUTCDate(date.getUTCDate() + 1) + ) { + const day = date.toISOString().slice(0, 10); + entries.push({ day, count: counts.get(day) ?? 0 }); + } + + return entries; +} + +async function fetchCategoryShares( + db: Database, + sourceId: string, +): Promise { + const rows = await db + .select({ + categories: sql`array_to_string(${articles.categories}, ',')`, + }) + .from(articles) + .where(eq(articles.sourceId, sourceId)); + + const counts = new Map(); + for (const row of rows) { + if (!row.categories) continue; + for (const category of row.categories.split(",")) { + const normalized = category.trim(); + if (normalized.length === 0) continue; + counts.set(normalized, (counts.get(normalized) ?? 0) + 1); + } + } + + const total = Array.from(counts.values()).reduce((acc, value) => acc + value, 0); + + const shares: CategoryShare[] = Array.from(counts.entries()).map( + ([category, count]) => ({ + category, + count, + percentage: total > 0 ? Math.round((count / total) * 10000) / 100 : 0, + }), + ); + + shares.sort((a, b) => b.count - a.count); + return shares; +} + +export async function getSourceDetails( + db: Database, + params: { sourceId: string; userId: string }, +): Promise { + const followExpression = buildFollowExistsExpression(params.userId); + + const [row] = await db + .select({ + source_id: sources.id, + source_name: sources.name, + source_description: sources.description, + source_url: sources.url, + source_updated_at: sources.updatedAt, + source_display_name: sources.displayName, + source_bias: sources.bias, + source_reliability: sources.reliability, + source_transparency: sources.transparency, + source_image: sql`('${SOURCE_IMAGE_BASE}' || ${sources.name} || '.png')`, + articles_count: sql`count(${articles.id})`, + source_crawled_at: sql`max(${articles.crawledAt})`, + articles_metadata_available: sql`count(*) FILTER (WHERE ${articles.metadata} IS NOT NULL)`, + source_is_followed: followExpression, + }) + .from(sources) + .leftJoin(articles, eq(articles.sourceId, sources.id)) + .where(eq(sources.id, params.sourceId)) + .groupBy( + sources.id, + sources.name, + sources.description, + sources.url, + sources.updatedAt, + sources.displayName, + sources.bias, + sources.reliability, + sources.transparency, + ) + .limit(1); + + if (!row) { + return null; + } + + const [publicationGraph, categoryShares] = await Promise.all([ + fetchPublicationGraph(db, params.sourceId), + fetchCategoryShares(db, params.sourceId), + ]); + + return { + source: { + ...row, + articles_count: Number(row.articles_count ?? 0), + articles_metadata_available: Number(row.articles_metadata_available ?? 0), + }, + publicationGraph, + categoryShares, + }; +} diff --git a/basango/packages/db/src/queries/identity/users.ts b/basango/packages/db/src/queries/identity/users.ts new file mode 100644 index 0000000..c8ba004 --- /dev/null +++ b/basango/packages/db/src/queries/identity/users.ts @@ -0,0 +1,31 @@ +import { eq } from "drizzle-orm"; + +import type { Database } from "@db/client"; +import { appUsers } from "@db/schema"; + +export interface UserProfileRow { + user_id: string; + user_name: string; + user_email: string; + user_created_at: string; + user_updated_at: string | null; +} + +export async function getUserProfile( + db: Database, + params: { userId: string }, +): Promise { + const [row] = await db + .select({ + user_id: appUsers.id, + user_name: appUsers.name, + user_email: appUsers.email, + user_created_at: appUsers.createdAt, + user_updated_at: appUsers.updatedAt, + }) + .from(appUsers) + .where(eq(appUsers.id, params.userId)) + .limit(1); + + return row ?? null; +} diff --git a/basango/packages/db/src/schema.ts b/basango/packages/db/src/schema.ts index 0179685..d4d5904 100644 --- a/basango/packages/db/src/schema.ts +++ b/basango/packages/db/src/schema.ts @@ -4,6 +4,7 @@ import { boolean, customType, date, + doublePrecision, foreignKey, index, integer, @@ -33,6 +34,14 @@ export const tsvector = customType<{ }, }); +export const inet = customType<{ + data: string; +}>({ + dataType() { + return "inet"; + }, +}); + type NumericConfig = { precision?: number; scale?: number; @@ -118,6 +127,43 @@ export const invoiceStatusEnum = pgEnum("invoice_status", [ "scheduled", ]); +export const articleSentimentEnum = pgEnum("article_sentiment", [ + "positive", + "neutral", + "negative", +]); + +export const biasEnum = pgEnum("bias", [ + "neutral", + "slightly", + "partisan", + "extreme", +]); + +export const reliabilityEnum = pgEnum("reliability", [ + "trusted", + "reliable", + "average", + "low_trust", + "unreliable", +]); + +export const transparencyEnum = pgEnum("transparency", [ + "high", + "medium", + "low", +]); + +export const verificationTokenPurposeEnum = pgEnum( + "verification_token_purpose", + [ + "confirm_account", + "password_reset", + "unlock_account", + "delete_account", + ], +); + export const plansEnum = pgEnum("plans", ["trial", "starter", "pro"]); export const subscriptionStatusEnum = pgEnum("subscription_status", [ "active", @@ -2477,7 +2523,432 @@ export const apiKeys = pgTable( ], ); +export const sources = pgTable( + "source", + { + id: uuid("id").notNull().defaultRandom().primaryKey(), + url: varchar("url", { length: 255 }).notNull(), + name: varchar("name", { length: 255 }).notNull(), + displayName: varchar("display_name", { length: 255 }), + description: varchar("description", { length: 1024 }), + createdAt: timestamp("created_at", { mode: "string" }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { mode: "string" }), + bias: biasEnum("bias").notNull().default("neutral"), + reliability: reliabilityEnum("reliability").notNull().default("reliable"), + transparency: transparencyEnum("transparency").notNull().default("medium"), + }, + (table) => [ + uniqueIndex("unq_source_name").using( + "btree", + sql`lower(${table.name})`, + ), + uniqueIndex("unq_source_url").using( + "btree", + sql`lower(${table.url})`, + ), + ], +); + +export const articles = pgTable( + "article", + { + id: uuid("id").notNull().defaultRandom().primaryKey(), + sourceId: uuid("source_id").notNull(), + title: varchar("title", { length: 1024 }).notNull(), + body: text("body").notNull(), + hash: varchar("hash", { length: 32 }).notNull(), + categories: text("categories").array(), + sentiment: articleSentimentEnum("sentiment").notNull().default("neutral"), + metadata: jsonb("metadata"), + tokenStatistics: jsonb("token_statistics"), + image: varchar("image", { length: 1024 }).generatedAlwaysAs( + sql`(metadata->>'image')`, + { stored: true }, + ), + excerpt: varchar("excerpt", { length: 255 }).generatedAlwaysAs( + sql`((left(body, 200) || '...'))`, + { stored: true }, + ), + publishedAt: timestamp("published_at", { mode: "string" }).notNull(), + crawledAt: timestamp("crawled_at", { mode: "string" }).notNull(), + updatedAt: timestamp("updated_at", { mode: "string" }), + link: varchar("link", { length: 1024 }).notNull(), + bias: biasEnum("bias").notNull().default("neutral"), + reliability: reliabilityEnum("reliability").notNull().default("reliable"), + transparency: transparencyEnum("transparency").notNull().default("medium"), + readingTime: integer("reading_time").default(1), + tsv: tsvector("tsv").generatedAlwaysAs( + sql`(setweight(to_tsvector('french', coalesce(title, '')), 'A') || setweight(to_tsvector('french', coalesce(body, '')), 'B'))`, + { stored: true }, + ), + }, + (table) => [ + index("article_source_id_idx").on(table.sourceId), + index("idx_article_published_at") + .using("btree", table.publishedAt.desc()), + index("idx_article_published_id") + .using("btree", table.publishedAt.desc(), table.id.desc()), + unique("unq_article_hash").on(table.hash), + index("gin_article_tsv").using("gin", table.tsv), + index("gin_article_link_trgm").using( + "gin", + table.link.op("gin_trgm_ops"), + ), + index("gin_article_title_trgm").using( + "gin", + table.title.op("gin_trgm_ops"), + ), + index("gin_article_categories").using("gin", table.categories), + foreignKey({ + columns: [table.sourceId], + foreignColumns: [sources.id], + name: "article_source_id_fkey", + }).onDelete("cascade"), + { + kind: "check", + expression: sql`reading_time >= 0`, + name: "chk_article_reading_time", + }, + { + kind: "check", + expression: sql`(metadata IS NULL OR jsonb_typeof(metadata) IN ('object','array'))`, + name: "chk_article_metadata_json", + }, + ], +); + +export const appUsers = pgTable( + "user", + { + id: uuid("id").notNull().defaultRandom().primaryKey(), + name: varchar("name", { length: 255 }).notNull(), + email: varchar("email", { length: 255 }).notNull(), + password: varchar("password", { length: 512 }).notNull(), + isLocked: boolean("is_locked").notNull().default(false), + isConfirmed: boolean("is_confirmed").notNull().default(false), + createdAt: timestamp("created_at", { mode: "string" }).notNull(), + updatedAt: timestamp("updated_at", { mode: "string" }), + roles: jsonb("roles").notNull(), + }, + (table) => [ + uniqueIndex("unq_user_email").using( + "btree", + sql`lower(${table.email})`, + ), + { + kind: "check", + name: "chk_user_roles_array", + expression: sql`jsonb_typeof(roles) = 'array'`, + }, + ], +); + +export const bookmarks = pgTable( + "bookmark", + { + id: uuid("id").notNull().defaultRandom().primaryKey(), + userId: uuid("user_id").notNull(), + name: varchar("name", { length: 255 }).notNull(), + description: varchar("description", { length: 512 }), + isPublic: boolean("is_public").notNull().default(false), + createdAt: timestamp("created_at", { mode: "string" }).notNull(), + updatedAt: timestamp("updated_at", { mode: "string" }), + }, + (table) => [ + index("bookmark_user_id_idx").on(table.userId), + index("idx_bookmark_user_created").using( + "btree", + table.userId, + table.createdAt.desc(), + ), + foreignKey({ + columns: [table.userId], + foreignColumns: [appUsers.id], + name: "bookmark_user_id_fkey", + }).onDelete("cascade"), + ], +); + +export const bookmarkArticles = pgTable( + "bookmark_article", + { + bookmarkId: uuid("bookmark_id").notNull(), + articleId: uuid("article_id").notNull(), + }, + (table) => [ + primaryKey({ + columns: [table.bookmarkId, table.articleId], + name: "bookmark_article_pkey", + }), + index("bookmark_article_bookmark_idx").on(table.bookmarkId), + index("bookmark_article_article_idx").on(table.articleId), + foreignKey({ + columns: [table.bookmarkId], + foreignColumns: [bookmarks.id], + name: "bookmark_article_bookmark_id_fkey", + }).onDelete("cascade"), + foreignKey({ + columns: [table.articleId], + foreignColumns: [articles.id], + name: "bookmark_article_article_id_fkey", + }).onDelete("cascade"), + ], +); + +export const comments = pgTable( + "comment", + { + id: uuid("id").notNull().defaultRandom().primaryKey(), + userId: uuid("user_id").notNull(), + articleId: uuid("article_id").notNull(), + content: varchar("content", { length: 512 }).notNull(), + sentiment: articleSentimentEnum("sentiment").notNull().default("neutral"), + isSpam: boolean("is_spam").notNull().default(false), + createdAt: timestamp("created_at", { mode: "string" }).notNull(), + }, + (table) => [ + index("comment_user_id_idx").on(table.userId), + index("comment_article_id_idx").on(table.articleId), + index("idx_comment_article_created").using( + "btree", + table.articleId, + table.createdAt.desc(), + ), + foreignKey({ + columns: [table.userId], + foreignColumns: [appUsers.id], + name: "comment_user_id_fkey", + }).onDelete("cascade"), + foreignKey({ + columns: [table.articleId], + foreignColumns: [articles.id], + name: "comment_article_id_fkey", + }).onDelete("cascade"), + ], +); + +export const followedSources = pgTable( + "followed_source", + { + id: uuid("id").notNull().defaultRandom().primaryKey(), + followerId: uuid("follower_id").notNull(), + sourceId: uuid("source_id").notNull(), + createdAt: timestamp("created_at", { mode: "string" }).notNull(), + }, + (table) => [ + index("followed_source_follower_idx").on(table.followerId), + index("followed_source_source_idx").on(table.sourceId), + index("idx_followed_source_follower_created").using( + "btree", + table.followerId, + table.createdAt.desc(), + ), + foreignKey({ + columns: [table.followerId], + foreignColumns: [appUsers.id], + name: "followed_source_follower_id_fkey", + }).onDelete("cascade"), + foreignKey({ + columns: [table.sourceId], + foreignColumns: [sources.id], + name: "followed_source_source_id_fkey", + }).onDelete("cascade"), + ], +); + +export const loginAttempts = pgTable( + "login_attempt", + { + id: uuid("id").notNull().defaultRandom().primaryKey(), + userId: uuid("user_id").notNull(), + createdAt: timestamp("created_at", { mode: "string" }).notNull(), + }, + (table) => [ + index("login_attempt_user_id_idx").on(table.userId), + index("idx_login_attempt_created_at").using( + "btree", + table.createdAt.desc(), + ), + foreignKey({ + columns: [table.userId], + foreignColumns: [appUsers.id], + name: "login_attempt_user_id_fkey", + }).onDelete("cascade"), + ], +); + +export const loginHistories = pgTable( + "login_history", + { + id: uuid("id").notNull().defaultRandom().primaryKey(), + userId: uuid("user_id").notNull(), + ipAddress: inet("ip_address"), + createdAt: timestamp("created_at", { mode: "string" }).notNull(), + deviceOperatingSystem: varchar("device_operating_system", { length: 255 }), + deviceClient: varchar("device_client", { length: 255 }), + deviceDevice: varchar("device_device", { length: 255 }), + deviceIsBot: boolean("device_is_bot").notNull().default(false), + locationTimeZone: varchar("location_time_zone", { length: 255 }), + locationLongitude: doublePrecision("location_longitude"), + locationLatitude: doublePrecision("location_latitude"), + locationAccuracyRadius: integer("location_accuracy_radius"), + }, + (table) => [ + index("login_history_user_id_idx").on(table.userId), + index("idx_login_history_created_at").using( + "btree", + table.userId, + table.createdAt.desc(), + ), + index("login_history_ip_address_idx").on(table.ipAddress), + foreignKey({ + columns: [table.userId], + foreignColumns: [appUsers.id], + name: "login_history_user_id_fkey", + }).onDelete("cascade"), + ], +); + +export const refreshTokens = pgTable( + "refresh_tokens", + { + id: integer("id") + .generatedAlwaysAsIdentity({ name: "refresh_tokens_id_seq" }) + .primaryKey(), + refreshToken: varchar("refresh_token", { length: 128 }).notNull(), + username: varchar("username", { length: 255 }).notNull(), + validUntil: timestamp("valid", { mode: "string" }).notNull(), + }, + (table) => [unique("uniq_refresh_token_token").on(table.refreshToken)], +); + +export const verificationTokens = pgTable( + "verification_token", + { + id: uuid("id").notNull().defaultRandom().primaryKey(), + userId: uuid("user_id").notNull(), + purpose: verificationTokenPurposeEnum("purpose").notNull(), + token: varchar("token", { length: 60 }), + createdAt: timestamp("created_at", { mode: "string" }).notNull(), + }, + (table) => [ + index("verification_token_user_id_idx").on(table.userId), + index("idx_verification_token_created_at").using( + "btree", + table.createdAt.desc(), + ), + uniqueIndex("unq_verification_token_user_purpose") + .on(table.userId, table.purpose) + .where(sql`token IS NOT NULL`), + foreignKey({ + columns: [table.userId], + foreignColumns: [appUsers.id], + name: "verification_token_user_id_fkey", + }).onDelete("cascade"), + ], +); + // Relations + +export const sourcesRelations = relations(sources, ({ many }) => ({ + articles: many(articles), + followers: many(followedSources), +})); + +export const articlesRelations = relations(articles, ({ one, many }) => ({ + source: one(sources, { + fields: [articles.sourceId], + references: [sources.id], + }), + bookmarkLinks: many(bookmarkArticles), + comments: many(comments), +})); + +export const appUsersRelations = relations(appUsers, ({ many }) => ({ + bookmarks: many(bookmarks), + comments: many(comments), + loginAttempts: many(loginAttempts), + loginHistories: many(loginHistories), + verificationTokens: many(verificationTokens), + followedSources: many(followedSources), +})); + +export const bookmarksRelations = relations(bookmarks, ({ one, many }) => ({ + user: one(appUsers, { + fields: [bookmarks.userId], + references: [appUsers.id], + }), + articles: many(bookmarkArticles), +})); + +export const bookmarkArticlesRelations = relations( + bookmarkArticles, + ({ one }) => ({ + bookmark: one(bookmarks, { + fields: [bookmarkArticles.bookmarkId], + references: [bookmarks.id], + }), + article: one(articles, { + fields: [bookmarkArticles.articleId], + references: [articles.id], + }), + }), +); + +export const commentsRelations = relations(comments, ({ one }) => ({ + article: one(articles, { + fields: [comments.articleId], + references: [articles.id], + }), + user: one(appUsers, { + fields: [comments.userId], + references: [appUsers.id], + }), +})); + +export const followedSourcesRelations = relations( + followedSources, + ({ one }) => ({ + follower: one(appUsers, { + fields: [followedSources.followerId], + references: [appUsers.id], + }), + source: one(sources, { + fields: [followedSources.sourceId], + references: [sources.id], + }), + }), +); + +export const loginAttemptsRelations = relations( + loginAttempts, + ({ one }) => ({ + user: one(appUsers, { + fields: [loginAttempts.userId], + references: [appUsers.id], + }), + }), +); + +export const loginHistoriesRelations = relations( + loginHistories, + ({ one }) => ({ + user: one(appUsers, { + fields: [loginHistories.userId], + references: [appUsers.id], + }), + }), +); + +export const verificationTokensRelations = relations( + verificationTokens, + ({ one }) => ({ + user: one(appUsers, { + fields: [verificationTokens.userId], + references: [appUsers.id], + }), + }), +); // OAuth Applications export const oauthApplications = pgTable( "oauth_applications", diff --git a/basango/packages/db/src/utils/pagination.ts b/basango/packages/db/src/utils/pagination.ts new file mode 100644 index 0000000..134c3fc --- /dev/null +++ b/basango/packages/db/src/utils/pagination.ts @@ -0,0 +1,117 @@ +import { Buffer } from "node:buffer"; + +export type SortDirection = "asc" | "desc"; + +export interface PageRequest { + page?: number; + limit?: number; + cursor?: string | null; +} + +export interface PageState { + page: number; + limit: number; + cursor: string | null; + offset: number; +} + +export interface CursorPayload { + id: string; + date?: string | null; +} + +export interface PaginationMeta { + current: number; + limit: number; + cursor: string | null; + hasNext: boolean; +} + +const DEFAULT_PAGE = 1; +const DEFAULT_LIMIT = 5; +const MAX_LIMIT = 100; + +export function createPageState(request: PageRequest = {}): PageState { + const page = Number.isFinite(request.page) && (request.page ?? 0) > 0 + ? Math.trunc(request.page!) + : DEFAULT_PAGE; + + let limit = Number.isFinite(request.limit) && (request.limit ?? 0) > 0 + ? Math.trunc(request.limit!) + : DEFAULT_LIMIT; + + if (limit < DEFAULT_LIMIT) { + limit = DEFAULT_LIMIT; + } + + if (limit > MAX_LIMIT) { + limit = MAX_LIMIT; + } + + const cursor = request.cursor ?? null; + const offset = (page - 1) * limit; + + return { page, limit, cursor, offset }; +} + +export function encodeCursor( + row: Record, + keyset: { id: string; date?: string | null }, +): string { + const payload: CursorPayload = { + id: String(row[keyset.id] ?? ""), + }; + + if (keyset.date) { + const value = row[keyset.date]; + if (value !== undefined && value !== null) { + payload.date = String(value); + } + } + + return Buffer.from(JSON.stringify(payload), "utf8").toString("base64"); +} + +export function decodeCursor(cursor?: string | null): CursorPayload | null { + if (!cursor) { + return null; + } + + try { + const decoded = Buffer.from(cursor, "base64").toString("utf8"); + const payload = JSON.parse(decoded) as CursorPayload; + + if (!payload || typeof payload.id !== "string" || payload.id.length === 0) { + return null; + } + + return payload; + } catch { + return null; + } +} + +export function buildPaginationResult>( + rows: T[], + page: PageState, + keyset: { id: string; date?: string | null }, +): { data: T[]; pagination: PaginationMeta } { + const hasNext = rows.length > page.limit; + const data = hasNext ? rows.slice(0, page.limit) : rows; + + let cursor: string | null = null; + if (data.length > 0) { + const lastRow = data[data.length - 1]; + cursor = encodeCursor(lastRow, keyset); + } + + return { + data, + pagination: { + current: page.page, + limit: page.limit, + cursor, + hasNext, + }, + }; +}