Files
basango/packages/db/src/queries/sources.ts
T

216 lines
5.1 KiB
TypeScript

import { endOfDay, startOfDay, subDays } from "date-fns";
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 { countArticlesBySourceId } from "./articles";
export async function getSources(db: Database) {
const rows = await db.query.sources.findMany();
return await Promise.all(
rows.map(async (row) => ({
...row,
articles: await countArticlesBySourceId(db, row.id),
publicationGraph: await getSourcePublicationGraph(db, { id: row.id }),
})),
);
}
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)
.values({ id: uuidV7(), ...params })
.returning();
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)
.set({
credibility: params.credibility,
description: params.description,
displayName: params.displayName,
name: params.name,
})
.where(eq(sources.id, params.id))
.returning();
if (result === undefined) {
throw new NotFoundError(`Source not found`);
}
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();
return result;
}
export async function getSourceByName(db: Database, name: string) {
return await db.query.sources.findFirst({
where: eq(sources.name, name),
});
}
export async function getSourceById(db: Database, id: string) {
const item = await db.query.sources.findFirst({
where: eq(sources.id, id),
});
if (item === undefined) {
throw new NotFoundError("Source not found");
}
return item;
}
export async function getSourceIdByName(db: Database, name: string): Promise<string> {
const result = await db.query.sources.findFirst({
columns: {
id: true,
},
where: eq(sources.name, name),
});
if (!result) {
throw new NotFoundError("Source not found");
}
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));
const data = await db.execute<PublicationEntry>(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(${TIMEZONE}, b.start_ts)),
date_trunc('day', timezone(${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.source_id = ${params.id}::uuid
AND a.published_at >= timezone(${TIMEZONE}, b.start_ts)
AND a.published_at <= timezone(${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
`);
return { items: data.rows, total: data.rows.length };
}
export type GetSourceCategorySharesParams = {
id: string;
limit?: number;
};
export async function getSourceCategoryShares(
db: Database,
params: GetSourceCategorySharesParams,
): Promise<CategoryShares> {
const data = await db.execute<CategoryShare>(sql`
SELECT
cat AS category,
COUNT(*)::int AS count,
ROUND((COUNT(*)::numeric / SUM(COUNT(*)) OVER ()) * 100, 2) AS percentage
FROM (
SELECT NULLIF(BTRIM(c), '') AS cat
FROM ${articles}
CROSS JOIN LATERAL UNNEST(COALESCE(${articles.categories}, ARRAY[]::text[])) AS c
WHERE ${articles.sourceId} = ${params.id}
) t
WHERE cat IS NOT NULL
GROUP BY cat
ORDER BY count DESC
LIMIT ${params.limit ?? CATEGORY_SHARES_LIMIT}
`);
return { items: data.rows, total: data.rowCount ?? 0 };
}