feat(FN-188): add Changelog tab to dashboard settings
Some checks failed
Sync dev → Gitea / Mirror dev to Gitea (push) Has been cancelled
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:
@@ -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: [
|
||||
|
||||
38
apps/api/src/changelog/changelog.controller.ts
Normal file
38
apps/api/src/changelog/changelog.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
1
apps/api/src/changelog/changelog.dto.ts
Normal file
1
apps/api/src/changelog/changelog.dto.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type { CreateChangelogEntry, UpdateChangelogEntry, ChangelogStage } from "@sase/shared";
|
||||
9
apps/api/src/changelog/changelog.module.ts
Normal file
9
apps/api/src/changelog/changelog.module.ts
Normal 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 {}
|
||||
185
apps/api/src/changelog/changelog.service.spec.ts
Normal file
185
apps/api/src/changelog/changelog.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
112
apps/api/src/changelog/changelog.service.ts
Normal file
112
apps/api/src/changelog/changelog.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user