Files
EpicNext-Cms/src/lib/services/news-archive.ts
T
openhands 9f024dc6ac perf(app): batch catalog updates, tune caches, add captcha env fallback
- catalog: apply bulk offer edits in a single transaction with one RCON
  refresh instead of N round-trips, virtualize the items table past 150
  rows and cap the shop preview at 200 tiles
- cache: lift the public cache TTLs and the boot warm-up into env-tunable
  constants (src/lib/perf-ttls.ts; CMS_*_TTL_SEC, CMS_WARMUP_ENABLED)
- auth: resolve the captcha provider, site key and secret from
  CAPTCHA_PROVIDER, TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY when the
  admin settings are unset; an explicit DB value (including "none") still
  wins, so Turnstile can be enabled per-deploy without touching the DB.
2026-10-15 16:00:00 +02:00

113 lines
3.6 KiB
TypeScript

import "server-only";
import { and, asc, count, desc, eq, like, or, sql } from "drizzle-orm";
import { db, WebsiteArticles } from "@/lib/db";
import { NEWS_ARCHIVE_TTL_MS } from "@/lib/perf-ttls";
import { cacheSafe } from "@/lib/redis-cache";
import { cacheNews } from "@/lib/services/news-cache";
/**
* Fresh for a minute, then servable stale for another minute. Both the landing
* page and /search hit this, and the COUNT(*) over a leading-wildcard LIKE is a
* full scan of website_articles — the reason the archive felt slow once the
* table grew. Publishing an article rotates the news revision and drops these
* entries immediately, so the window only applies when pub/sub cannot be
* delivered.
*/
const ARCHIVE_TTL_MS = NEWS_ARCHIVE_TTL_MS;
async function queryNewsArchive(
options: { search?: string; page?: number; order?: string } = {},
) {
const search = (typeof options.search === "string" ? options.search : "")
.trim()
.slice(0, 191);
const order = options.order === "oldest" ? "oldest" : "newest";
const publishedAt =
sql<Date | null>`COALESCE(${WebsiteArticles.publishAt}, ${WebsiteArticles.createdAt})`.mapWith(
WebsiteArticles.createdAt,
);
const where = and(
eq(WebsiteArticles.status, "published"),
or(
sql`${WebsiteArticles.publishAt} IS NULL`,
sql`${WebsiteArticles.publishAt} <= NOW()`,
),
search
? or(
like(WebsiteArticles.title, `%${search}%`),
like(WebsiteArticles.shortStory, `%${search}%`),
)
: undefined,
);
const totals = await db
.select({ value: count() })
.from(WebsiteArticles)
.where(where);
const total = Number(totals[0]?.value ?? 0);
const perPage = 24;
const lastPage = Math.max(1, Math.ceil(total / perPage));
const page = Math.min(
lastPage,
Number.isFinite(options.page)
? Math.max(1, Math.trunc(options.page ?? 1))
: 1,
);
const sort = order === "oldest" ? asc : desc;
const rows = await db
.select({
slug: WebsiteArticles.slug,
title: WebsiteArticles.title,
shortStory: WebsiteArticles.shortStory,
image: WebsiteArticles.image,
createdAt: publishedAt,
})
.from(WebsiteArticles)
.where(where)
.orderBy(sort(publishedAt), sort(WebsiteArticles.id))
.limit(perPage)
.offset((page - 1) * perPage);
// cacheSafe() flattens the Date into the same ISO string on both cache
// paths. Without it a Redis hit would hand callers a string where a miss
// hands them a Date, and the archive grid formats this field directly.
return cacheSafe({ rows, total, page, perPage, lastPage, search, order });
}
/**
* Keyed on the REQUESTED page rather than the clamped one, so two requests that
* clamp onto the same page cannot alias each other's entry.
*/
function archiveKey(options: {
search?: string;
page?: number;
order?: string;
}): string {
const search = (typeof options.search === "string" ? options.search : "")
.trim()
.slice(0, 191);
const order = options.order === "oldest" ? "oldest" : "newest";
const page = Number.isFinite(options.page)
? Math.trunc(options.page ?? 1)
: 1;
return `news:archive:${order}:${page}:${search}`;
}
export async function loadNewsArchive(
options: { search?: string; page?: number; order?: string } = {},
) {
const key = archiveKey(options);
// cacheNews() namespaces by the news revision and owns the stale window.
const result = await cacheNews(key, ARCHIVE_TTL_MS, () =>
queryNewsArchive(options),
);
// Revive the one field callers treat as a Date. Kept outside the cache so
// the cached value stays plain JSON.
return {
...result,
rows: result.rows.map((row) => ({
...row,
createdAt: new Date(row.createdAt),
})),
};
}