From b4d214d9c37e1bbdf214c6936aa079320033262b Mon Sep 17 00:00:00 2001 From: "Kyryll O." Date: Wed, 3 Jun 2026 15:41:48 +0300 Subject: [PATCH] feat: add HelpModule (HelpController, HelpService) for standardized FAQ/HELP system HelpModule auto-discovers articles from help/*.md files or articles.json. Exposes GET /api/help (articles list) and GET /api/help/:id (markdown content). Supports i18n via {id}.{lang}.md file pattern. --- package-lock.json | 4 +- package.json | 2 +- src/backend/help/help.controller.ts | 25 ++++++ src/backend/help/help.module.ts | 10 +++ src/backend/help/help.service.ts | 130 ++++++++++++++++++++++++++++ src/backend/help/index.ts | 3 + src/index.ts | 1 + 7 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 src/backend/help/help.controller.ts create mode 100644 src/backend/help/help.module.ts create mode 100644 src/backend/help/help.service.ts create mode 100644 src/backend/help/index.ts diff --git a/package-lock.json b/package-lock.json index 088151c..e1178c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@erp/plugin-sdk", - "version": "1.9.1", + "version": "1.10.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@erp/plugin-sdk", - "version": "1.9.1", + "version": "1.10.1", "license": "ISC", "dependencies": { "uuid": "^9.0.1" diff --git a/package.json b/package.json index 7e0f247..4bba7fa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@erp/plugin-sdk", - "version": "1.10.0", + "version": "1.10.1", "main": "dist/index.js", "types": "dist/index.d.ts", "bin": { diff --git a/src/backend/help/help.controller.ts b/src/backend/help/help.controller.ts new file mode 100644 index 0000000..e267373 --- /dev/null +++ b/src/backend/help/help.controller.ts @@ -0,0 +1,25 @@ +import { Controller, Get, Param, Query } from '@nestjs/common'; +import { HelpService, HelpArticle } from './help.service'; + +@Controller('api/help') +export class HelpController { + constructor(private readonly helpService: HelpService) {} + + @Get() + getArticles(): { articles: HelpArticle[] } { + return { articles: this.helpService.getArticles() }; + } + + @Get(':id') + getArticle( + @Param('id') id: string, + @Query('lang') lang?: string, + ): string { + const language = lang || 'en'; + const content = this.helpService.getArticleContent(id, language); + if (content === null) { + return `# Not Found\nHelp article "${id}" not found for language "${language}".`; + } + return content; + } +} diff --git a/src/backend/help/help.module.ts b/src/backend/help/help.module.ts new file mode 100644 index 0000000..ba3b8ff --- /dev/null +++ b/src/backend/help/help.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { HelpController } from './help.controller'; +import { HelpService } from './help.service'; + +@Module({ + controllers: [HelpController], + providers: [HelpService], + exports: [HelpService], +}) +export class HelpModule {} diff --git a/src/backend/help/help.service.ts b/src/backend/help/help.service.ts new file mode 100644 index 0000000..069a807 --- /dev/null +++ b/src/backend/help/help.service.ts @@ -0,0 +1,130 @@ +import { Injectable, Logger } from '@nestjs/common'; +import * as fs from 'fs'; +import * as path from 'path'; + +export interface HelpArticle { + id: string; + title: string | Record; + description?: string | Record; +} + +export interface HelpArticlesJson { + articles: HelpArticle[]; +} + +@Injectable() +export class HelpService { + private readonly logger = new Logger(HelpService.name); + private readonly helpDir: string; + private cachedArticles: HelpArticle[] | null = null; + + constructor() { + this.helpDir = path.resolve(process.cwd(), 'help'); + } + + getArticles(): HelpArticle[] { + if (this.cachedArticles) return this.cachedArticles; + + if (!fs.existsSync(this.helpDir)) { + this.logger.warn(`Help directory not found: ${this.helpDir}`); + this.cachedArticles = []; + return this.cachedArticles; + } + + // Try articles.json first + const jsonPath = path.join(this.helpDir, 'articles.json'); + if (fs.existsSync(jsonPath)) { + try { + const raw = fs.readFileSync(jsonPath, 'utf-8'); + const parsed: HelpArticlesJson = JSON.parse(raw); + if (parsed.articles && Array.isArray(parsed.articles)) { + this.cachedArticles = parsed.articles; + return this.cachedArticles; + } + } catch (e: any) { + this.logger.error(`Failed to parse articles.json: ${e.message}`); + } + } + + // Fallback: auto-discover from .md files + return this.discoverArticlesFromFiles(); + } + + getArticleContent(id: string, lang: string): string | null { + if (!fs.existsSync(this.helpDir)) return null; + + // Try lang-specific file first: help/{id}.{lang}.md + const langPath = path.join(this.helpDir, `${id}.${lang}.md`); + if (fs.existsSync(langPath)) { + return fs.readFileSync(langPath, 'utf-8'); + } + + // Fallback to .en.md + const enPath = path.join(this.helpDir, `${id}.en.md`); + if (fs.existsSync(enPath)) { + return fs.readFileSync(enPath, 'utf-8'); + } + + // Fallback to plain {id}.md + const plainPath = path.join(this.helpDir, `${id}.md`); + if (fs.existsSync(plainPath)) { + return fs.readFileSync(plainPath, 'utf-8'); + } + + // Fallback to any matching file + try { + const files = fs.readdirSync(this.helpDir); + const match = files.find(f => f.startsWith(`${id}.`) && f.endsWith('.md')); + if (match) { + return fs.readFileSync(path.join(this.helpDir, match), 'utf-8'); + } + } catch (e) {} + + return null; + } + + clearCache(): void { + this.cachedArticles = null; + } + + private discoverArticlesFromFiles(): HelpArticle[] { + try { + const files = fs.readdirSync(this.helpDir).filter(f => f.endsWith('.md')); + const seen = new Set(); + + const articles: HelpArticle[] = []; + + for (const file of files) { + // Pattern: {id}.{lang}.md or {id}.md + const baseName = file.replace(/\.md$/, ''); + const parts = baseName.split('.'); + let id: string; + + if (parts.length >= 2 && parts[parts.length - 1].length === 2) { + // Has language suffix: getting-started.en.md + id = parts.slice(0, -1).join('.'); + } else { + // Plain name: getting-started.md + id = baseName; + } + + if (seen.has(id)) continue; + seen.add(id); + + // Extract title from first h1 + const content = fs.readFileSync(path.join(this.helpDir, file), 'utf-8'); + const titleMatch = content.match(/^#\s+(.+)$/m); + const title = titleMatch ? titleMatch[1].trim() : id; + const descMatch = content.match(/^>\s+(.+)$/m); + const description = descMatch ? descMatch[1].trim() : undefined; + + articles.push({ id, title, ...(description ? { description } : {}) }); + } + + return articles; + } catch (e: any) { + this.logger.error(`Failed to discover help articles: ${e.message}`); + return []; + } + } +} diff --git a/src/backend/help/index.ts b/src/backend/help/index.ts new file mode 100644 index 0000000..58f0269 --- /dev/null +++ b/src/backend/help/index.ts @@ -0,0 +1,3 @@ +export { HelpModule } from './help.module'; +export { HelpService, HelpArticle } from './help.service'; +export { HelpController } from './help.controller'; diff --git a/src/index.ts b/src/index.ts index 7ba311c..5ef0591 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,3 +9,4 @@ export * from './backend/interceptors/request-context.interceptor'; export * from './backend/interceptors/transform.interceptor'; export * from './backend/config/rabbitmq.config'; export * from './backend/services/attribute-access.service'; +export { HelpModule, HelpService } from './backend/help';