Files
EpicNext-Cms/src/lib/services/news-detail.ts
T
Simo f7b9b55700
CI / check (push) Failing after 29s
CI / deploy (push) Skipped
CI / publish-container (push) Skipped
fix(news): preserve recoverable reads and verify publication against real services
2026-09-13 19:31:32 +02:00

141 lines
4.0 KiB
TypeScript

import "server-only";
import { and, asc, count, eq, or, sql } from "drizzle-orm";
import {
db,
User,
WebsiteArticleComments,
WebsiteArticleReactions,
WebsiteArticles,
} from "@/lib/db";
import { cacheNews } from "@/lib/services/news-cache";
/** Null means a successful lookup found no public article. Failed reads must reject. */
export async function getPublishedArticle(slug: string) {
const cached = await cacheNews(
`article:v2:slug:${slug}`,
60_000,
async () => {
const [article] = await db
.select({
id: WebsiteArticles.id,
slug: WebsiteArticles.slug,
title: WebsiteArticles.title,
shortStory: WebsiteArticles.shortStory,
fullStory: WebsiteArticles.fullStory,
image: WebsiteArticles.image,
createdAt: WebsiteArticles.createdAt,
updatedAt: WebsiteArticles.updatedAt,
publishAt: WebsiteArticles.publishAt,
publishedAt: WebsiteArticles.publishedAt,
})
.from(WebsiteArticles)
.where(
and(
eq(WebsiteArticles.slug, slug),
eq(WebsiteArticles.status, "published"),
or(
sql`${WebsiteArticles.publishAt} IS NULL`,
sql`${WebsiteArticles.publishAt} <= NOW()`,
),
),
)
.limit(1);
if (!article) return null;
// Redis uses JSON: preserve full bigint precision and restore dates on reads.
return {
...article,
id: String(article.id),
createdAt: article.createdAt?.toISOString() ?? null,
updatedAt: article.updatedAt?.toISOString() ?? null,
publishAt: article.publishAt?.toISOString() ?? null,
publishedAt: article.publishedAt?.toISOString() ?? null,
};
},
);
if (!cached) return null;
return {
...cached,
id: BigInt(cached.id),
createdAt: cached.createdAt ? new Date(cached.createdAt) : null,
updatedAt: cached.updatedAt ? new Date(cached.updatedAt) : null,
publishAt: cached.publishAt ? new Date(cached.publishAt) : null,
publishedAt: cached.publishedAt ? new Date(cached.publishedAt) : null,
};
}
export type PublishedArticle = NonNullable<
Awaited<ReturnType<typeof getPublishedArticle>>
>;
export async function getArticleComments(
articleId: bigint,
requestedPage: number | "last" = 1,
) {
const where = eq(WebsiteArticleComments.articleId, articleId);
const [totals] = await db
.select({ value: count() })
.from(WebsiteArticleComments)
.where(where);
const total = Number(totals?.value ?? 0);
const perPage = 25;
const lastPage = Math.max(1, Math.ceil(total / perPage));
const page =
requestedPage === "last"
? lastPage
: Math.min(
lastPage,
Number.isFinite(requestedPage)
? Math.max(1, Math.trunc(requestedPage))
: 1,
);
const rows = await db
.select({
id: WebsiteArticleComments.id,
userId: WebsiteArticleComments.userId,
comment: WebsiteArticleComments.comment,
createdAt: WebsiteArticleComments.createdAt,
username: User.username,
})
.from(WebsiteArticleComments)
.leftJoin(User, eq(User.id, WebsiteArticleComments.userId))
.where(where)
.orderBy(
asc(WebsiteArticleComments.createdAt),
asc(WebsiteArticleComments.id),
)
.limit(perPage)
.offset((page - 1) * perPage);
return { rows, total, page, perPage, lastPage };
}
export type ArticleComments = Awaited<ReturnType<typeof getArticleComments>>;
export async function getArticleReactions(
articleId: bigint,
userId: number | null,
) {
const rows = await db
.select({
reaction: WebsiteArticleReactions.reaction,
count: count(),
mine:
userId === null
? sql<number>`0`
: sql<number>`MAX(CASE WHEN ${WebsiteArticleReactions.userId} = ${userId} THEN 1 ELSE 0 END)`,
})
.from(WebsiteArticleReactions)
.where(
and(
eq(WebsiteArticleReactions.articleId, articleId),
eq(WebsiteArticleReactions.active, true),
),
)
.groupBy(WebsiteArticleReactions.reaction);
return {
counts: new Map(rows.map((row) => [row.reaction, Number(row.count)])),
myReaction: rows.find((row) => Number(row.mine) > 0)?.reaction ?? null,
};
}
export type ArticleReactions = Awaited<ReturnType<typeof getArticleReactions>>;