@goplusvn/core 0.1.72 → 0.1.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/package.json +1 -1
  2. package/src/branding/__tests__/identity.test.ts +101 -0
  3. package/src/branding/__tests__/logo-route.test.ts +143 -0
  4. package/src/branding/identity.ts +74 -0
  5. package/src/branding/index.ts +36 -0
  6. package/src/branding/logo-route.ts +249 -0
  7. package/src/branding/pwa.tsx +146 -0
  8. package/src/branding/types.ts +45 -0
  9. package/src/styles/base.css +16 -0
  10. package/src/ui/errors/error-view.tsx +80 -87
  11. package/src/ui/errors/index.ts +6 -1
  12. package/src/ui/errors/not-found-view.tsx +89 -16
  13. package/src/ui/errors/status-page.tsx +132 -0
  14. package/src/ui/layout/logo.tsx +5 -0
  15. package/src/ui/pages/not-found.tsx +11 -27
  16. package/templates/starter-app/next.config.mjs +8 -0
  17. package/templates/starter-app/package.json +1 -0
  18. package/templates/starter-app/public/images/illustrations/characters/character-01.svg +33 -0
  19. package/templates/starter-app/public/images/illustrations/characters/character-02.svg +30 -0
  20. package/templates/starter-app/public/images/illustrations/characters/character-03.svg +30 -0
  21. package/templates/starter-app/public/images/illustrations/characters/character-04.svg +27 -0
  22. package/templates/starter-app/public/images/logos/goeat_logo.png +0 -0
  23. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/company-profile-form.tsx +21 -5
  24. package/templates/starter-app/src/app/[lang]/layout.tsx +26 -1
  25. package/templates/starter-app/src/app/api/branding/logo/route.ts +25 -0
  26. package/templates/starter-app/src/app/layout.tsx +42 -1
  27. package/templates/starter-app/src/app/manifest.ts +23 -19
  28. package/templates/starter-app/src/configs/tenant.ts +6 -1
  29. package/templates/starter-app/src/providers/index.tsx +12 -2
  30. package/templates/starter-app/src/proxy.ts +3 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goplusvn/core",
3
3
  "description": "GoPlusVN Platform Kit - ERP kernel: layout, RBAC, CRUD, multi-tenant, system pages",
