feat(dashboard): more type safety

This commit is contained in:
2025-11-17 01:26:33 +02:00
parent f39635e04f
commit 22aab9ffc6
25 changed files with 120 additions and 71 deletions
+3 -5
View File
@@ -31,11 +31,9 @@ export const sourcesRouter = createTRPCRouter({
return getSourceCategoryShares(ctx.db, input); return getSourceCategoryShares(ctx.db, input);
}), }),
getPublicationGraph: protectedProcedure getPublications: protectedProcedure.input(getPublicationsSchema).query(async ({ ctx, input }) => {
.input(getPublicationsSchema) return getSourcePublicationGraph(ctx.db, input);
.query(async ({ ctx, input }) => { }),
return getSourcePublicationGraph(ctx.db, input);
}),
list: protectedProcedure.query(async ({ ctx }) => getSources(ctx.db)), list: protectedProcedure.query(async ({ ctx }) => getSources(ctx.db)),
+7
View File
@@ -1,4 +1,11 @@
{ {
"compilerOptions": {
"paths": {
"#api/*": ["./src/*"],
"#db/*": ["../../packages/db/src/*"],
"#domain/*": ["../../packages/domain/src/*"]
}
},
"extends": "@basango/tsconfig/base.json", "extends": "@basango/tsconfig/base.json",
"include": ["src"] "include": ["src"]
} }
+6
View File
@@ -1,4 +1,10 @@
{ {
"compilerOptions": {
"paths": {
"#crawler/*": ["./src/*"],
"#domain/*": ["../../packages/domain/src/*"]
}
},
"extends": "@basango/tsconfig/base.json", "extends": "@basango/tsconfig/base.json",
"include": ["src"] "include": ["src"]
} }
@@ -1,9 +1,9 @@
import { Source } from "@basango/domain/models/sources";
import { Button } from "@basango/ui/components/button"; import { Button } from "@basango/ui/components/button";
import { PlusIcon } from "lucide-react"; import { PlusIcon } from "lucide-react";
import { Metadata } from "next"; import { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { RouterOutputs } from "#api/trpc/routers/_app";
import { SourceCreateModal } from "#dashboard/components/modals/source-create-modal"; import { SourceCreateModal } from "#dashboard/components/modals/source-create-modal";
import { PageLayout } from "#dashboard/components/shell/page-layout"; import { PageLayout } from "#dashboard/components/shell/page-layout";
import { SourceCard } from "#dashboard/components/source-card"; import { SourceCard } from "#dashboard/components/source-card";
@@ -13,11 +13,13 @@ export const metadata: Metadata = {
title: "Sources | Basango Dashboard", title: "Sources | Basango Dashboard",
}; };
type Source = RouterOutputs["sources"]["list"][number];
export default async function Page() { export default async function Page() {
const queryClient = getQueryClient(); const queryClient = getQueryClient();
prefetch(trpc.sources.get.queryOptions()); prefetch(trpc.sources.list.queryOptions());
const sources: Source[] = await queryClient.fetchQuery(trpc.sources.get.queryOptions()); const sources = await queryClient.fetchQuery(trpc.sources.list.queryOptions());
return ( return (
<HydrateClient> <HydrateClient>
+9 -15
View File
@@ -23,18 +23,8 @@ type ArticleCardProps = {
article: Article; article: Article;
}; };
function getDescription(article: Article) {
return (
article.metadata?.description ??
article.excerpt ??
"No description was provided for this article."
);
}
export function ArticleCard({ article }: ArticleCardProps) { export function ArticleCard({ article }: ArticleCardProps) {
const [copied, setCopied] = React.useState(false); const [copied, setCopied] = React.useState(false);
const description = getDescription(article);
const imageUrl = article.image ?? undefined;
const copyLink = React.useCallback(async () => { const copyLink = React.useCallback(async () => {
try { try {
@@ -50,12 +40,12 @@ export function ArticleCard({ article }: ArticleCardProps) {
<Card className="flex h-full flex-col overflow-hidden border border-border/80 p-0"> <Card className="flex h-full flex-col overflow-hidden border border-border/80 p-0">
<CardHeader className="relative h-40 overflow-hidden p-0"> <CardHeader className="relative h-40 overflow-hidden p-0">
<div className="relative h-full w-full bg-muted"> <div className="relative h-full w-full bg-muted">
{imageUrl ? ( {article.image ? (
<img <img
alt={article.title} alt={article.title}
className="h-full w-full object-cover transition duration-200 hover:scale-105" className="h-full w-full object-cover transition duration-200 hover:scale-105"
loading="lazy" loading="lazy"
src={imageUrl} src={article.image}
/> />
) : ( ) : (
<div className="flex h-full w-full items-center justify-center text-xs text-muted-foreground"> <div className="flex h-full w-full items-center justify-center text-xs text-muted-foreground">
@@ -63,7 +53,7 @@ export function ArticleCard({ article }: ArticleCardProps) {
</div> </div>
)} )}
<div className="absolute left-3 top-3"> <div className="absolute left-3 top-3">
<Badge variant="secondary">{article.sourceName}</Badge> <Badge variant="secondary">{article.source?.name}</Badge>
</div> </div>
<div className="absolute right-3 top-3"> <div className="absolute right-3 top-3">
<DropdownMenu> <DropdownMenu>
@@ -103,14 +93,18 @@ export function ArticleCard({ article }: ArticleCardProps) {
{article.title} {article.title}
</Link> </Link>
</CardTitle> </CardTitle>
<p className="text-sm text-muted-foreground line-clamp-3">{description}</p> <p className="text-sm text-muted-foreground line-clamp-3">
{article.metadata?.description ??
article.excerpt ??
"No description was provided for this article."}
</p>
</CardContent> </CardContent>
<CardFooter className="flex items-center justify-between gap-2 px-4 py-3 text-xs text-muted-foreground"> <CardFooter className="flex items-center justify-between gap-2 px-4 py-3 text-xs text-muted-foreground">
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium text-foreground"> <span className="font-medium text-foreground">
{formatDate(article.publishedAt.toISOString(), "PP", false)} {formatDate(article.publishedAt.toISOString(), "PP", false)}
</span> </span>
<span>{formatRelativeTime(new Date(article.publishedAt))}</span> <span>{formatRelativeTime(article.publishedAt)}</span>
</div> </div>
<span>{article.readingTime} min</span> <span>{article.readingTime} min</span>
</CardFooter> </CardFooter>
@@ -5,11 +5,11 @@ import { Area, AreaChart as BaseAreachart, CartesianGrid, XAxis, YAxis } from "r
import { formatDate, formatNumber } from "#dashboard/utils/utils"; import { formatDate, formatNumber } from "#dashboard/utils/utils";
type AreaChartProps = { type AreaChartProps<T> = {
data: unknown; data: T[];
}; };
export function AreaChart({ data }: AreaChartProps) { export function AreaChart<T>({ data }: AreaChartProps<T>) {
return ( return (
<BaseAreachart accessibilityLayer data={data}> <BaseAreachart accessibilityLayer data={data}>
<CartesianGrid strokeDasharray="3 3" vertical={false} /> <CartesianGrid strokeDasharray="3 3" vertical={false} />
@@ -1,4 +1,3 @@
// @ts-nocheck
"use client"; "use client";
import { import {
@@ -33,7 +32,7 @@ export function PublicationGraphChart() {
const period = useChartPeriodFilter(); const period = useChartPeriodFilter();
const { data } = useQuery( const { data } = useQuery(
trpc.articles.getPublicationGraph.queryOptions({ trpc.articles.getPublications.queryOptions({
range: period.range, range: period.range,
}), }),
); );
@@ -45,19 +44,19 @@ export function PublicationGraphChart() {
<CardTitle>{formatNumber(data?.meta?.current)} articles</CardTitle> <CardTitle>{formatNumber(data?.meta?.current)} articles</CardTitle>
<CardDescription> <CardDescription>
<div className="flex items-center justify-start gap-1 text-xs"> <div className="flex items-center justify-start gap-1 text-xs">
<Status value={data?.delta} /> <Status value={data?.meta?.delta} />
<span className="text-muted-foreground">vs previous</span> <span className="text-muted-foreground">vs previous</span>
</div> </div>
</CardDescription> </CardDescription>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<ChartPeriodPicker defaultDays={period.defaultDays} paramKey="articlesPeriod" /> <ChartPeriodPicker defaultDays={period.defaultDays} />
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6"> <CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
<ChartContainer className="aspect-auto h-[250px] w-full" config={chartConfig}> <ChartContainer className="aspect-auto h-[250px] w-full" config={chartConfig}>
<AreaChart data={data?.items} /> <AreaChart data={data?.items ?? []} />
</ChartContainer> </ChartContainer>
</CardContent> </CardContent>
@@ -68,7 +67,7 @@ export function PublicationGraphChart() {
<span className="font-semibold text-foreground"> <span className="font-semibold text-foreground">
{formatNumber(data?.meta?.current)} vs {formatNumber(data?.meta?.previous)} articles {formatNumber(data?.meta?.current)} vs {formatNumber(data?.meta?.previous)} articles
</span> </span>
<Status icons={false} percentage={true} value={data?.deltaPercentage} /> <Status icons={false} percentage={true} value={data?.meta?.delta} />
<span className="text-muted-foreground">period</span> <span className="text-muted-foreground">period</span>
{data?.meta?.previous === 0 && data?.meta?.current === 0 && ( {data?.meta?.previous === 0 && data?.meta?.current === 0 && (
<span className="text-muted-foreground">(no articles yet)</span> <span className="text-muted-foreground">(no articles yet)</span>
@@ -1,4 +1,3 @@
// @ts-nocheck
"use client"; "use client";
import { import {
@@ -5,11 +5,11 @@ import { Bar, BarChart as BaseBarChart, CartesianGrid, XAxis } from "recharts";
import { formatDate } from "#dashboard/utils/utils"; import { formatDate } from "#dashboard/utils/utils";
type BarChartProps = { type BarChartProps<T> = {
data: unknown; data: T[];
}; };
export function BarChart({ data }: BarChartProps) { export function BarChart<T>({ data }: BarChartProps<T>) {
return ( return (
<BaseBarChart accessibilityLayer data={data}> <BaseBarChart accessibilityLayer data={data}>
<CartesianGrid vertical={false} /> <CartesianGrid vertical={false} />
@@ -25,8 +25,6 @@ const DEFAULT_PERIOD_OPTIONS = [
{ label: "Last 12 months", value: 365 }, { label: "Last 12 months", value: 365 },
] as const; ] as const;
type DateInput = number | Date | null | undefined;
const createRangeFromDays = (days: number): DateRange => { const createRangeFromDays = (days: number): DateRange => {
const end = new Date(); const end = new Date();
@@ -75,7 +73,7 @@ export function useChartPeriodFilter(options: ChartPeriodFilterOptions = {}) {
return undefined; return undefined;
}, [from, to]); }, [from, to]);
const range = useMemo(() => { const calendarRange: DateRange | undefined = useMemo(() => {
if (from && to) { if (from && to) {
return { from, to }; return { from, to };
} }
@@ -83,7 +81,10 @@ export function useChartPeriodFilter(options: ChartPeriodFilterOptions = {}) {
return defaultRange; return defaultRange;
}, [defaultRange, from, to]); }, [defaultRange, from, to]);
const range = useMemo(() => formatDomainDateRange(calendarRange), [calendarRange]);
return { return {
calendarRange,
defaultDays, defaultDays,
keys: { fromKey, toKey }, keys: { fromKey, toKey },
range, range,
@@ -118,19 +119,22 @@ export function ChartPeriodPicker({
paramKey = "chartPeriod", paramKey = "chartPeriod",
disabled, disabled,
}: ChartPeriodPickerProps & { disabled?: boolean }) { }: ChartPeriodPickerProps & { disabled?: boolean }) {
const { range, selectedRange, keys, setState } = useChartPeriodFilter({ defaultDays, paramKey }); const { calendarRange, selectedRange, keys, setState } = useChartPeriodFilter({
defaultDays,
paramKey,
});
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const selectValue = useMemo(() => { const selectValue = useMemo(() => {
if (!range?.from || !range?.to) { if (!calendarRange?.from || !calendarRange?.to) {
return "custom"; return "custom";
} }
const diff = differenceInCalendarDays(range.to, range.from) + 1; const diff = differenceInCalendarDays(calendarRange.to, calendarRange.from) + 1;
const match = options.find((option) => option.value === diff); const match = options.find((option) => option.value === diff);
return match ? String(match.value) : "custom"; return match ? String(match.value) : "custom";
}, [options, range]); }, [calendarRange, options]);
const handlePresetChange = (value: string) => { const handlePresetChange = (value: string) => {
if (value === "custom") { if (value === "custom") {
@@ -160,7 +164,7 @@ export function ChartPeriodPicker({
}; };
const displayLabel = const displayLabel =
formatDateRange(range) ?? formatDateRange(calendarRange) ??
options.find((option) => String(option.value) === selectValue)?.label ?? options.find((option) => String(option.value) === selectValue)?.label ??
"Select range"; "Select range";
@@ -196,7 +200,7 @@ export function ChartPeriodPicker({
mode="range" mode="range"
numberOfMonths={2} numberOfMonths={2}
onSelect={handleCalendarSelect} onSelect={handleCalendarSelect}
selected={(selectedRange ?? range) as DateRange | undefined} selected={(selectedRange ?? calendarRange) as DateRange | undefined}
/> />
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
@@ -252,8 +256,14 @@ export function ChartLimitToggle({
); );
} }
function formatDateRange(range?: { from?: DateInput; to?: DateInput }) { function formatDateRange(range?: DateRange) {
if (!range?.from || !range?.to) return null; if (!range?.from || !range?.to) return null;
return `${format(range.from, "MMM d, yyyy")} - ${format(range.to, "MMM d, yyyy")}`; return `${format(range.from, "MMM d, yyyy")} - ${format(range.to, "MMM d, yyyy")}`;
} }
function formatDomainDateRange(range?: DateRange) {
if (!range?.from || !range?.to) return undefined;
return { end: range.to, start: range.from };
}
@@ -1,4 +1,3 @@
// @ts-nocheck
"use client"; "use client";
import { import {
@@ -11,6 +10,7 @@ import {
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Bar, BarChart, Legend, ResponsiveContainer, XAxis, YAxis } from "recharts"; import { Bar, BarChart, Legend, ResponsiveContainer, XAxis, YAxis } from "recharts";
import { RouterOutputs } from "#api/trpc/routers/_app";
import { ChartLimitToggle, useChartLimitFilter } from "#dashboard/components/charts/chart-filters"; import { ChartLimitToggle, useChartLimitFilter } from "#dashboard/components/charts/chart-filters";
import { useTRPC } from "#dashboard/trpc/client"; import { useTRPC } from "#dashboard/trpc/client";
import { getColorFromName } from "#dashboard/utils/categories"; import { getColorFromName } from "#dashboard/utils/categories";
@@ -19,6 +19,8 @@ type Props = {
sourceId: string; sourceId: string;
}; };
type CategoryShare = RouterOutputs["sources"]["getCategoryShares"]["items"][number];
export function CategorySharesChart({ sourceId }: Props) { export function CategorySharesChart({ sourceId }: Props) {
const trpc = useTRPC(); const trpc = useTRPC();
const { limit } = useChartLimitFilter(); const { limit } = useChartLimitFilter();
@@ -29,11 +31,12 @@ export function CategorySharesChart({ sourceId }: Props) {
limit, limit,
}), }),
); );
const items: CategoryShare[] = data?.items || [];
const chartData = [ const chartData = [
{ {
name: "Total", name: "Total",
...Object.fromEntries(data?.items.map((item) => [item.category, item.count])), ...Object.fromEntries(items.map((item) => [item.category, item.count])),
}, },
]; ];
@@ -44,7 +47,7 @@ export function CategorySharesChart({ sourceId }: Props) {
<CardTitle>Category Shares</CardTitle> <CardTitle>Category Shares</CardTitle>
<CardDescription>showing top {limit} categories for this source</CardDescription> <CardDescription>showing top {limit} categories for this source</CardDescription>
</div> </div>
<ChartLimitToggle paramKey={`categoryLimit-${sourceId}`} /> <ChartLimitToggle />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="-ml-1 h-20"> <div className="-ml-1 h-20">
@@ -1,4 +1,3 @@
// @ts-nocheck
"use client"; "use client";
import { import {
@@ -37,7 +36,7 @@ export function PublicationGraphChart({ sourceId }: Props) {
const period = useChartPeriodFilter(); const period = useChartPeriodFilter();
const { data } = useQuery( const { data } = useQuery(
trpc.sources.getPublicationGraph.queryOptions({ trpc.sources.getPublications.queryOptions({
id: sourceId, id: sourceId,
range: period.range, range: period.range,
}), }),
@@ -50,11 +49,11 @@ export function PublicationGraphChart({ sourceId }: Props) {
<CardTitle>Publication Graph</CardTitle> <CardTitle>Publication Graph</CardTitle>
<CardDescription>Showing total crawled articles for the selected period</CardDescription> <CardDescription>Showing total crawled articles for the selected period</CardDescription>
</div> </div>
<ChartPeriodPicker defaultDays={period.defaultDays} paramKey={`sourcePeriod-${sourceId}`} /> <ChartPeriodPicker defaultDays={period.defaultDays} />
</CardHeader> </CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6"> <CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
<ChartContainer className="aspect-auto h-[250px] w-full" config={chartConfig}> <ChartContainer className="aspect-auto h-[250px] w-full" config={chartConfig}>
<AreaChart data={data?.items} /> <AreaChart data={data?.items ?? []} />
</ChartContainer> </ChartContainer>
</CardContent> </CardContent>
</Card> </Card>
@@ -1,7 +1,7 @@
"use client"; "use client";
import type { RouterOutputs } from "@basango/api/trpc/routers/_app"; import type { RouterOutputs } from "@basango/api/trpc/routers/_app";
import { updateSourceSchema } from "@basango/domain/models/sources"; import { updateSourceSchema } from "@basango/domain/models";
import { import {
Field, Field,
FieldDescription, FieldDescription,
@@ -1,6 +1,6 @@
"use client"; "use client";
import { createSourceSchema } from "@basango/domain/models/sources"; import { createSourceSchema } from "@basango/domain/models";
import { import {
Field, Field,
FieldDescription, FieldDescription,
@@ -1,6 +1,5 @@
"use client"; "use client";
import { Source } from "@basango/domain/models/sources";
import { import {
Card, Card,
CardContent, CardContent,
@@ -17,6 +16,7 @@ import {
} from "@basango/ui/components/chart"; } from "@basango/ui/components/chart";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"; import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { RouterOutputs } from "#api/trpc/routers/_app";
import { formatDate, formatNumber } from "#dashboard/utils/utils"; import { formatDate, formatNumber } from "#dashboard/utils/utils";
const chartConfig = { const chartConfig = {
@@ -29,7 +29,11 @@ const chartConfig = {
}, },
} satisfies ChartConfig; } satisfies ChartConfig;
export function SourceCard({ source }: { source: Source }) { type Props = {
source: RouterOutputs["sources"]["list"][number];
};
export function SourceCard({ source }: Props) {
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
+6
View File
@@ -1,5 +1,11 @@
{ {
"compilerOptions": { "compilerOptions": {
"paths": {
"#api/*": ["../api/src/*"],
"#dashboard/*": ["./src/*"],
"#db/*": ["../../packages/db/src/*"],
"#domain/*": ["../../packages/domain/src/*"]
},
"plugins": [ "plugins": [
{ {
"name": "next" "name": "next"
+2
View File
@@ -35,9 +35,11 @@
"private": true, "private": true,
"scripts": { "scripts": {
"android": "expo start --android", "android": "expo start --android",
"clean": "rm -rf .expo node_modules",
"ios": "expo start --ios", "ios": "expo start --ios",
"lint": "expo lint", "lint": "expo lint",
"start": "expo start", "start": "expo start",
"typecheck": "tsc --noEmit",
"web": "expo start --web" "web": "expo start --web"
}, },
"version": "1.0.0" "version": "1.0.0"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"paths": { "paths": {
"#mobile/*": ["./*"] "#mobile/*": ["./src/*"]
}, },
"strict": true "strict": true
}, },
+5 -4
View File
@@ -1,5 +1,6 @@
import { DEFAULT_TIMEZONE } from "@basango/domain/constants"; import { DEFAULT_TIMEZONE } from "@basango/domain/constants";
import { import {
Article,
Distribution, Distribution,
Distributions, Distributions,
ID, ID,
@@ -143,7 +144,7 @@ export async function getArticles(db: Database, params: GetArticlesParams) {
.orderBy(desc(articles.publishedAt), desc(articles.id)) .orderBy(desc(articles.publishedAt), desc(articles.id))
.limit(pagination.limit + 1); .limit(pagination.limit + 1);
return buildPaginatedResult(rows, pagination, { return buildPaginatedResult<Article>(rows, pagination, {
date: "publishedAt", date: "publishedAt",
id: "id", id: "id",
}); });
@@ -154,7 +155,7 @@ export async function getArticlesPublicationGraph(
params: GetPublicationsParams, params: GetPublicationsParams,
): Promise<Publications> { ): Promise<Publications> {
const [startDate, endDate] = buildDateRange(params.range); const [startDate, endDate] = buildDateRange(params.range);
const [previousRangeStart, previousRangeEnd] = buildPreviousRange([startDate, endDate]); const [previousStart, previousEnd] = buildPreviousRange([startDate, endDate]);
const data = await db.execute<Publication>(sql` const data = await db.execute<Publication>(sql`
WITH bounds AS ( WITH bounds AS (
@@ -193,8 +194,8 @@ export async function getArticlesPublicationGraph(
sql` sql`
SELECT COALESCE(COUNT(*)::int, 0) AS count SELECT COALESCE(COUNT(*)::int, 0) AS count
FROM article a FROM article a
WHERE a.published_at >= timezone(${DEFAULT_TIMEZONE}, ${previousRangeStart}) WHERE a.published_at >= timezone(${DEFAULT_TIMEZONE}, ${previousStart})
AND a.published_at <= timezone(${DEFAULT_TIMEZONE}, ${previousRangeEnd}) AND a.published_at <= timezone(${DEFAULT_TIMEZONE}, ${previousEnd})
`, `,
) )
.then((res) => res.rows); .then((res) => res.rows);
+1 -1
View File
@@ -24,7 +24,7 @@ export async function getSources(db: Database) {
rows.map(async (row) => ({ rows.map(async (row) => ({
...row, ...row,
articles: await countArticlesBySourceId(db, row.id), articles: await countArticlesBySourceId(db, row.id),
publicationGraph: await getSourcePublicationGraph(db, { id: row.id }), publications: await getSourcePublicationGraph(db, { id: row.id }),
})), })),
); );
} }
+6
View File
@@ -1,4 +1,10 @@
{ {
"compilerOptions": {
"paths": {
"#db/*": ["./src/*"],
"#domain/*": ["../domain/src/*"]
}
},
"exclude": ["node_modules"], "exclude": ["node_modules"],
"extends": "@basango/tsconfig/base.json", "extends": "@basango/tsconfig/base.json",
"include": ["src"] "include": ["src"]
+15
View File
@@ -2,6 +2,8 @@ import { z } from "@hono/zod-openapi";
import { idSchema, sentimentSchema } from "#domain/models/shared"; import { idSchema, sentimentSchema } from "#domain/models/shared";
import { sourceSchema } from "./sources";
// schemas // schemas
export const articleMetadataSchema = z.object({ export const articleMetadataSchema = z.object({
author: z.string().optional().openapi({ author: z.string().optional().openapi({
@@ -70,11 +72,19 @@ export const articleSchema = z.object({
description: "The date and time when the article was created in the system.", description: "The date and time when the article was created in the system.",
example: "2023-01-01T12:00:00Z", example: "2023-01-01T12:00:00Z",
}), }),
excerpt: z.string().optional().openapi({
description: "A brief excerpt or summary of the article.",
example: "This article discusses the latest advancements in AI technology.",
}),
hash: z.string().min(1).openapi({ hash: z.string().min(1).openapi({
description: "The unique hash of the article link.", description: "The unique hash of the article link.",
example: "d41d8cd98f00b204e9800998ecf8427e", example: "d41d8cd98f00b204e9800998ecf8427e",
}), }),
id: idSchema, id: idSchema,
image: z.url().optional().openapi({
description: "The URL of the main image associated with the article.",
example: "https://example.com/image.jpg",
}),
link: z.string().url().openapi({ link: z.string().url().openapi({
description: "The URL of the article.", description: "The URL of the article.",
example: "https://example.com/article", example: "https://example.com/article",
@@ -84,6 +94,11 @@ export const articleSchema = z.object({
description: "The publication date of the article as a Date object.", description: "The publication date of the article as a Date object.",
example: "2023-01-01T00:00:00Z", example: "2023-01-01T00:00:00Z",
}), }),
readingTime: z.number().int().min(1).openapi({
description: "Estimated reading time of the article in minutes.",
example: 5,
}),
source: sourceSchema.optional(),
sourceId: z.union([z.uuid(), z.string().min(1)]).openapi({ sourceId: z.union([z.uuid(), z.string().min(1)]).openapi({
description: "The unique identifier of the source from which the article was crawled.", description: "The unique identifier of the source from which the article was crawled.",
example: "b3e1c8f4-5d6a-4c9e-8f1e-2d3c4b5a6f7g", example: "b3e1c8f4-5d6a-4c9e-8f1e-2d3c4b5a6f7g",
+5
View File
@@ -1,4 +1,9 @@
{ {
"compilerOptions": {
"paths": {
"#domain/*": ["./src/*"]
}
},
"exclude": ["node_modules"], "exclude": ["node_modules"],
"extends": "@basango/tsconfig/base.json", "extends": "@basango/tsconfig/base.json",
"include": ["src"] "include": ["src"]
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"exclude": ["node_modules"], "exclude": ["node_modules"],
"extends": "@basango/tsconfig/base.json", "extends": "@basango/tsconfig/base.json",
"include": ["src/**/*"] "include": ["src"]
} }
-7
View File
@@ -11,13 +11,6 @@
"moduleDetection": "force", "moduleDetection": "force",
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"paths": {
"#api/*": ["../../apps/api/src/*"],
"#crawler/*": ["../../apps/crawler/src/*"],
"#dashboard/*": ["../../apps/dashboard/src/*"],
"#db/*": ["../db/src/*"],
"#domain/*": ["../domain/src/*"]
},
"resolveJsonModule": true, "resolveJsonModule": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,