feat(domain): centralize data definition

This commit is contained in:
2025-11-17 00:04:27 +02:00
parent e7585aa76c
commit f39635e04f
96 changed files with 3474 additions and 1167 deletions
+187 -25
View File
@@ -1,26 +1,45 @@
import { DEFAULT_TIMEZONE } from "@basango/domain/constants";
import {
Distribution,
Distributions,
ID,
PaginationState,
Publication,
Publications,
Sentiment,
} from "@basango/domain/models";
import { md5 } from "@basango/encryption";
import { count, eq } from "drizzle-orm";
import type { SQL } from "drizzle-orm";
import { count, desc, eq, getTableColumns, sql } from "drizzle-orm";
import { v7 as uuidV7 } from "uuid";
import { Database } from "#db/client";
import { getSourceIdByName } from "#db/queries/sources";
import { ArticleMetadata, Sentiment, TokenStatistics, articles } from "#db/schema";
import { computeReadingTime, computeTokenStatistics } from "#db/utils/computed";
export type CreateArticleParams = {
title: string;
body: string;
categories: string[];
link: string;
sourceId: string;
publishedAt: Date;
sentiment?: Sentiment;
tokenStatistics?: TokenStatistics;
readingTime?: number;
metadata?: ArticleMetadata;
};
import { articles, sources } from "#db/schema";
import { CreateArticleParams, GetArticlesParams } from "#db/types/articles";
import { GetDistributionsParams, GetPublicationsParams } from "#db/types/shared";
import {
applyFilters,
buildDateRange,
buildKeysetFilter,
buildPaginatedResult,
buildPaginationState,
buildPreviousRange,
buildSearchQuery,
computeDelta,
computeReadingTime,
computeTokenStatistics,
} from "#db/utils";
export async function createArticle(db: Database, params: CreateArticleParams) {
const duplicated = await getArticleByHash(db, md5(params.link));
if (duplicated !== undefined) {
return {
id: duplicated.id,
sourceId: duplicated.sourceId,
};
}
const data = {
...params,
hash: md5(params.link),
@@ -34,14 +53,6 @@ export async function createArticle(db: Database, params: CreateArticleParams) {
}),
};
const duplicated = await getArticleByHash(db, data.hash);
if (duplicated !== undefined) {
return {
id: duplicated.id,
sourceId: duplicated.sourceId,
};
}
const [result] = await db
.insert(articles)
.values({ id: uuidV7(), ...data })
@@ -63,7 +74,13 @@ export async function getArticleByHash(db: Database, hash: string) {
});
}
export async function countArticlesBySourceId(db: Database, sourceId: string) {
export async function getArticleById(db: Database, id: ID) {
return await db.query.articles.findFirst({
where: eq(articles.id, id),
});
}
export async function countArticlesBySourceId(db: Database, sourceId: ID) {
const result = await db
.select({ count: count(articles.id) })
.from(articles)
@@ -72,3 +89,148 @@ export async function countArticlesBySourceId(db: Database, sourceId: string) {
return result?.count ?? 0;
}
function buildFilters(params: GetArticlesParams, pagination: PaginationState) {
const filters: SQL<unknown>[] = [];
if (params.sourceId) {
filters.push(eq(articles.sourceId, params.sourceId));
}
if (params.sentiment) {
filters.push(eq(articles.sentiment, params.sentiment as Sentiment));
}
if (params.category) {
filters.push(sql`${params.category} = ANY(${articles.categories})`);
}
if (params.search?.trim()) {
const query = buildSearchQuery(params.search);
if (query) {
filters.push(sql`${articles.tsv} @@ to_tsquery('french', ${query})`);
}
}
const cursorFilter = buildKeysetFilter({
cursor: pagination.payload,
date: articles.publishedAt,
id: articles.id,
});
if (cursorFilter !== undefined) {
filters.push(cursorFilter);
}
return filters;
}
export async function getArticles(db: Database, params: GetArticlesParams) {
const pagination = buildPaginationState(params);
const filters = buildFilters(params, pagination);
const query = db
.select({
...getTableColumns(articles),
source: {
...getTableColumns(sources),
},
})
.from(articles)
.innerJoin(sources, eq(articles.sourceId, sources.id));
const rows = await applyFilters(query, filters)
.orderBy(desc(articles.publishedAt), desc(articles.id))
.limit(pagination.limit + 1);
return buildPaginatedResult(rows, pagination, {
date: "publishedAt",
id: "id",
});
}
export async function getArticlesPublicationGraph(
db: Database,
params: GetPublicationsParams,
): Promise<Publications> {
const [startDate, endDate] = buildDateRange(params.range);
const [previousRangeStart, previousRangeEnd] = buildPreviousRange([startDate, endDate]);
const data = await db.execute<Publication>(sql`
WITH bounds AS (
SELECT
${startDate}::timestamptz AS start_ts,
${endDate}::timestamptz AS end_ts
),
series AS (
SELECT (gs)::date AS d
FROM bounds b,
LATERAL generate_series(
date_trunc('day', timezone(${DEFAULT_TIMEZONE}, b.start_ts)),
date_trunc('day', timezone(${DEFAULT_TIMEZONE}, b.end_ts)),
INTERVAL '1 day'
) AS gs
),
counts AS (
SELECT
a.published_at::date AS d,
COUNT(*)::int AS c
FROM article a, bounds b
WHERE a.published_at >= timezone(${DEFAULT_TIMEZONE}, b.start_ts)
AND a.published_at <= timezone(${DEFAULT_TIMEZONE}, b.end_ts)
GROUP BY 1
)
SELECT
to_char(s.d, 'YYYY-MM-DD') AS date,
COALESCE(c.c, 0) AS count
FROM series s
LEFT JOIN counts c USING (d)
ORDER BY s.d ASC
`);
const [previous] = await db
.execute<{ count: number }>(
sql`
SELECT COALESCE(COUNT(*)::int, 0) AS count
FROM article a
WHERE a.published_at >= timezone(${DEFAULT_TIMEZONE}, ${previousRangeStart})
AND a.published_at <= timezone(${DEFAULT_TIMEZONE}, ${previousRangeEnd})
`,
)
.then((res) => res.rows);
const currentTotal = data.rows.reduce((acc, item) => acc + item.count, 0);
const previousTotal = previous?.count ?? 0;
return {
items: data.rows,
meta: {
current: currentTotal,
delta: computeDelta(currentTotal, previousTotal),
previous: previousTotal,
},
};
}
export async function getArticlesSourceDistribution(
db: Database,
params: GetDistributionsParams,
): Promise<Distributions> {
const data = await db.execute<Distribution>(sql`
SELECT
${sources.id}::text AS id,
${sources.name} AS name,
COUNT(${articles.id})::int AS count,
ROUND((COUNT(*)::numeric / SUM(COUNT(*)) OVER ()) * 100, 2)::float AS percentage
FROM ${articles}
JOIN ${sources} ON ${sources.id} = ${articles.sourceId}
GROUP BY ${sources.id}, ${sources.name}
ORDER BY count DESC
LIMIT ${params.limit ?? 10}
`);
return {
items: data.rows,
total: data.rows.reduce((acc, item) => acc + item.count, 0),
};
}
+25 -77
View File
@@ -1,11 +1,19 @@
import { endOfDay, startOfDay, subDays } from "date-fns";
import { DEFAULT_CATEGORY_SHARES_LIMIT, DEFAULT_TIMEZONE } from "@basango/domain/constants";
import { ID, Publication, Publications } from "@basango/domain/models";
import { eq, sql } from "drizzle-orm";
import { v7 as uuidV7 } from "uuid";
import { Database } from "#db/client";
import { CATEGORY_SHARES_LIMIT, PUBLICATION_GRAPH_DAYS, TIMEZONE } from "#db/constants";
import { NotFoundError } from "#db/errors";
import { Credibility, articles, sources } from "#db/schema";
import { articles, sources } from "#db/schema";
import {
CategoryShare,
CategoryShares,
GetCategorySharesParams,
GetPublicationsParams,
} from "#db/types/shared";
import { CreateSourceParams, UpdateSourceParams } from "#db/types/sources";
import { buildDateRange } from "#db/utils";
import { countArticlesBySourceId } from "./articles";
@@ -21,14 +29,6 @@ export async function getSources(db: Database) {
);
}
export type CreateSourceParams = {
name: string;
url: string;
displayName?: string;
description?: string;
credibility?: Credibility;
};
export async function createSource(db: Database, params: CreateSourceParams) {
const [result] = await db
.insert(sources)
@@ -38,14 +38,6 @@ export async function createSource(db: Database, params: CreateSourceParams) {
return result;
}
export type UpdateSourceParams = {
id: string;
name?: string;
displayName?: string;
description?: string;
credibility?: Credibility;
};
export async function updateSource(db: Database, params: UpdateSourceParams) {
const [result] = await db
.update(sources)
@@ -65,12 +57,8 @@ export async function updateSource(db: Database, params: UpdateSourceParams) {
return result;
}
export type DeleteSourceParams = {
id: string;
};
export async function deleteSource(db: Database, params: DeleteSourceParams) {
const [result] = await db.delete(sources).where(eq(sources.id, params.id)).returning();
export async function deleteSource(db: Database, id: ID) {
const [result] = await db.delete(sources).where(eq(sources.id, id)).returning();
return result;
}
@@ -81,7 +69,7 @@ export async function getSourceByName(db: Database, name: string) {
});
}
export async function getSourceById(db: Database, id: string) {
export async function getSourceById(db: Database, id: ID) {
const item = await db.query.sources.findFirst({
where: eq(sources.id, id),
});
@@ -108,48 +96,13 @@ export async function getSourceIdByName(db: Database, name: string): Promise<str
return result.id;
}
export type GetSourceByIdParams = {
id: string;
};
export type PublicationEntry = {
date: string;
count: number;
};
export type PublicationGraph = {
items: PublicationEntry[];
total: number;
};
export type CategoryShare = {
category: string;
count: number;
percentage: number;
};
export type CategoryShares = {
items: CategoryShare[];
total: number;
};
export type GetSourcePublicationGraphParams = {
id: string;
days?: number;
range?: {
from: Date;
to: Date;
};
};
export async function getSourcePublicationGraph(
db: Database,
params: GetSourcePublicationGraphParams,
): Promise<PublicationGraph> {
const endDate = endOfDay(new Date());
const startDate = startOfDay(subDays(endDate, params.days ?? PUBLICATION_GRAPH_DAYS - 1));
params: GetPublicationsParams,
): Promise<Publications> {
const [startDate, endDate] = buildDateRange(params.range);
const data = await db.execute<PublicationEntry>(sql`
const data = await db.execute<Publication>(sql`
WITH bounds AS (
SELECT
${startDate}::timestamptz AS start_ts,
@@ -159,8 +112,8 @@ export async function getSourcePublicationGraph(
SELECT (gs)::date AS d
FROM bounds b,
LATERAL generate_series(
date_trunc('day', timezone(${TIMEZONE}, b.start_ts)),
date_trunc('day', timezone(${TIMEZONE}, b.end_ts)),
date_trunc('day', timezone(${DEFAULT_TIMEZONE}, b.start_ts)),
date_trunc('day', timezone(${DEFAULT_TIMEZONE}, b.end_ts)),
INTERVAL '1 day'
) AS gs
),
@@ -170,8 +123,8 @@ export async function getSourcePublicationGraph(
COUNT(*)::int AS c
FROM article a, bounds b
WHERE a.source_id = ${params.id}::uuid
AND a.published_at >= timezone(${TIMEZONE}, b.start_ts)
AND a.published_at <= timezone(${TIMEZONE}, b.end_ts)
AND a.published_at >= timezone(${DEFAULT_TIMEZONE}, b.start_ts)
AND a.published_at <= timezone(${DEFAULT_TIMEZONE}, b.end_ts)
GROUP BY 1
)
SELECT
@@ -182,17 +135,12 @@ export async function getSourcePublicationGraph(
ORDER BY s.d ASC
`);
return { items: data.rows, total: data.rows.length };
return { items: data.rows };
}
export type GetSourceCategorySharesParams = {
id: string;
limit?: number;
};
export async function getSourceCategoryShares(
db: Database,
params: GetSourceCategorySharesParams,
params: GetCategorySharesParams,
): Promise<CategoryShares> {
const data = await db.execute<CategoryShare>(sql`
SELECT
@@ -208,7 +156,7 @@ export async function getSourceCategoryShares(
WHERE cat IS NOT NULL
GROUP BY cat
ORDER BY count DESC
LIMIT ${params.limit ?? CATEGORY_SHARES_LIMIT}
LIMIT ${params.limit ?? DEFAULT_CATEGORY_SHARES_LIMIT}
`);
return { items: data.rows, total: data.rowCount ?? 0 };