Skip to content

Commit a43b78c

Browse files
SOIVclaude
andcommitted
feat(modules): Ledger 모듈 구현 및 모듈 시스템 인프라 정비
### Ledger 모듈 - modules/ledger/ 추가: 가계부 수입/지출 CRUD 백엔드 + 프론트엔드 - 백엔드: DB 마이그레이션, 카테고리/결제수단/항목 API, Zod 검증 - 프론트엔드: 월별 네비게이션, 요약 카드, DataTable, 항목 추가/수정/삭제 모달 ### 모듈 시스템 인프라 - module-registry: 마이그레이션 자동 실행, Express 5 req.path getter 대응 - loader: ModuleManifest에 displayName 추가 - app.ts: AppServices에 db 필드 추가 (모듈에 DB 주입) - core.ts: /core/modules/me API에 displayName 포함 ### 프론트엔드 - AppShell: MODULE_DISPLAY 하드코딩 제거, API displayName 자동 반영 - main.tsx: MODULE_ROUTES, LedgerView 렌더 연결 - vite.config.ts: modules/ 하위 파일용 @fieldstack/* alias 추가 - tsconfig.json: modules/ 파일 타입 체크용 paths 추가 ### 문서 - docs/v2_FINANCIAL-LEDGER/modules/01-development-guide.md 갱신 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 9cff82f commit a43b78c

20 files changed

Lines changed: 2028 additions & 332 deletions

File tree

apps/api/src/app.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ export interface AppServices {
3838
userAuth: UserAuthService;
3939
sharedLink: SharedLinkService;
4040
settings: SystemSettingsService;
41+
// 모듈 라우터에서 사용할 DB 프로바이더 (모듈이 @fieldstack/core를 직접 import하지 않아도 되도록)
42+
db: DbProvider;
4143
}
4244

