feat(FN-188): add Changelog tab to dashboard settings
Some checks failed
Sync dev → Gitea / Mirror dev to Gitea (push) Has been cancelled

Squash-recovery of fusion/fn-188 onto dev — original branch was based on
main (dccd4fa) due to baseBranch=None drift in Fusion settings; rebase
onto dev surfaced unrelated main-only commits (EMEX/PL24/Corgi) as false
conflicts. This commit applies only FN-188's 23-file changelog patch.

- apps/api: ChangelogModule (controller, service, DTO, spec, schema)
- apps/web: ChangelogTab (timeline + accordion), useChangelog hook, i18n
- packages/shared: changelog Zod schemas + types
- packages/ui: Accordion component + Badge stage variant
- docs/INDEX.md: changelog feature documented

Lint, typecheck, all 169 api tests + 2 web tests pass.
This commit is contained in:
Fusion
2026-05-11 20:05:16 +00:00
parent a7042dffb0
commit 85ebd35048
23 changed files with 739 additions and 13 deletions

View File

@@ -11,6 +11,7 @@ import { AuthModule } from "./auth/auth.module";
import { BrandsModule } from "./brands/brands.module";
import { CatalogModule } from "./catalog/catalog.module";
import { CategoriesModule } from "./categories/categories.module";
import { ChangelogModule } from "./changelog/changelog.module";
import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
import { AuthGuard } from "./common/guards/auth.guard";
import { RolesGuard } from "./common/guards/roles.guard";
@@ -79,6 +80,7 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
AdminModule,
AnalyticsModule,
CatalogModule,
ChangelogModule,
],
controllers: [HealthController],
providers: [

View File

@@ -0,0 +1,38 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from "@nestjs/common";
import { Public } from "../common/decorators/public.decorator";
import { Roles } from "../common/decorators/roles.decorator";
import { RolesGuard } from "../common/guards/roles.guard";
import type { CreateChangelogEntry, UpdateChangelogEntry } from "./changelog.dto";
import { ChangelogService } from "./changelog.service";
@Controller("changelog")
export class ChangelogController {
constructor(private readonly changelogService: ChangelogService) {}
@Get()
@Public()
async findAll() {
return this.changelogService.findAll();
}
@Post()
@UseGuards(RolesGuard)
@Roles("admin")
async create(@Body() body: CreateChangelogEntry) {
return this.changelogService.create(body);
}
@Patch(":id")
@UseGuards(RolesGuard)
@Roles("admin")
async update(@Param("id") id: string, @Body() body: UpdateChangelogEntry) {
return this.changelogService.update(id, body);
}
@Delete(":id")
@UseGuards(RolesGuard)
@Roles("admin")
async delete(@Param("id") id: string) {
return this.changelogService.delete(id);
}
}

View File

@@ -0,0 +1 @@
export type { CreateChangelogEntry, UpdateChangelogEntry, ChangelogStage } from "@sase/shared";

View File

@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { ChangelogController } from "./changelog.controller";
import { ChangelogService } from "./changelog.service";
@Module({
controllers: [ChangelogController],
providers: [ChangelogService],
})
export class ChangelogModule {}

View File

@@ -0,0 +1,185 @@
import { NotFoundException } from "@nestjs/common";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ChangelogService } from "./changelog.service";
function createMockDb(overrides: Record<string, unknown> = {}) {
function chainable(terminalValue: unknown) {
const chain: Record<string, unknown> = {};
const methods = [
"select",
"from",
"where",
"orderBy",
"limit",
"offset",
"insert",
"values",
"update",
"set",
"delete",
"returning",
];
for (const m of methods) chain[m] = vi.fn().mockReturnValue(chain);
chain.limit = vi.fn().mockReturnValue(terminalValue);
chain.returning = vi.fn().mockReturnValue(terminalValue);
chain.orderBy = vi.fn().mockReturnValue(terminalValue);
return chain;
}
return {
select: vi.fn().mockImplementation(() => chainable(overrides._selectRows ?? [])),
insert: vi.fn().mockImplementation(() => chainable(overrides._insertRows ?? [])),
update: vi.fn().mockImplementation(() => chainable(overrides._updateRows ?? [])),
delete: vi.fn().mockImplementation(() => chainable(overrides._deleteRows ?? [])),
...overrides,
};
}
function createMockRedis(overrides: Record<string, unknown> = {}) {
return {
getJson: vi.fn().mockResolvedValue(null),
setJson: vi.fn().mockResolvedValue(undefined),
del: vi.fn().mockResolvedValue(undefined),
...overrides,
};
}
const isoDate = "2026-05-11T12:00:00.000Z";
const dateObj = new Date(isoDate);
const sampleEntry = {
id: "entry-1",
stage: "prod" as const,
title: "New Feature",
description: "A new feature was added.",
publishedAt: dateObj,
createdAt: dateObj,
updatedAt: dateObj,
};
describe("ChangelogService", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("findAll", () => {
it("should return cached entries when cache hit", async () => {
const cached = [
{ ...sampleEntry, publishedAt: isoDate, createdAt: isoDate, updatedAt: isoDate },
];
const redis = createMockRedis({ getJson: vi.fn().mockResolvedValue(cached) });
const db = createMockDb();
const service = new ChangelogService(db as any, redis as any);
const result = await service.findAll();
expect(result).toEqual(cached);
expect(redis.getJson).toHaveBeenCalledWith("changelog:entries");
expect(db.select).not.toHaveBeenCalled();
});
it("should query DB and cache when cache miss", async () => {
const rows = [sampleEntry];
const db = createMockDb({ _selectRows: rows });
const redis = createMockRedis();
const service = new ChangelogService(db as any, redis as any);
const result = await service.findAll();
expect(result).toHaveLength(1);
expect(result[0].publishedAt).toBe(isoDate);
expect(redis.setJson).toHaveBeenCalledWith("changelog:entries", expect.any(Array), 1800);
});
it("should order by publishedAt DESC", async () => {
const rows = [
{ ...sampleEntry, id: "2", publishedAt: new Date("2026-02-01") },
{ ...sampleEntry, id: "1", publishedAt: new Date("2026-01-01") },
];
const db = createMockDb({ _selectRows: rows });
const redis = createMockRedis();
const service = new ChangelogService(db as any, redis as any);
const result = await service.findAll();
// The mock doesn't actually sort; this test just verifies the call pattern
expect(db.select).toHaveBeenCalled();
});
});
describe("findById", () => {
it("should return entry when found", async () => {
const db = createMockDb({ _selectRows: [sampleEntry] });
const redis = createMockRedis();
const service = new ChangelogService(db as any, redis as any);
const result = await service.findById("entry-1");
expect(result.publishedAt).toBe(isoDate);
});
it("should throw NotFoundException when not found", async () => {
const db = createMockDb({ _selectRows: [] });
const redis = createMockRedis();
const service = new ChangelogService(db as any, redis as any);
await expect(service.findById("nonexistent")).rejects.toThrow(NotFoundException);
});
});
describe("create", () => {
it("should create entry and invalidate cache", async () => {
const db = createMockDb({ _insertRows: [sampleEntry] });
const redis = createMockRedis();
const service = new ChangelogService(db as any, redis as any);
const result = await service.create({
stage: "prod",
title: "New Feature",
description: "A new feature was added.",
publishedAt: isoDate,
});
expect(result.publishedAt).toBe(isoDate);
expect(redis.del).toHaveBeenCalledWith("changelog:entries");
});
});
describe("update", () => {
it("should update entry and invalidate cache", async () => {
const updated = { ...sampleEntry, title: "Updated Title", updatedAt: dateObj };
const db = createMockDb({ _updateRows: [updated] });
const redis = createMockRedis();
const service = new ChangelogService(db as any, redis as any);
const result = await service.update("entry-1", { title: "Updated Title" });
expect(result.title).toBe("Updated Title");
expect(redis.del).toHaveBeenCalledWith("changelog:entries");
});
it("should throw NotFoundException when entry not found", async () => {
const db = createMockDb({ _updateRows: [] });
const redis = createMockRedis();
const service = new ChangelogService(db as any, redis as any);
await expect(service.update("nonexistent", { title: "X" })).rejects.toThrow(
NotFoundException,
);
});
});
describe("delete", () => {
it("should delete entry and invalidate cache", async () => {
const db = createMockDb({ _deleteRows: [sampleEntry] });
const redis = createMockRedis();
const service = new ChangelogService(db as any, redis as any);
await service.delete("entry-1");
expect(redis.del).toHaveBeenCalledWith("changelog:entries");
});
it("should throw NotFoundException when entry not found", async () => {
const db = createMockDb({ _deleteRows: [] });
const redis = createMockRedis();
const service = new ChangelogService(db as any, redis as any);
await expect(service.delete("nonexistent")).rejects.toThrow(NotFoundException);
});
});
});

