fix(news): restore publication flow and deduplicate dashboard friends
CI / check (push) Successful in 1m6s
CI / deploy (push) Successful in 59s

This commit is contained in:
Simo committed 2026-09-06 18:32:43 +02:00
1 parent ef94646d60
commit 9b0ea2fb16
22 files changed
+638 -195

No files matched your search

@@ -0,0 +1,7 @@
-- Existing articles remain published. Preserve any publication settings already present.
ALTER TABLE website_articles
ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'published',
ADD COLUMN IF NOT EXISTS publish_at TIMESTAMP NULL DEFAULT NULL,
ADD COLUMN IF NOT EXISTS published_at TIMESTAMP NULL DEFAULT NULL;
CREATE INDEX IF NOT EXISTS idx_website_articles_publication
ON website_articles (status, publish_at, created_at);
+4 -5
View File
@@ -12,6 +12,7 @@ import { logger } from "../src/lib/logger";
import { redis } from "../src/lib/redis"; import { redis } from "../src/lib/redis";
import { emulatorOffline, healthDegraded } from "../src/lib/services/alert"; import { emulatorOffline, healthDegraded } from "../src/lib/services/alert";
import { runCatalogExport } from "../src/lib/services/catalog-git-export"; import { runCatalogExport } from "../src/lib/services/catalog-git-export";
import { invalidateNewsCache } from "../src/lib/services/news-cache";
import { rcon } from "../src/lib/services/rcon"; import { rcon } from "../src/lib/services/rcon";
function captureWorkerError(err: unknown, context: string): void { function captureWorkerError(err: unknown, context: string): void {
@@ -246,12 +247,10 @@ async function publishScheduledArticles(): Promise<void> {
lte(WebsiteArticles.publishAt, now), lte(WebsiteArticles.publishAt, now),
), ),
); );
const info = result as unknown as { const [info] = result;
affectedRows?: number; const published = info.affectedRows ?? 0;
rowsAffected?: number;
};
const published = info.affectedRows ?? info.rowsAffected ?? 0;
if (published > 0) { if (published > 0) {
await invalidateNewsCache();
logger.info(`Published ${published} scheduled article(s)`, { logger.info(`Published ${published} scheduled article(s)`, {
module: "jobs", module: "jobs",
}); });
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+1 -7
View File
@@ -99,13 +99,7 @@ export default async function MePage({
); );
const canClaim = referralTotal >= needed; const canClaim = referralTotal >= needed;
const remaining = Math.max(0, needed - referralTotal); const remaining = Math.max(0, needed - referralTotal);
const onlineFriends = [ const onlineFriends = friends.filter((friend) => friend.online === "1");
...new Map(
friends
.filter((friend) => friend.online === "1")
.map((friend) => [friend.id, friend]),
).values(),
];
const date = (value: number) => const date = (value: number) =>
value value
? format.dateTime(new Date(value * 1000), { dateStyle: "medium" }) ? format.dateTime(new Date(value * 1000), { dateStyle: "medium" })
+32 -35
View File
@@ -8,8 +8,8 @@ import { toggleReaction } from "@/actions/article-reactions";
import Link from "@/components/link"; import Link from "@/components/link";
import { ContentCard, EmptyState } from "@/components/public/ui"; import { ContentCard, EmptyState } from "@/components/public/ui";
import { SanitizedHtml } from "@/components/shared/sanitized-html"; import { SanitizedHtml } from "@/components/shared/sanitized-html";
import { articleSlug } from "@/lib/article-input";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { cachedQuery } from "@/lib/cached-db";
import { import {
db, db,
User, User,
@@ -20,6 +20,7 @@ import {
import { excerpt } from "@/lib/format"; import { excerpt } from "@/lib/format";
import { formatDate } from "@/lib/format-date"; import { formatDate } from "@/lib/format-date";
import { sanitize } from "@/lib/sanitize"; import { sanitize } from "@/lib/sanitize";
import { cacheNews } from "@/lib/services/news-cache";
type SearchParams = Promise<{ type SearchParams = Promise<{
comment?: string; comment?: string;
@@ -46,10 +47,10 @@ function feedbackStyle(tone: "success" | "error"): CSSProperties {
export async function generateMetadata({ export async function generateMetadata({
params, params,
}: { }: {
params: Promise<{ slug: string }>; params: Promise<{ slug: string[] }>;
}): Promise<Metadata> { }): Promise<Metadata> {
const appUrl = process.env.APP_URL ?? "http://localhost:3000"; const appUrl = process.env.APP_URL ?? "http://localhost:3000";
const { slug } = await params; const slug = articleSlug((await params).slug);
const [article] = await db const [article] = await db
.select({ .select({
title: WebsiteArticles.title, title: WebsiteArticles.title,
@@ -95,44 +96,40 @@ export default async function ArticlePage({
params, params,
searchParams, searchParams,
}: { }: {
params: Promise<{ slug: string }>; params: Promise<{ slug: string[] }>;
searchParams: SearchParams; searchParams: SearchParams;
}) { }) {
const { slug } = await params; const slug = articleSlug((await params).slug);
const { comment, reaction, error } = await searchParams; const { comment, reaction, error } = await searchParams;
const t = await getTranslations("pages.article"); const t = await getTranslations("pages.article");
const article = await cachedQuery( const article = await cacheNews(`article:slug:${slug}`, 60_000, async () => {
`article:slug:${slug}`, const [row] = await db
async () => { .select({
const [row] = await db id: WebsiteArticles.id,
.select({ slug: WebsiteArticles.slug,
id: WebsiteArticles.id, title: WebsiteArticles.title,
slug: WebsiteArticles.slug, shortStory: WebsiteArticles.shortStory,
title: WebsiteArticles.title, fullStory: WebsiteArticles.fullStory,
shortStory: WebsiteArticles.shortStory, image: WebsiteArticles.image,
fullStory: WebsiteArticles.fullStory, createdAt: WebsiteArticles.createdAt,
image: WebsiteArticles.image, updatedAt: WebsiteArticles.updatedAt,
createdAt: WebsiteArticles.createdAt, })
updatedAt: WebsiteArticles.updatedAt, .from(WebsiteArticles)
}) .where(
.from(WebsiteArticles) and(
.where( eq(WebsiteArticles.slug, slug),
and( eq(WebsiteArticles.status, "published"),
eq(WebsiteArticles.slug, slug), or(
eq(WebsiteArticles.status, "published"), sql`${WebsiteArticles.publishAt} IS NULL`,
or( sql`${WebsiteArticles.publishAt} <= NOW()`,
sql`${WebsiteArticles.publishAt} IS NULL`,
sql`${WebsiteArticles.publishAt} <= NOW()`,
),
), ),
) ),
.limit(1) )
.catch(() => []); .limit(1)
return row ?? null; .catch(() => []);
}, return row ?? null;
60, });
);
if (!article) notFound(); if (!article) notFound();
const articleId = article.id; const articleId = article.id;
+5 -1
View File
@@ -31,6 +31,8 @@ export default async function EditArticle({
title: WebsiteArticles.title, title: WebsiteArticles.title,
shortStory: WebsiteArticles.shortStory, shortStory: WebsiteArticles.shortStory,
fullStory: WebsiteArticles.fullStory, fullStory: WebsiteArticles.fullStory,
status: WebsiteArticles.status,
publishAt: WebsiteArticles.publishAt,
image: WebsiteArticles.image, image: WebsiteArticles.image,
}) })
.from(WebsiteArticles) .from(WebsiteArticles)
@@ -63,7 +65,7 @@ export default async function EditArticle({
action={async (fd) => { action={async (fd) => {
"use server"; "use server";
fd.set("id", String(article.id)); fd.set("id", String(article.id));
await updateArticle(fd); return updateArticle(fd);
}} }}
defaultValues={{ defaultValues={{
title: article.title, title: article.title,
@@ -71,6 +73,8 @@ export default async function EditArticle({
image: article.image, image: article.image,
shortStory: article.shortStory, shortStory: article.shortStory,
fullStory: article.fullStory, fullStory: article.fullStory,
status: article.status,
publishAt: article.publishAt?.toISOString(),
}} }}
/> />
<form action={deleteArticle} style={{ marginTop: "1rem" }}> <form action={deleteArticle} style={{ marginTop: "1rem" }}>
+7 -4
View File
@@ -1,7 +1,9 @@
import { and, eq, or, sql } from "drizzle-orm"; import { and, eq, or, sql } from "drizzle-orm";
import { apiJson } from "@/lib/api"; import { apiJson } from "@/lib/api";
import { db, WebsiteArticles } from "@/lib/db"; import { db, WebsiteArticles } from "@/lib/db";
import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache"; import { logger } from "@/lib/logger";
import { apiCacheKey, cacheSafe } from "@/lib/redis-cache";
import { cacheNews } from "@/lib/services/news-cache";
/** /**
* GET /api/articles/:slug — single website_article by slug, including the * GET /api/articles/:slug — single website_article by slug, including the
@@ -14,9 +16,9 @@ export async function GET(
const { slug } = await params; const { slug } = await params;
try { try {
const data = await redisCache( const data = await cacheNews(
apiCacheKey(`articles:${slug}`), apiCacheKey(`articles:${slug}`),
60, 60_000,
async () => { async () => {
const [article] = await db const [article] = await db
.select({ .select({
@@ -55,7 +57,8 @@ export async function GET(
} }
return apiJson(data); return apiJson(data);
} catch { } catch (error) {
logger.error("Public news query failed", { module: "news", error });
// DB unavailable — treat as not found rather than a 500. // DB unavailable — treat as not found rather than a 500.
return apiJson({ error: "Article not found" }, { status: 200 }); return apiJson({ error: "Article not found" }, { status: 200 });
} }
+7 -4
View File
@@ -1,7 +1,9 @@
import { and, count, desc, eq, or, sql } from "drizzle-orm"; import { and, count, desc, eq, or, sql } from "drizzle-orm";
import { apiJson, pagination } from "@/lib/api"; import { apiJson, pagination } from "@/lib/api";
import { db, WebsiteArticles } from "@/lib/db"; import { db, WebsiteArticles } from "@/lib/db";
import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache"; import { logger } from "@/lib/logger";
import { apiCacheKey, cacheSafe } from "@/lib/redis-cache";
import { cacheNews } from "@/lib/services/news-cache";
/** /**
* GET /api/articles — paginated list of website_articles, newest first. * GET /api/articles — paginated list of website_articles, newest first.
@@ -13,9 +15,9 @@ export async function GET(req: Request) {
const { page, perPage, skip, take } = pagination(sp); const { page, perPage, skip, take } = pagination(sp);
try { try {
const data = await redisCache( const data = await cacheNews(
apiCacheKey(`articles:${page}:${perPage}`), apiCacheKey(`articles:${page}:${perPage}`),
60, 60_000,
async () => { async () => {
const publishedFilter = and( const publishedFilter = and(
eq(WebsiteArticles.status, "published"), eq(WebsiteArticles.status, "published"),
@@ -60,7 +62,8 @@ export async function GET(req: Request) {
); );
return apiJson(data); return apiJson(data);
} catch { } catch (error) {
logger.error("Public news query failed", { module: "news", error });
// DB unavailable — return an empty payload instead of a 500. // DB unavailable — return an empty payload instead of a 500.
return apiJson( return apiJson(
{ data: [], meta: { page, perPage, total: 0, lastPage: 1 } }, { data: [], meta: { page, perPage, total: 0, lastPage: 1 } },
+6 -3
View File
@@ -3,7 +3,9 @@ import { env } from "@/env";
import { apiJson } from "@/lib/api"; import { apiJson } from "@/lib/api";
import { db, User, WebsiteArticles } from "@/lib/db"; import { db, User, WebsiteArticles } from "@/lib/db";
import { resolveHotelName } from "@/lib/hotel-name"; import { resolveHotelName } from "@/lib/hotel-name";
import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache"; import { logger } from "@/lib/logger";
import { apiCacheKey, cacheSafe } from "@/lib/redis-cache";
import { cacheNews } from "@/lib/services/news-cache";
/** /**
* GET /api/home — combined landing payload: the latest 4 website_articles and * GET /api/home — combined landing payload: the latest 4 website_articles and
@@ -12,7 +14,7 @@ import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
*/ */
export async function GET(_req: Request) { export async function GET(_req: Request) {
try { try {
const data = await redisCache(apiCacheKey("home"), 15, async () => { const data = await cacheNews(apiCacheKey("home"), 15_000, async () => {
const [articles, onlineRows, hotelName] = await Promise.all([ const [articles, onlineRows, hotelName] = await Promise.all([
db db
.select({ .select({
@@ -45,7 +47,8 @@ export async function GET(_req: Request) {
}); });
}); });
return apiJson(data); return apiJson(data);
} catch { } catch (error) {
logger.error("Public news query failed", { module: "news", error });
return apiJson( return apiJson(
{ articles: [], online: 0, hotelName: env.HOTEL_NAME }, { articles: [], online: 0, hotelName: env.HOTEL_NAME },
{ status: 200 }, { status: 200 },
File diff suppressed because it is too large. Load diff
+5 -3
View File
@@ -1,4 +1,5 @@
"use client"; "use client";
import { useTranslations } from "next-intl";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -28,6 +29,7 @@ export function ArticlePreview({
data: ArticlePreviewData | null; data: ArticlePreviewData | null;
onClose: () => void; onClose: () => void;
}) { }) {
const t = useTranslations("pages.admin.articles.form");
const document = data const document = data
? `<!doctype html><html><head><meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src https: http: data:; style-src 'unsafe-inline'"><meta name="viewport" content="width=device-width, initial-scale=1"><style>:root{color-scheme:light;background:Canvas;color:CanvasText}body{font:16px/1.6 system-ui;padding:20px;margin:auto;max-width:760px;overflow-wrap:anywhere}img{max-width:100%;height:auto}h1{line-height:1.2}pre{white-space:pre-wrap}</style></head><body><h1>${escapeHtml(data.title)}</h1>${data.image ? `<img src="${escapeHtml(data.image)}" alt="">` : ""}<p>${escapeHtml(data.summary)}</p>${data.body}</body></html>` ? `<!doctype html><html><head><meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src https: http: data:; style-src 'unsafe-inline'"><meta name="viewport" content="width=device-width, initial-scale=1"><style>:root{color-scheme:light;background:Canvas;color:CanvasText}body{font:16px/1.6 system-ui;padding:20px;margin:auto;max-width:760px;overflow-wrap:anywhere}img{max-width:100%;height:auto}h1{line-height:1.2}pre{white-space:pre-wrap}</style></head><body><h1>${escapeHtml(data.title)}</h1>${data.image ? `<img src="${escapeHtml(data.image)}" alt="">` : ""}<p>${escapeHtml(data.summary)}</p>${data.body}</body></html>`
: ""; : "";
@@ -40,13 +42,13 @@ export function ArticlePreview({
> >
<DialogContent className="sm:max-w-4xl h-[85dvh] flex flex-col min-h-0"> <DialogContent className="sm:max-w-4xl h-[85dvh] flex flex-col min-h-0">
<DialogHeader> <DialogHeader>
<DialogTitle>Article preview</DialogTitle> <DialogTitle>{t("previewTitle")}</DialogTitle>
</DialogHeader> </DialogHeader>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Content preview. The published page applies the site theme. {t("previewDescription")}
</p> </p>
<iframe <iframe
title="Article content preview" title={t("previewFrame")}
sandbox="" sandbox=""
srcDoc={document} srcDoc={document}
className="min-h-0 flex-1 w-full bg-background rounded border" className="min-h-0 flex-1 w-full bg-background rounded border"
+27
View File
@@ -0,0 +1,27 @@
import { readFileSync } from "node:fs";
import { expect, it } from "vitest";
import { articleSlug, readArticleInput } from "./article-input";
it("normalizes Next catch-all slug parameters", () => {
expect(articleSlug(["welcome"])).toBe("welcome");
expect(articleSlug("welcome")).toBe("welcome");
});
it("keeps the selected timezone instant when scheduling", () => {
const form = new FormData();
form.set("title", "News");
form.set("status", "scheduled");
form.set("publishAt", "2026-09-06T20:00:00+02:00");
expect(readArticleInput(form).publishAt?.toISOString()).toBe(
"2026-09-06T18:00:00.000Z",
);
});
it("ships additive publication columns for existing installations", () => {
const sql = readFileSync(
"drizzle/migrations/0025_article_publication.sql",
"utf8",
);
for (const column of ["status", "publish_at", "published_at"])
expect(sql).toContain(`ADD COLUMN IF NOT EXISTS ${column}`);
expect(sql).toContain("DEFAULT 'published'");
expect(sql).not.toMatch(/DROP|UPDATE\s+website_articles/i);
});
+47
View File
@@ -0,0 +1,47 @@
import type { ActionResult } from "./safe-action-shared";
export type ArticleSaveResult = ActionResult<{ redirectTo: string }>;
export class ArticleInputError extends Error {
constructor(
public readonly code:
| "titleRequired"
| "titleTooLong"
| "summaryTooLong"
| "imageTooLong"
| "invalidStatus"
| "publishDateRequired",
) {
super(code);
}
}
export function readArticleInput(form: FormData) {
const text = (key: string) =>
String(form.get(key) ?? "")
.normalize("NFC")
.trim();
const title = text("title"),
shortStory = text("shortStory"),
fullStory = text("fullStory"),
image = text("image");
if (!title) throw new ArticleInputError("titleRequired");
if (title.length > 255) throw new ArticleInputError("titleTooLong");
if (shortStory.length > 255) throw new ArticleInputError("summaryTooLong");
if (image.length > 255) throw new ArticleInputError("imageTooLong");
const status = text("status") || "published";
if (status !== "published" && status !== "draft" && status !== "scheduled")
throw new ArticleInputError("invalidStatus");
const publishAt = status === "scheduled" ? new Date(text("publishAt")) : null;
if (publishAt && Number.isNaN(publishAt.getTime()))
throw new ArticleInputError("publishDateRequired");
return {
title,
shortStory,
fullStory,
image,
status,
publishAt,
rawSlug: text("slug"),
};
}
export function articleSlug(value: string | string[]): string {
return Array.isArray(value) ? value.join("/") : value;
}
+67
View File
@@ -0,0 +1,67 @@
import { beforeEach, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
revision: "0",
values: new Map<string, unknown>(),
get: vi.fn(),
set: vi.fn(),
status: "ready",
}));
vi.mock("@/lib/redis", () => ({ redis: state }));
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
vi.mock("@/lib/cache", () => ({
cached: async (key: string, _ttl: number, query: () => Promise<unknown>) => {
if (state.values.has(key)) return state.values.get(key);
const result = await query();
state.values.set(key, result);
return result;
},
}));
import { cacheNews, invalidateNewsCache } from "./news-cache";
beforeEach(() => {
state.values.clear();
state.revision = "0";
state.status = "ready";
state.get.mockReset().mockImplementation(async () => state.revision);
state.set
.mockReset()
.mockImplementation(async (_key: string, value: string) => {
state.revision = value;
});
});
it("invalidates a previously cached public list", async () => {
const query = vi
.fn()
.mockResolvedValueOnce([])
.mockResolvedValueOnce(["new article"]);
expect(await cacheNews("list", 60000, query)).toEqual([]);
await invalidateNewsCache();
expect(await cacheNews("list", 60000, query)).toEqual(["new article"]);
});
it("does not let a stale in-flight read replace a newer revision", async () => {
let finish!: (value: string[]) => void;
const old = cacheNews(
"list",
60000,
() =>
new Promise<string[]>((resolve) => {
finish = resolve;
}),
);
await vi.waitFor(() => expect(finish).toBeDefined());
await invalidateNewsCache();
expect(await cacheNews("list", 60000, async () => ["new"])).toEqual(["new"]);
finish([]);
await old;
expect(await cacheNews("list", 60000, async () => ["wrong"])).toEqual([
"new",
]);
});
it("reads fresh data when Redis is unavailable", async () => {
state.get.mockRejectedValue(Error("offline"));
expect(await cacheNews("list", 60000, async () => ["fresh"])).toEqual([
"fresh",
]);
});
+32
View File
@@ -0,0 +1,32 @@
import "server-only";
import { randomUUID } from "node:crypto";
import { cached } from "@/lib/cache";
import { logger } from "@/lib/logger";
import { redis } from "@/lib/redis";
const REVISION_KEY = "cms:news:revision";
export async function cacheNews<T>(
key: string,
ttlMs: number,
query: () => Promise<T>,
): Promise<T> {
if (!redis || redis.status === "end") return query();
let revision: string;
try {
revision = (await redis.get(REVISION_KEY)) ?? "0";
} catch {
return query();
}
return cached(`news:${revision}:${key}`, ttlMs, query);
}
export async function invalidateNewsCache(): Promise<void> {
if (!redis || redis.status === "end") return;
try {
await redis.set(REVISION_KEY, randomUUID());
} catch (error) {
logger.error("News saved but public cache invalidation failed", {
module: "news",
error,
});
}
}
+5 -3
View File
@@ -1,7 +1,8 @@
import { logger } from "@/lib/logger";
import { cacheNews } from "@/lib/services/news-cache";
import "server-only"; import "server-only";
import { and, desc, eq, or, sql } from "drizzle-orm"; import { and, desc, eq, or, sql } from "drizzle-orm";
import { cached } from "@/lib/cache";
import { db, WebsiteArticles } from "@/lib/db"; import { db, WebsiteArticles } from "@/lib/db";
export interface NewsListItem { export interface NewsListItem {
@@ -32,7 +33,7 @@ const FETCH_LIMIT = 30;
*/ */
export async function getNewsList(limit: number): Promise<NewsListItem[]> { export async function getNewsList(limit: number): Promise<NewsListItem[]> {
try { try {
const rows = await cached<NewsListRow[]>(CACHE_KEY, TTL_MS, () => const rows = await cacheNews<NewsListRow[]>(CACHE_KEY, TTL_MS, () =>
db db
.select({ .select({
slug: WebsiteArticles.slug, slug: WebsiteArticles.slug,
@@ -60,7 +61,8 @@ export async function getNewsList(limit: number): Promise<NewsListItem[]> {
createdAt: a.createdAt ? new Date(a.createdAt) : null, createdAt: a.createdAt ? new Date(a.createdAt) : null,
})) }))
.slice(0, limit); .slice(0, limit);
} catch { } catch (error) {
logger.error("Public news query failed", { module: "news", error });
return []; return [];
} }
} }
+78
View File
@@ -0,0 +1,78 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
friends: [] as { id: number; username: string; online: string }[],
}));
vi.mock("@/lib/hotel-name", () => ({ resolveHotelName: async () => "Hotel" }));
vi.mock("./site-settings", () => ({
siteSettings: {
get: async (_key: string, fallback: string) => fallback,
getMany: async () => ({}),
},
}));
vi.mock("@/lib/db", async () => {
const schema = await import("@/db/schema");
return {
...schema,
db: {
select: (fields: Record<string, unknown>) => {
let rows: unknown[] = [];
const chain = {
from: (table: unknown) => {
if (table === schema.MessengerFriendships)
rows =
"value" in fields
? [{ value: state.friends.length }]
: state.friends;
return chain;
},
where: () => chain,
innerJoin: () => chain,
orderBy: () => chain,
limit: () => chain,
catch: () => Promise.resolve(rows),
// biome-ignore lint/suspicious/noThenProperty: Drizzle query builders are intentionally awaitable.
then: (resolve: (value: unknown[]) => unknown) =>
Promise.resolve(rows).then(resolve),
};
return chain;
},
},
};
});
import { loadUserDashboard } from "./user-dashboard";
describe("dashboard friendships", () => {
beforeEach(() => {
state.friends = [];
});
it("counts each friend once when both directional rows are returned", async () => {
const wait = { id: 2, username: "wait", online: "1" };
const other = { id: 3, username: "YouKnowMe", online: "1" };
state.friends = [wait, other, { ...wait }, { ...other }];
const data = await loadUserDashboard(1);
expect(data.friends.map((friend) => friend.id)).toEqual([2, 3]);
expect(data.friendCountRows).toEqual([{ value: 2 }]);
});
it("preserves single-direction and offline friendships", async () => {
state.friends = [
{ id: 2, username: "wait", online: "1" },
{ id: 3, username: "offline", online: "0" },
];
const data = await loadUserDashboard(1);
expect(data.friends).toEqual(state.friends);
expect(data.friendCountRows).toEqual([{ value: 2 }]);
});
it("excludes self friendships", async () => {
state.friends = [{ id: 1, username: "me", online: "1" }];
const data = await loadUserDashboard(1);
expect(data.friends).toEqual([]);
expect(data.friendCountRows).toEqual([{ value: 0 }]);
});
it("returns zero for an empty friends list", async () => {
const data = await loadUserDashboard(1);
expect(data.friends).toEqual([]);
expect(data.friendCountRows).toEqual([{ value: 0 }]);
});
});
+11 -13
View File
@@ -24,7 +24,6 @@ export async function loadUserDashboard(userId: number) {
recentRooms, recentRooms,
badges, badges,
userSettingsRows, userSettingsRows,
friendCountRows,
unreadCountRows, unreadCountRows,
referralsRows, referralsRows,
friends, friends,
@@ -82,16 +81,6 @@ export async function loadUserDashboard(userId: number) {
.where(eq(UsersSettings.userId, userId)) .where(eq(UsersSettings.userId, userId))
.limit(1) .limit(1)
.catch(() => []), .catch(() => []),
db
.select({ value: count() })
.from(MessengerFriendships)
.where(
or(
eq(MessengerFriendships.userOneId, userId),
eq(MessengerFriendships.userTwoId, userId),
),
)
.catch(() => [{ value: 0 }]),
db db
.select({ value: count() }) .select({ value: count() })
.from(MessengerOffline) .from(MessengerOffline)
@@ -145,6 +134,15 @@ export async function loadUserDashboard(userId: number) {
.catch(() => null), .catch(() => null),
]); ]);
// Each friendship can have two directional rows; expose users, not edges.
const uniqueFriends = [
...new Map(
friends
.filter((friend) => friend.id !== userId)
.map((friend) => [friend.id, friend]),
).values(),
];
return { return {
userRows, userRows,
hotelName, hotelName,
@@ -155,10 +153,10 @@ export async function loadUserDashboard(userId: number) {
recentRooms, recentRooms,
badges, badges,
userSettingsRows, userSettingsRows,
friendCountRows, friendCountRows: [{ value: uniqueFriends.length }],
unreadCountRows, unreadCountRows,
referralsRows, referralsRows,
friends, friends: uniqueFriends,
currencyRows, currencyRows,
}; };
} }
+15 -1
View File
@@ -1161,7 +1161,21 @@
"statusScheduled": "Scheduled", "statusScheduled": "Scheduled",
"statusPublished": "Published", "statusPublished": "Published",
"publishAt": "Publish at", "publishAt": "Publish at",
"publishedAt": "Published at" "publishedAt": "Published at",
"titleRequired": "Enter a title.",
"titleTooLong": "The title must be at most 255 characters.",
"summaryTooLong": "The summary must be at most 255 characters.",
"imageTooLong": "The image URL must be at most 255 characters.",
"invalidStatus": "Select a valid publication status.",
"publishDateRequired": "Choose a valid publication date.",
"saveFailedReference": "Could not save the article. Your content is preserved. Error reference: {reference}",
"articleNotFound": "Article not found.",
"summary": "Summary",
"imageNotFound": "Image not found. Enter a valid URL or browse.",
"imagePreview": "Article image preview",
"previewTitle": "Article preview",
"previewDescription": "Content preview. The published page applies the site theme.",
"previewFrame": "Article content preview"
}, },
"articlesShown": "Articles shown", "articlesShown": "Articles shown",
"latest": "Latest article", "latest": "Latest article",
+15 -1
View File
@@ -1135,7 +1135,21 @@
"statusScheduled": "Programmato", "statusScheduled": "Programmato",
"statusPublished": "Pubblicato", "statusPublished": "Pubblicato",
"publishAt": "Data di pubblicazione", "publishAt": "Data di pubblicazione",
"publishedAt": "Pubblicato il" "publishedAt": "Pubblicato il",
"titleRequired": "Inserisci un titolo.",
"titleTooLong": "Il titolo può contenere al massimo 255 caratteri.",
"summaryTooLong": "Il sommario può contenere al massimo 255 caratteri.",
"imageTooLong": "L’URL dell’immagine può contenere al massimo 255 caratteri.",
"invalidStatus": "Seleziona uno stato di pubblicazione valido.",
"publishDateRequired": "Scegli una data di pubblicazione valida.",
"saveFailedReference": "Impossibile salvare la news. Il contenuto è conservato. Riferimento errore: {reference}",
"articleNotFound": "News non trovata.",
"summary": "Sommario",
"imageNotFound": "Immagine non trovata. Inserisci un URL valido o sfoglia i media.",
"imagePreview": "Anteprima immagine della news",
"previewTitle": "Anteprima news",
"previewDescription": "Anteprima del contenuto. La pagina pubblicata usa il tema del sito.",
"previewFrame": "Anteprima contenuto della news"
}, },
"articlesShown": "Articoli mostrati", "articlesShown": "Articoli mostrati",
"latest": "Ultimo articolo", "latest": "Ultimo articolo",
+15 -1
View File
@@ -1123,7 +1123,21 @@
"saving": "Opslaan…", "saving": "Opslaan…",
"deleteArticle": "Artikel verwijderen", "deleteArticle": "Artikel verwijderen",
"deleteConfirm": "Deze actie kan niet ongedaan worden gemaakt. Het artikel wordt permanent verwijderd.", "deleteConfirm": "Deze actie kan niet ongedaan worden gemaakt. Het artikel wordt permanent verwijderd.",
"cancel": "Annuleren" "cancel": "Annuleren",
"titleRequired": "Voer een titel in.",
"titleTooLong": "De titel mag maximaal 255 tekens bevatten.",
"summaryTooLong": "De samenvatting mag maximaal 255 tekens bevatten.",
"imageTooLong": "De afbeeldings-URL mag maximaal 255 tekens bevatten.",
"invalidStatus": "Selecteer een geldige publicatiestatus.",
"publishDateRequired": "Kies een geldige publicatiedatum.",
"saveFailedReference": "Het artikel kon niet worden opgeslagen. Je inhoud blijft bewaard. Foutreferentie: {reference}",
"articleNotFound": "Artikel niet gevonden.",
"summary": "Samenvatting",
"imageNotFound": "Afbeelding niet gevonden. Voer een geldige URL in of blader door media.",
"imagePreview": "Voorbeeld van artikelafbeelding",
"previewTitle": "Artikelvoorbeeld",
"previewDescription": "Voorbeeld van de inhoud. De gepubliceerde pagina gebruikt het sitethema.",
"previewFrame": "Voorbeeld van artikelinhoud"
} }
}, },
"events": { "events": {