4345
export function createApp(services?: AppServices) {
@@ -184,5 +186,5 @@ export async function initServices(db: DbProvider): Promise<AppServices> {
184186
const settings = new SystemSettingsService(db);
185187
const sharedLink = new SharedLinkService(db, settings, env.PUBLIC_URL ?? null);
186188

187-
return { jwtManager, whitelist, adminPin, totpService, userAuth, sharedLink, settings };
189+
return { jwtManager, whitelist, adminPin, totpService, userAuth, sharedLink, settings, db };
188190
}

apps/api/src/integration/smoke.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ describe("api integration smoke", () => {
77
const manifests: ModuleManifest[] = [
88
{
99
name: "ledger",
10+
displayName: "가계부",
1011
version: "1.0.0",
1112
enabled: true,
1213
dependencies: [],

apps/api/src/loader/index.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ describe("api module loader", () => {
5757
ledger,
5858
{
5959
name: "subscription",
60+
displayName: "구독 관리",
6061
version: "1.0.0",
6162
enabled: true,
6263
dependencies: [],

apps/api/src/loader/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export interface ModuleRoutes {
88

99
export interface ModuleManifest {
1010
name: string;
11+
displayName: string;
1112
version: string;
1213
enabled: boolean;
1314
dependencies: string[];
@@ -37,6 +38,7 @@ export function parseModuleJson(content: string): ModuleManifest {
3738

3839
return {
3940
name: parsed.name ?? "",
41+
displayName: parsed.displayName ?? parsed.name ?? "",
4042
version: parsed.version ?? "0.0.0",
4143
enabled: parsed.enabled ?? false,
4244
dependencies: parsed.dependencies ?? [],

apps/api/src/module-registry.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -80,17 +80,15 @@ export class ModuleRegistry {
8080
: record.basePath + '/';
8181

8282
if (req.path === record.basePath || req.path.startsWith(base)) {
83-
// sub-path를 라우터에 전달하기 위해 url 재작성
83+
// sub-path를 라우터에 전달하기 위해 url 재작성.
84+
// Express 5 / router 패키지에서 req.path는 req.url 파생 getter이므로
85+
// req.url만 변경하면 req.path가 자동으로 갱신된다.
8486
const originalUrl = req.url;
85-
const originalPath = req.path;
8687

8788
req.url = req.url.slice(record.basePath.length) || '/';
88-
(req as express.Request & { path: string }).path = originalPath.slice(record.basePath.length) || '/';
8989

9090
record.router(req, res, (err?: unknown) => {
91-
// 라우터가 처리하지 못하면 url 복원 후 다음 미들웨어로
9291
req.url = originalUrl;
93-
(req as express.Request & { path: string }).path = originalPath;
9492
next(err);
9593
});
9694
return;
@@ -105,7 +103,8 @@ export class ModuleRegistry {
105103

106104
interface ModuleRouterModule {
107105
default?: express.Router;
108-
createRouter?: (services: AppServices) => express.Router;
106+
// createRouter는 동기 또는 비동기 모두 허용 (모듈 마이그레이션 등 async 초기화 지원)
107+
createRouter?: (services: AppServices) => express.Router | Promise<express.Router>;
109108
}
110109

111110
/**
@@ -148,11 +147,21 @@ export async function loadModulesIntoRegistry(
148147
}
149148

150149
try {
150+
// apps/api는 @fieldstack/core에 접근 가능하므로 여기서 마이그레이션 실행
151+
const migrationsDir = path.join(modulesDir, reg.moduleName, 'backend', 'migrations');
152+
if (fs.existsSync(migrationsDir)) {
153+
const { FileMigrationRunner } = await import('@fieldstack/core');
154+
const runner = new FileMigrationRunner(services.db, reg.moduleName, migrationsDir);
155+
await runner.run();
156+
console.log(`[fieldstack][registry] migrations applied for module "${reg.moduleName}"`);
157+
}
158+
151159
const mod = (await import(routerFile)) as ModuleRouterModule;
152160
let router: express.Router | undefined;
153161

154162
if (typeof mod.createRouter === 'function') {
155-
router = mod.createRouter(services);
163+
// async createRouter도 지원
164+
router = await Promise.resolve(mod.createRouter(services));
156165
} else if (mod.default) {
157166
router = mod.default;
158167
}

apps/api/src/routes/core.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ export function createCoreRouter(services: AppServices): Router {
8383

8484
const result = registryModules.map((mod) => ({
8585
name: mod.name,
86+
displayName: mod.manifest.displayName,
8687
basePath: mod.basePath,
8788
version: mod.manifest.version,
8889
// user_modules 레코드 없으면 기본 활성

apps/web/src/components/AppShell.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { type ReactNode, useState, useEffect } from "react";
22
import "../styles/shell.css";
33

4-
export type RouteKey = "login" | "forgot-password" | "home" | "marketplace" | "admin" | "change-password";
4+
// 코어 라우트 + 설치된 모듈 이름도 RouteKey에 포함 (가계부: "ledger" 등)
5+
export type CoreRouteKey = "login" | "forgot-password" | "home" | "marketplace" | "admin" | "change-password";
6+
export type RouteKey = CoreRouteKey | string;
57

68
interface AppShellProps {
79
route: RouteKey;
@@ -16,6 +18,7 @@ interface AppShellProps {
1618

1719
interface SidebarModule {
1820
name: string;
21+
displayName: string;
1922
basePath: string;
2023
enabled: boolean;
2124
}
@@ -163,11 +166,12 @@ export function AppShell({
163166
<button
164167
type="button"
165168
className="shell-nav-item"
166-
data-label={mod.name}
169+
data-label={mod.displayName || mod.name}
170+
aria-current={route === mod.name ? "page" : undefined}
167171
onClick={() => { window.location.hash = mod.name; closeMobileMenu(); }}
168172
>
169173
<span className="shell-nav-icon" aria-hidden="true">📦</span>
170-
<span className="shell-nav-text">{mod.name}</span>
174+
<span className="shell-nav-text">{mod.displayName || mod.name}</span>
171175
</button>
172176
</li>
173177
))

apps/web/src/main.tsx

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,21 @@ import { MarketplaceView } from "./views/MarketplaceView";
1616
import { ChangePasswordView } from "./views/ChangePasswordView";
1717
import { ForgotPasswordView } from "./views/ForgotPasswordView";
1818
import { SetupWizardView } from "./views/SetupWizardView";
19+
import { LedgerView } from "../../../modules/ledger/frontend/LedgerView";
1920

2021
// ─── Helpers ──────────────────────────────────────────────────
21-
const WEB_BOOTSTRAP_MESSAGE = "Fieldstack Web bootstrap initialized";
22+
23+
// 코어 라우트 목록 (앱 shell 없이 전체 화면으로 렌더되는 것 제외)
24+
const CORE_ROUTES = ["login", "forgot-password", "home", "marketplace", "admin", "change-password"] as const;
25+
// 모듈 라우트 — module.json name 기준 (서버 레지스트리와 일치)
26+
const MODULE_ROUTES: string[] = ["ledger"];
2227

2328
function getRouteFromHash(rawHash: string): RouteKey {
2429
const hash = rawHash.replace("#", "");
2530
if (hash === "settings") return "home";
26-
const valid: RouteKey[] = ["login", "forgot-password", "home", "marketplace", "admin", "change-password"];
27-
return (valid as string[]).includes(hash) ? (hash as RouteKey) : "login";
31+
if ((CORE_ROUTES as readonly string[]).includes(hash)) return hash as RouteKey;
32+
if (MODULE_ROUTES.includes(hash)) return hash as RouteKey;
33+
return "login";
2834
}
2935

3036
// ─── Theme ────────────────────────────────────────────────────
@@ -63,16 +69,15 @@ const SS = {
6369
} as const;
6470

6571
const LS = {
66-
theme: "fs_theme",
6772
firstVisitShown: "fs_first_visit_shown",
6873
startupRoute: "fs_startup_route",
6974
} as const;
7075

71-
// 딥 링크: 비인증 상태에서 진입한 app route 반환
76+
// 딥 링크: 비인증 상태에서 진입한 app route 반환 (모듈 라우트 포함)
7277
function getDeepLinkTarget(): RouteKey | null {
7378
const hash = window.location.hash.replace("#", "");
74-
const appRoutes: RouteKey[] = ["home", "marketplace", "admin"];
75-
return (appRoutes as string[]).includes(hash) ? (hash as RouteKey) : null;
79+
const appRoutes: string[] = ["home", "marketplace", "admin", ...MODULE_ROUTES];
80+
return appRoutes.includes(hash) ? (hash as RouteKey) : null;
7681
}
7782

7883
// 개인화: 로그인 후 첫 화면 설정
@@ -469,6 +474,8 @@ function App() {
469474
onRequestPin={() => setIsPinModalOpen(true)}
470475
/>
471476
)}
477+
{/* ── 모듈 뷰 ────────────────────────────────────── */}
478+
{effectiveRoute === "ledger" && <LedgerView />}
472479
{isSettingsOpen && (
473480
<SettingsView
474481
theme={theme}
@@ -533,7 +540,7 @@ function AppRoot() {
533540
}
534541

535542
// ─── Bootstrap ────────────────────────────────────────────────
536-
console.log(WEB_BOOTSTRAP_MESSAGE);
543+
console.log("Fieldstack Web bootstrap initialized");
537544

538545
const appRootElement = document.querySelector<HTMLDivElement>("#app");
539546
if (appRootElement === null) {

apps/web/tsconfig.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@
33
"compilerOptions": {
44
"jsx": "react-jsx",
55
"moduleResolution": "Bundler",
6-
"types": []
6+
"types": [],
7+
"paths": {
8+
"@fieldstack/controls": ["./node_modules/@fieldstack/controls"],
9+
"@fieldstack/controls/*": ["./node_modules/@fieldstack/controls/*"],
10+
"@fieldstack/core/browser": ["./node_modules/@fieldstack/core/browser"],
11+
"@fieldstack/core/browser/*": ["./node_modules/@fieldstack/core/browser/*"]
12+
}
713
},
814
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"]
915
}

apps/web/vite.config.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,27 @@
1+
import path from "path";
2+
import { fileURLToPath } from "url";
3+
14
import { defineConfig } from "vite";
25

6+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
7+
8+
// modules/ 하위 파일들은 apps/web/node_modules에 접근할 수 없어
9+
// Vite가 @fieldstack/* 패키지를 찾지 못한다.
10+
// alias로 각 export 경로를 명시한다.
11+
// 주의: 서브패스(controls/styles)를 베이스(controls)보다 먼저 선언해야
12+
// prefix 매칭 순서가 올바르게 동작한다.
13+
const WEB_NODE_MODULES = path.resolve(__dirname, "node_modules");
14+
315
export default defineConfig({
16+
resolve: {
17+
alias: {
18+
"@fieldstack/controls/styles": path.join(WEB_NODE_MODULES, "@fieldstack/controls/src/styles/index.css"),
19+
"@fieldstack/controls": path.join(WEB_NODE_MODULES, "@fieldstack/controls/dist/index.js"),
20+
"@fieldstack/core/browser": path.join(WEB_NODE_MODULES, "@fieldstack/core/dist/browser.js"),
21+
},
22+
},
423
server: {
5-
host: true, // 0.0.0.0 — 로컬 네트워크에서 접속 가능
24+
host: true,
625
proxy: {
726
"/setup": "http://localhost:3000",
827
"/core": "http://localhost:3000",

0 commit comments

Comments
 (0)