View File

@@ -0,0 +1,112 @@
import { Inject, Injectable, NotFoundException } from "@nestjs/common";
import type { ChangelogEntry, CreateChangelogEntry, UpdateChangelogEntry } from "@sase/shared";
import { desc, eq } from "drizzle-orm";
import { DATABASE, type Database } from "../database/database.provider";
import { changelogEntries } from "../database/schema/core";
import { RedisService } from "../redis/redis.service";
const CACHE_KEY = "changelog:entries";
const CACHE_TTL = 1800; // 30 minutes
@Injectable()
export class ChangelogService {
constructor(
@Inject(DATABASE) private db: Database,
private readonly redis: RedisService,
) {}
async findAll(): Promise<ChangelogEntry[]> {
const cached = await this.redis.getJson<ChangelogEntry[]>(CACHE_KEY);
if (cached) return cached;
const rows = await this.db
.select()
.from(changelogEntries)
.orderBy(desc(changelogEntries.publishedAt));
const entries = rows.map((row) => ({
...row,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
publishedAt: row.publishedAt.toISOString(),
}));
await this.redis.setJson(CACHE_KEY, entries, CACHE_TTL);
return entries as ChangelogEntry[];
}
async findById(id: string): Promise<ChangelogEntry> {
const result = await this.db
.select()
.from(changelogEntries)
.where(eq(changelogEntries.id, id))
.limit(1);
if (result.length === 0) throw new NotFoundException("Changelog entry not found");
const row = result[0];
return {
...row,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
publishedAt: row.publishedAt.toISOString(),
} as ChangelogEntry;
}
async create(dto: CreateChangelogEntry): Promise<ChangelogEntry> {
const [row] = await this.db
.insert(changelogEntries)
.values({
stage: dto.stage,
title: dto.title,
description: dto.description,
publishedAt: new Date(dto.publishedAt),
})
.returning();
await this.redis.del(CACHE_KEY);
return {
...row,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
publishedAt: row.publishedAt.toISOString(),
} as ChangelogEntry;
}
async update(id: string, dto: UpdateChangelogEntry): Promise<ChangelogEntry> {
const values: Record<string, unknown> = { updatedAt: new Date() };
if (dto.stage !== undefined) values.stage = dto.stage;
if (dto.title !== undefined) values.title = dto.title;
if (dto.description !== undefined) values.description = dto.description;
if (dto.publishedAt !== undefined) values.publishedAt = new Date(dto.publishedAt);
const [row] = await this.db
.update(changelogEntries)
.set(values)
.where(eq(changelogEntries.id, id))
.returning();
if (!row) throw new NotFoundException("Changelog entry not found");
await this.redis.del(CACHE_KEY);
return {
...row,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
publishedAt: row.publishedAt.toISOString(),
} as ChangelogEntry;
}
async delete(id: string): Promise<void> {
const [row] = await this.db
.delete(changelogEntries)
.where(eq(changelogEntries.id, id))
.returning();
if (!row) throw new NotFoundException("Changelog entry not found");
await this.redis.del(CACHE_KEY);
}
}