4
- "version": "0.1.72",
4
+ "version": "0.1.73",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -0,0 +1,101 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { resolveBranding, tenantWithBranding } from "../identity";
4
+ import { buildAppManifest, buildBrandingMetadata } from "../pwa";
5
+
6
+ import type { TenantConfig } from "../../providers/tenant-provider";
7
+
8
+ const tenant: TenantConfig = {
9
+ id: "starter",
10
+ name: "GoERP Starter",
11
+ branding: {
12
+ logo: "/images/logos/goeat_logo.png",
13
+ companyName: "GoERP Starter",
14
+ tagline: "Nền tảng quản trị",
15
+ },
16
+ currency: "VND",
17
+ };
18
+
19
+ describe("resolveBranding", () => {
20
+ it("DB đè cấu hình tĩnh; systemName thắng tên pháp lý", () => {
21
+ const identity = resolveBranding({
22
+ tenant,
23
+ company: {
24
+ name: "CÔNG TY TNHH SPARTRONICS VIỆT NAM",
25
+ systemName: "Spartronics",
26
+ updatedAt: new Date("2026-08-01T00:00:00Z"),
27
+ },
28
+ });
29
+
30
+ expect(identity.appName).toBe("Spartronics");
31
+ expect(identity.companyName).toBe("CÔNG TY TNHH SPARTRONICS VIỆT NAM");
32
+ });
33
+
34
+ it("DB trống ⇒ giữ nguyên tenant tĩnh, logo không kèm ?v=", () => {
35
+ const identity = resolveBranding({ tenant, company: null });
36
+
37
+ expect(identity.appName).toBe("GoERP Starter");
38
+ expect(identity.logoUrl).toBe("/api/branding/logo");
39
+ });
40
+
41
+ it("logo LUÔN trỏ route branding kèm ?v=updatedAt, không trỏ file thô", () => {
42
+ const identity = resolveBranding({
43
+ tenant,
44
+ company: {
45
+ name: "Cty A",
46
+ // URL thô cần phiên đăng nhập — không được lọt ra ngoài.
47
+ logoUrl: "/api/files/branding/a.png",
48
+ updatedAt: new Date("2026-08-01T00:00:00Z"),
49
+ },
50
+ });
51
+
52
+ expect(identity.logoUrl).toBe(
53
+ `/api/branding/logo?v=${new Date("2026-08-01T00:00:00Z").getTime()}`,
54
+ );
55
+ });
56
+
57
+ it("tenantWithBranding thay tên + logo nhưng giữ phần còn lại", () => {
58
+ const identity = resolveBranding({
59
+ tenant,
60
+ company: {
61
+ systemName: "Spartronics",
62
+ // chuỗi ISO: đúng thứ nhận được khi hồ sơ đi qua serialize JSON
63
+ updatedAt: "2026-08-01T00:00:00.000Z",
64
+ },
65
+ });
66
+ const merged = tenantWithBranding(tenant, identity);
67
+
68
+ expect(merged.name).toBe("Spartronics");
69
+ expect(merged.branding.logo).toBe(identity.logoUrl);
70
+ expect(merged.branding.tagline).toBe("Nền tảng quản trị");
71
+ expect(merged.currency).toBe("VND");
72
+ });
73
+ });
74
+
75
+ describe("PWA", () => {
76
+ it("manifest cài được: start_url có locale, icon trỏ route branding", () => {
77
+ const identity = resolveBranding({
78
+ tenant,
79
+ company: { systemName: "Spartronics" },
80
+ themeColor: "#0f7b4c",
81
+ startUrl: "/vi",
82
+ });
83
+ const manifest = buildAppManifest(identity);
84
+
85
+ expect(manifest.display).toBe("standalone");
86
+ expect(manifest.start_url).toBe("/vi");
87
+ expect(manifest.scope).toBe("/");
88
+ expect(manifest.theme_color).toBe("#0f7b4c");
89
+ expect(manifest.icons?.map((i) => i.purpose)).toEqual(["any", "maskable"]);
90
+ expect(manifest.icons?.[0].src).toContain("/api/branding/logo?variant=");
91
+ });
92
+
93
+ it("metadata khai icons + manifest để đè convention app/icon.*", () => {
94
+ const identity = resolveBranding({ tenant, company: null });
95
+ const meta = buildBrandingMetadata(identity);
96
+
97
+ expect(meta.manifest).toBe("/manifest.webmanifest");
98
+ expect(JSON.stringify(meta.icons)).toContain("variant=favicon");
99
+ expect(JSON.stringify(meta.icons)).toContain("variant=apple");
100
+ });
101
+ });
@@ -0,0 +1,143 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { createBrandingLogoHandler } from "../logo-route";
4
+
5
+ /**
6
+ * Hợp đồng của `/api/branding/logo`. Hai điều KHÔNG được vỡ:
7
+ * 1. Không bao giờ 500 — favicon và sidebar gọi route này ở mọi trang, 500 là
8
+ * bơm rác vào `error_logs` và làm hỏng cả trang đăng nhập.
9
+ * 2. Chưa upload / file hỏng ⇒ rơi về logo mặc định trong `public/`.
10
+ */
11
+
12
+ const readFile = vi.hoisted(() => vi.fn());
13
+ vi.mock("node:fs/promises", () => ({ readFile, default: { readFile } }));
14
+
15
+ const FILE_BYTES = Buffer.from("logo-tu-kho");
16
+ const PUBLIC_BYTES = Buffer.from("logo-mac-dinh");
17
+
18
+ function request(url = "http://localhost/api/branding/logo") {
19
+ return new Request(url);
20
+ }
21
+
22
+ beforeEach(() => {
23
+ readFile.mockReset();
24
+ readFile.mockResolvedValue(PUBLIC_BYTES);
25
+ });
26
+
27
+ describe("createBrandingLogoHandler", () => {
28
+ it("phục vụ logo đã upload qua getFile, giữ đúng MIME theo đuôi key", async () => {
29
+ const getFile = vi.fn().mockResolvedValue(FILE_BYTES);
30
+ const GET = createBrandingLogoHandler({
31
+ getCompany: async () => ({
32
+ logoUrl: "/api/files/branding/logo%20cong%20ty.webp",
33
+ }),
34
+ getFile,
35
+ });
36
+
37
+ const res = await GET(request());
38
+
39
+ expect(getFile).toHaveBeenCalledWith("branding/logo cong ty.webp");
40
+ expect(res.status).toBe(200);
41
+ expect(res.headers.get("Content-Type")).toBe("image/webp");
42
+ expect(Buffer.from(await res.arrayBuffer())).toEqual(FILE_BYTES);
43
+ });
44
+
45
+ it("chưa upload ⇒ dùng ảnh mặc định dưới public/", async () => {
46
+ const GET = createBrandingLogoHandler({ getCompany: async () => null });
47
+
48
+ const res = await GET(request());
49
+
50
+ expect(res.status).toBe(200);
51
+ expect(String(readFile.mock.calls[0][0])).toContain(
52
+ "public/images/logos/goeat_logo.png",
53
+ );
54
+ expect(Buffer.from(await res.arrayBuffer())).toEqual(PUBLIC_BYTES);
55
+ });
56
+
57
+ it("key trỏ file đã xoá ⇒ rơi về mặc định chứ không 500", async () => {
58
+ const GET = createBrandingLogoHandler({
59
+ getCompany: async () => ({ logoUrl: "/api/files/da-xoa.png" }),
60
+ getFile: async () => {
61
+ throw new Error("NoSuchKey");
62
+ },
63
+ });
64
+
65
+ const res = await GET(request());
66
+
67
+ expect(res.status).toBe(200);
68
+ expect(Buffer.from(await res.arrayBuffer())).toEqual(PUBLIC_BYTES);
69
+ });
70
+
71
+ it("DB chập chờn ⇒ vẫn trả ảnh mặc định", async () => {
72
+ const GET = createBrandingLogoHandler({
73
+ getCompany: async () => {
74
+ throw new Error("connection refused");
75
+ },
76
+ });
77
+
78
+ expect((await GET(request())).status).toBe(200);
79
+ });
80
+
81
+ it("không có cả ảnh mặc định ⇒ 404, không ném lỗi", async () => {
82
+ readFile.mockRejectedValue(new Error("ENOENT"));
83
+ const GET = createBrandingLogoHandler({ getCompany: async () => null });
84
+
85
+ expect((await GET(request())).status).toBe(404);
86
+ });
87
+
88
+ it("?variant= gọi sharp và trả PNG; nhận module có default", async () => {
89
+ const resized = Buffer.from("png-da-resize");
90
+ const chain = {
91
+ resize: vi.fn(() => chain),
92
+ composite: vi.fn(() => chain),
93
+ png: vi.fn(() => chain),
94
+ toBuffer: vi.fn(async () => resized),
95
+ };
96
+ const sharp = vi.fn(() => chain);
97
+ const GET = createBrandingLogoHandler({
98
+ getCompany: async () => null,
99
+ loadSharp: async () => ({ default: sharp }),
100
+ });
101
+
102
+ const res = await GET(
103
+ request("http://localhost/api/branding/logo?variant=apple"),
104
+ );
105
+
106
+ expect(res.status).toBe(200);
107
+ expect(res.headers.get("Content-Type")).toBe("image/png");
108
+ expect(Buffer.from(await res.arrayBuffer())).toEqual(resized);
109
+ // apple = 180×180, không padding
110
+ expect(chain.resize).toHaveBeenCalledWith(180, 180, expect.anything());
111
+ });
112
+
113
+ it("sharp lỗi ⇒ trả ảnh gốc thay vì 500", async () => {
114
+ const GET = createBrandingLogoHandler({
115
+ getCompany: async () => null,
116
+ loadSharp: async () => {
117
+ throw new Error("sharp chưa cài");
118
+ },
119
+ });
120
+
121
+ const res = await GET(
122
+ request("http://localhost/api/branding/logo?variant=favicon"),
123
+ );
124
+
125
+ expect(res.status).toBe(200);
126
+ expect(Buffer.from(await res.arrayBuffer())).toEqual(PUBLIC_BYTES);
127
+ });
128
+
129
+ it("variant lạ ⇒ bỏ qua, không gọi sharp", async () => {
130
+ const sharp = vi.fn();
131
+ const GET = createBrandingLogoHandler({
132
+ getCompany: async () => null,
133
+ loadSharp: async () => sharp,
134
+ });
135
+
136
+ const res = await GET(
137
+ request("http://localhost/api/branding/logo?variant=../../etc/passwd"),
138
+ );
139
+
140
+ expect(res.status).toBe(200);
141
+ expect(sharp).not.toHaveBeenCalled();
142
+ });
143
+ });
@@ -0,0 +1,74 @@
1
+ import type { TenantConfig } from "../providers/tenant-provider";
2
+ import type { BrandingCompany, BrandingIdentity } from "./types";
3
+
4
+ export const DEFAULT_BRANDING_LOGO_PATH = "/api/branding/logo";
5
+
6
+ export interface ResolveBrandingOptions {
7
+ /** Hồ sơ công ty đọc từ DB (`getSystemCompany()`). */
8
+ company?: BrandingCompany | null;
9
+ /** Cấu hình tĩnh trong `src/configs/tenant.ts` — làm nền khi DB còn trống. */
10
+ tenant: TenantConfig;
11
+ /** Đổi nếu app đặt route logo ở đường khác. */
12
+ logoPath?: string;
13
+ themeColor?: string;
14
+ backgroundColor?: string;
15
+ /** `start_url` của PWA — PHẢI có locale sẵn (`/vi`), tránh 2 chặng redirect. */
16
+ startUrl?: string;
17
+ }
18
+
19
+ /**
20
+ * Hoà giải thương hiệu: **DB đè cấu hình tĩnh**.
21
+ *
22
+ * `systemName` (tên ngắn hiển thị app) tách khỏi `name` (tên pháp lý in trên
23
+ * hoá đơn) có chủ đích — sidebar và tên PWA không thể là "CÔNG TY TNHH ..."
24
+ * dài ba dòng.
25
+ *
26
+ * `logoUrl` LUÔN trỏ route `/api/branding/logo`, không bao giờ trỏ thẳng file
27
+ * trong `public/`: đổi logo một lần trong DB là mọi bề mặt đổi theo. `?v=` lấy
28
+ * từ `updatedAt` để phá cache trình duyệt ngay sau khi lưu.
29
+ */
30
+ export function resolveBranding(
31
+ options: ResolveBrandingOptions,
32
+ ): BrandingIdentity {
33
+ const { company, tenant } = options;
34
+ const logoPath = options.logoPath ?? DEFAULT_BRANDING_LOGO_PATH;
35
+
36
+ const companyName =
37
+ company?.name?.trim() || tenant.branding.companyName || tenant.name;
38
+ const appName = company?.systemName?.trim() || companyName;
39
+
40
+ const stamp = company?.updatedAt
41
+ ? new Date(company.updatedAt).getTime()
42
+ : null;
43
+
44
+ return {
45
+ appName,
46
+ companyName,
47
+ tagline: tenant.branding.tagline ?? "",
48
+ logoPath,
49
+ logoUrl: stamp ? `${logoPath}?v=${stamp}` : logoPath,
50
+ themeColor: options.themeColor ?? "#ffffff",
51
+ backgroundColor: options.backgroundColor ?? "#ffffff",
52
+ startUrl: options.startUrl ?? "/",
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Bản `TenantConfig` đã áp thương hiệu từ DB, để truyền thẳng vào
58
+ * `TenantProvider` — sidebar, header và trang đăng nhập đọc qua
59
+ * `useOptionalTenant()`.
60
+ */
61
+ export function tenantWithBranding(
62
+ tenant: TenantConfig,
63
+ identity: BrandingIdentity,
64
+ ): TenantConfig {
65
+ return {
66
+ ...tenant,
67
+ name: identity.appName,
68
+ branding: {
69
+ ...tenant.branding,
70
+ logo: identity.logoUrl,
71
+ companyName: identity.appName,
72
+ },
73
+ };
74
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * `@goerp/core/branding` — thương hiệu chạy bằng DỮ LIỆU, không bằng file tĩnh.
3
+ *
4
+ * Admin đổi logo/tên ở trang "Hồ sơ công ty" ⇒ sidebar, trang đăng nhập, đầu
5
+ * trang in, favicon, icon PWA và thẻ chia sẻ đổi theo, vì tất cả cùng trỏ
6
+ * `/api/branding/logo`. Chưa upload gì thì rơi về logo GoEat mặc định.
7
+ *
8
+ * Barrel này CHỈ dùng ở phía server (route handler, layout, manifest) — nó kéo
9
+ * `node:fs`.
10
+ */
11
+
12
+ export {
13
+ DEFAULT_BRANDING_FALLBACK,
14
+ createBrandingLogoHandler,
15
+ } from "./logo-route";
16
+ export type {
17
+ BrandingLogoOptions,
18
+ SharpFactory,
19
+ SharpLike,
20
+ } from "./logo-route";
21
+
22
+ export {
23
+ DEFAULT_BRANDING_LOGO_PATH,
24
+ resolveBranding,
25
+ tenantWithBranding,
26
+ } from "./identity";
27
+ export type { ResolveBrandingOptions } from "./identity";
28
+
29
+ export { PwaMetaTags, buildAppManifest, buildBrandingMetadata } from "./pwa";
30
+ export type { AppManifestOptions } from "./pwa";
31
+
32
+ export type {
33
+ BrandingCompany,
34
+ BrandingIdentity,
35
+ BrandingLogoVariant,
36
+ } from "./types";
@@ -0,0 +1,249 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join, normalize } from "node:path";
3
+
4
+ import { logger } from "../utils";
5
+
6
+ import type { BrandingCompany, BrandingLogoVariant } from "./types";
7
+
8
+ /**
9
+ * `GET /api/branding/logo` — MỘT URL duy nhất cho mọi bề mặt thương hiệu.
10
+ *
11
+ * Trước đây mỗi app tự viết lại route này (vinhhoa, thingtodo) còn app init từ
12
+ * core thì KHÔNG có: trang "Hồ sơ công ty" cho upload logo, lưu vào
13
+ * `companies.logo_url`, rồi không ai đọc — sidebar/favicon vẫn trỏ file tĩnh
14
+ * trong `public/`. Đây là mắt xích còn thiếu đó, lift lên core.
15
+ *
16
+ * Nguyên tắc:
17
+ * - **Công khai.** Trang đăng nhập và favicon render TRƯỚC khi có phiên, nên
18
+ * app phải khai `/api/branding` vào `publicPrefixes` của proxy. Route không
19
+ * đọc gì ngoài cột logo nên không mở thêm bề mặt dữ liệu.
20
+ * - **Không bao giờ 500.** Thiếu logo, hỏng file, sai key ⇒ 404. Favicon và
21
+ * sidebar gọi nó ở mọi trang; trả 500 là bơm rác vào `error_logs`.
22
+ * - **Chưa upload thì rơi về logo GoEat** trong `public/` của app (file
23
+ * `images/logos/goeat_logo.png` do `goerp-init` chép sẵn) — không phải logo
24
+ * của khách hàng khác còn sót lại từ lúc fork mã nguồn.
25
+ */
26
+
27
+ const WHITE = { r: 255, g: 255, b: 255, alpha: 1 };
28
+ const TRANSPARENT = { r: 0, g: 0, b: 0, alpha: 0 };
29
+
30
+ const VARIANTS: Record<
31
+ BrandingLogoVariant,
32
+ { w: number; h: number; bg: typeof WHITE; pad?: number }
33
+ > = {
34
+ favicon: { w: 64, h: 64, bg: TRANSPARENT },
35
+ apple: { w: 180, h: 180, bg: WHITE }, // iOS không ưa nền trong suốt
36
+ icon: { w: 512, h: 512, bg: TRANSPARENT }, // PWA purpose "any"
37
+ maskable: { w: 512, h: 512, bg: WHITE, pad: 56 }, // safe-zone ~80%
38
+ og: { w: 1200, h: 630, bg: WHITE, pad: 96 }, // thẻ chia sẻ mạng xã hội
39
+ };
40
+
41
+ const MIME: Record<string, string> = {
42
+ png: "image/png",
43
+ jpg: "image/jpeg",
44
+ jpeg: "image/jpeg",
45
+ webp: "image/webp",
46
+ gif: "image/gif",
47
+ svg: "image/svg+xml",
48
+ ico: "image/x-icon",
49
+ };
50
+
51
+ function mimeFor(path: string): string {
52
+ const ext = (path.split("?")[0].split(".").pop() || "").toLowerCase();
53
+ return MIME[ext] || "application/octet-stream";
54
+ }
55
+
56
+ /** Ảnh mặc định khi công ty chưa cấu hình logo riêng — nằm dưới `public/`. */
57
+ export const DEFAULT_BRANDING_FALLBACK = "images/logos/goeat_logo.png";
58
+
59
+ /**
60
+ * Kiểu tối thiểu của `sharp` mà route cần — khai theo CẤU TRÚC để core không
61
+ * phải phụ thuộc gói đó.
62
+ */
63
+ type SharpBackground = { r: number; g: number; b: number; alpha: number };
64
+ export interface SharpLike {
65
+ resize(
66
+ width: number,
67
+ height: number,
68
+ options: { fit: "contain"; background: SharpBackground },
69
+ ): SharpLike;
70
+ composite(items: Array<{ input: Buffer; gravity: string }>): SharpLike;
71
+ png(): SharpLike;
72
+ toBuffer(): Promise<Buffer>;
73
+ }
74
+ export type SharpFactory = (
75
+ input?:
76
+ | Buffer
77
+ | {
78
+ create: {
79
+ width: number;
80
+ height: number;
81
+ channels: 4;
82
+ background: SharpBackground;
83
+ };
84
+ },
85
+ ) => SharpLike;
86
+
87
+ export interface BrandingLogoOptions {
88
+ /** Đọc hồ sơ công ty. App cắm service của mình vào đây. */
89
+ getCompany: () => Promise<BrandingCompany | null | undefined>;
90
+ /**
91
+ * Đọc tập tin từ kho (`@goerp/core/storage` `getFile`). Bỏ trống thì logo lưu
92
+ * dạng `/api/files/<key>` sẽ không đọc được và route rơi về ảnh mặc định.
93
+ */
94
+ getFile?: (key: string) => Promise<Buffer>;
95
+ /**
96
+ * Nạp `sharp` để render `?variant=`. CỐ Ý để app truyền vào
97
+ * (`() => import("sharp")`): `import` chữ literal nằm trong mã app mới được
98
+ * Next trace vào bản standalone — core tự `import` bằng biến thì gói bị rơi
99
+ * khỏi Docker image. Bỏ trống ⇒ route trả ảnh gốc.
100
+ *
101
+ * Kiểu là `unknown` vì `sharp` có nhiều overload, khai theo cấu trúc thì mỗi
102
+ * app phải tự ép kiểu ở chỗ gọi. Nhận cả module namespace lẫn `default`.
103
+ */
104
+ loadSharp?: () => Promise<unknown>;
105
+ /** Ảnh mặc định (đường dẫn tương đối dưới `public/`). */
106
+ fallbackPublicPath?: string;
107
+ /** TTL cache trình duyệt. URL luôn kèm `?v=<updatedAt>` nên để dài được. */
108
+ cacheSeconds?: number;
109
+ }
110
+
111
+ async function readPublic(relPath: string): Promise<Buffer> {
112
+ // relPath nằm dưới public/. Giá trị đến từ DB do admin nhập nên rủi ro thấp,
113
+ // vẫn normalize để chặn path traversal.
114
+ const safe = normalize(relPath).replace(/^(\.\.([/\\]|$))+/, "");
115
+ return readFile(join(process.cwd(), "public", safe));
116
+ }
117
+
118
+ interface LoadedLogo {
119
+ buffer: Buffer;
120
+ contentType: string;
121
+ }
122
+
123
+ async function loadLogo(
124
+ options: BrandingLogoOptions,
125
+ ): Promise<LoadedLogo | null> {
126
+ const fallbackPath = options.fallbackPublicPath ?? DEFAULT_BRANDING_FALLBACK;
127
+
128
+ let configured: string | null = null;
129
+ try {
130
+ const company = await options.getCompany();
131
+ configured = company?.logoUrl ?? company?.logo ?? null;
132
+ } catch (error) {
133
+ // DB chập chờn không được phép làm mất favicon của cả app.
134
+ logger.warn("[branding] không đọc được hồ sơ công ty, dùng logo mặc định", {
135
+ error: error instanceof Error ? error.message : String(error),
136
+ });
137
+ }
138
+
139
+ if (configured) {
140
+ try {
141
+ const clean = configured.split("?")[0];
142
+ if (clean.startsWith("/api/files/")) {
143
+ const key = decodeURIComponent(clean.slice("/api/files/".length));
144
+ if (options.getFile) {
145
+ return {
146
+ buffer: await options.getFile(key),
147
+ contentType: mimeFor(key),
148
+ };
149
+ }
150
+ } else if (clean.startsWith("/")) {
151
+ // Ảnh nằm sẵn trong public/ (kho local kiểu cũ, hoặc file tĩnh).
152
+ return {
153
+ buffer: await readPublic(clean.slice(1)),
154
+ contentType: mimeFor(clean),
155
+ };
156
+ }
157
+ } catch (error) {
158
+ // Key trỏ file đã xoá / kho chưa cấu hình — rơi về mặc định, KHÔNG 500.
159
+ logger.warn("[branding] logo cấu hình không đọc được, dùng mặc định", {
160
+ logoUrl: configured,
161
+ error: error instanceof Error ? error.message : String(error),
162
+ });
163
+ }
164
+ }
165
+
166
+ try {
167
+ return {
168
+ buffer: await readPublic(fallbackPath),
169
+ contentType: mimeFor(fallbackPath),
170
+ };
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
175
+
176
+ async function renderVariant(
177
+ logo: LoadedLogo,
178
+ variant: BrandingLogoVariant,
179
+ sharp: SharpFactory,
180
+ ): Promise<LoadedLogo> {
181
+ const spec = VARIANTS[variant];
182
+ const pad = spec.pad ?? 0;
183
+ const inner = await sharp(logo.buffer)
184
+ .resize(spec.w - pad * 2, spec.h - pad * 2, {
185
+ fit: "contain",
186
+ background: spec.bg,
187
+ })
188
+ .toBuffer();
189
+
190
+ const buffer = await sharp({
191
+ create: {
192
+ width: spec.w,
193
+ height: spec.h,
194
+ channels: 4,
195
+ background: spec.bg,
196
+ },
197
+ })
198
+ .composite([{ input: inner, gravity: "centre" }])
199
+ .png()
200
+ .toBuffer();
201
+
202
+ return { buffer, contentType: "image/png" };
203
+ }
204
+
205
+ function isVariant(value: string | null): value is BrandingLogoVariant {
206
+ return value !== null && value in VARIANTS;
207
+ }
208
+
209
+ /**
210
+ * Dựng handler `GET` cho `app/api/branding/logo/route.ts`.
211
+ *
212
+ * Route PHẢI khai `export const dynamic = "force-dynamic"` — logo đổi ngay khi
213
+ * admin bấm Lưu, không được đóng băng vào build.
214
+ */
215
+ export function createBrandingLogoHandler(options: BrandingLogoOptions) {
216
+ const cacheSeconds = options.cacheSeconds ?? 3600;
217
+
218
+ return async function GET(request: Request): Promise<Response> {
219
+ const logo = await loadLogo(options);
220
+ if (!logo) {
221
+ // 404 chứ không 500: favicon/sidebar gọi route này ở mọi trang.
222
+ return new Response("Chưa cấu hình logo", { status: 404 });
223
+ }
224
+
225
+ const variant = new URL(request.url).searchParams.get("variant");
226
+ let output = logo;
227
+ if (isVariant(variant) && options.loadSharp) {
228
+ try {
229
+ const mod = await options.loadSharp();
230
+ const sharp = (
231
+ typeof mod === "function" ? mod : (mod as { default: unknown }).default
232
+ ) as SharpFactory;
233
+ output = await renderVariant(logo, variant, sharp);
234
+ } catch (error) {
235
+ logger.warn("[branding] resize thất bại, trả ảnh gốc", {
236
+ variant,
237
+ error: error instanceof Error ? error.message : String(error),
238
+ });
239
+ }
240
+ }
241
+
242
+ return new Response(new Uint8Array(output.buffer), {
243
+ headers: {
244
+ "Content-Type": output.contentType,
245
+ "Cache-Control": `public, max-age=${cacheSeconds}`,
246
+ },
247
+ });
248
+ };
249
+ }