View File

@@ -428,6 +428,21 @@ export const referrals = pgTable(
],
);
// ─── Changelog Entries ────────────────────────────
export const changelogEntries = pgTable(
"changelog_entries",
{
id: uuid("id").primaryKey().defaultRandom(),
stage: varchar("stage", { length: 10 }).default("prod").notNull(),
title: varchar("title", { length: 255 }).notNull(),
description: text("description").notNull(),
publishedAt: timestamp("published_at", { withTimezone: true }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [index("changelog_entries_published_at_idx").on(table.publishedAt)],
);
// ─── EMEX Category Translations ─────────────────────
export const emexCategoryTranslations = pgTable(
"emex_category_translations",

View File

@@ -0,0 +1,113 @@
import { useChangelog } from "@/hooks/use-changelog";
import { useTranslation } from "@/lib/i18n";
import type { ChangelogEntry } from "@sase/shared";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Badge,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Skeleton,
} from "@sase/ui";
import { CalendarDays } from "lucide-react";
function formatDate(iso: string, locale: "tr" | "en"): string {
const date = new Date(iso);
const options: Intl.DateTimeFormatOptions = {
day: "numeric",
month: "long",
year: "numeric",
};
return date.toLocaleDateString(locale === "tr" ? "tr-TR" : "en-US", options);
}
function stageLabel(stage: ChangelogEntry["stage"], t: (key: string) => string): string {
return t(`settings.changelog.stage.${stage}`);
}
function ChangelogSkeleton() {
return (
<div className="space-y-0">
{[1, 2, 3].map((i) => (
<div key={i} className="relative pb-6 pl-8">
<div className="absolute left-0 top-1.5 h-2.5 w-2.5 rounded-full bg-muted-foreground/20" />
<div className="space-y-2">
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-16 rounded-md" />
<Skeleton className="h-5 w-48" />
</div>
<Skeleton className="h-4 w-28" />
</div>
</div>
))}
</div>
);
}
function ChangelogEmpty({ t }: { t: (key: string) => string }) {
return (
<Card className="border-dashed">
<CardHeader className="text-center">
<CalendarDays className="mx-auto h-10 w-10 text-muted-foreground" />
<CardTitle>{t("settings.changelog.emptyTitle")}</CardTitle>
<CardDescription>{t("settings.changelog.emptyDescription")}</CardDescription>
</CardHeader>
</Card>
);
}
export function ChangelogTab() {
const { data: entries, isLoading } = useChangelog();
const { t, locale } = useTranslation();
if (isLoading) {
return <ChangelogSkeleton />;
}
if (!entries || entries.length === 0) {
return <ChangelogEmpty t={t} />;
}
return (
<Card>
<CardHeader>
<CardTitle>{t("settings.changelog.title")}</CardTitle>
<CardDescription>{t("settings.changelog.description")}</CardDescription>
</CardHeader>
<CardContent>
<Accordion
type="single"
collapsible
className="relative border-l-2 border-muted-foreground/20"
>
{entries.map((entry) => (
<AccordionItem key={entry.id} value={entry.id} className="border-b-0 pl-6">
<div className="absolute left-[-5px] mt-6 h-2.5 w-2.5 rounded-full border-2 border-background bg-foreground dark:bg-primary" />
<AccordionTrigger className="py-3 hover:no-underline">
<div className="flex flex-col items-start gap-1.5 text-left">
<div className="flex flex-wrap items-center gap-2">
<Badge stage={entry.stage}>{stageLabel(entry.stage, t)}</Badge>
<span className="font-medium">{entry.title}</span>
</div>
<span className="text-xs text-muted-foreground">
{formatDate(entry.publishedAt, locale)}
</span>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="text-sm text-muted-foreground leading-relaxed whitespace-pre-wrap">
{entry.description}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</CardContent>
</Card>
);
}

View File

@@ -21,8 +21,19 @@ import {
DialogTrigger,
} from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { AlertTriangle, Copy, Gift, Link2, Share2, Shield, Trash2, User } from "lucide-react";
import {
AlertTriangle,
CalendarDays,
Copy,
Gift,
Link2,
Share2,
Shield,
Trash2,
User,
} from "lucide-react";
import { useEffect, useState } from "react";
import { ChangelogTab } from "./changelog-tab";
export function SettingsContent() {
const { t } = useTranslation();
@@ -172,6 +183,10 @@ export function SettingsContent() {
<Trash2 className="h-4 w-4" />
<span className="hidden sm:inline">{t("settings.tabs.account")}</span>
</TabsTrigger>
<TabsTrigger value="changelog" className="gap-2">
<CalendarDays className="h-4 w-4" />
<span className="hidden sm:inline">{t("settings.tabs.changelog")}</span>
</TabsTrigger>
</TabsList>
{/* Profile Tab */}
@@ -454,6 +469,11 @@ export function SettingsContent() {
</CardContent>
</Card>
</TabsContent>
{/* Changelog Tab */}
<TabsContent value="changelog">
<ChangelogTab />
</TabsContent>
</Tabs>
</div>
);

View File

@@ -50,6 +50,9 @@
--font-mono: "Geist Mono", ui-monospace, "SF Mono", monospace;
--font-display: "Geist", ui-sans-serif, system-ui, sans-serif;
--font-serif: "Instrument Serif", ui-serif, Georgia, serif;
--animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out;
}
@layer base {
@@ -149,6 +152,24 @@
}
}
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
.animate-scroll-left {
animation: scroll-left 30s linear infinite;
}

View File

@@ -0,0 +1,11 @@
import { api } from "@/lib/api-client";
import type { ChangelogEntry } from "@sase/shared";
import { useQuery } from "@tanstack/react-query";
export function useChangelog() {
return useQuery({
queryKey: ["changelog"],
queryFn: () => api.get<ChangelogEntry[]>("/changelog"),
staleTime: 30 * 60 * 1000, // 30 min — matches Redis cache TTL
});
}

View File

@@ -261,7 +261,8 @@
"security": "Security",
"connections": "Connections",
"referral": "Referral",
"account": "Account"
"account": "Account",
"changelog": "Changelog"
},
"profile": {
"title": "Profile Information",
@@ -318,6 +319,17 @@
"deleteFailed": "Account deletion failed.",
"typeConfirm": "Type 'DELETE' to confirm",
"confirmWord": "DELETE"
},
"changelog": {
"title": "Changelog",
"description": "Latest platform updates, new features, and bug fixes.",
"emptyTitle": "No updates yet",
"emptyDescription": "Platform updates will appear here soon.",
"stage": {
"alpha": "Alpha",
"beta": "Beta",
"prod": "Production"
}
}
},
"errors": {

View File

@@ -261,7 +261,8 @@
"security": "Güvenlik",
"connections": "Bağlantılar",
"referral": "Referans",
"account": "Hesap"
"account": "Hesap",
"changelog": "Değişiklik Günlüğü"
},
"profile": {
"title": "Profil Bilgileri",
@@ -318,6 +319,17 @@
"deleteFailed": "Hesap silme başarısız.",
"typeConfirm": "Onaylamak için 'SİL' yazın",
"confirmWord": "SİL"
},
"changelog": {
"title": "Değişiklik Günlüğü",
"description": "Platformdaki son güncellemeler, yeni özellikler ve hata düzeltmeleri.",
"emptyTitle": "Henüz güncelleme yok",
"emptyDescription": "Yakında platform güncellemeleri burada görünecek.",
"stage": {
"alpha": "Alpha",
"beta": "Beta",
"prod": "Canlı"
}
}
},
"errors": {

View File

@@ -6,6 +6,7 @@
*/
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { BrowserRouter } from "react-router-dom";
import { vi } from "vitest";
// Mock the PostHog capture function