diff --git a/README.md b/README.md index 2757fdc8..9c51fd0e 100644 --- a/README.md +++ b/README.md @@ -133,12 +133,31 @@ OmegaBot is online │ │ └── OmegaBot.yml │ └── pull_request_template.md ├── .husky +│ ├── _ +│ │ ├── .gitignore +│ │ ├── applypatch-msg +│ │ ├── commit-msg +│ │ ├── h +│ │ ├── husky.sh +│ │ ├── post-applypatch +│ │ ├── post-checkout +│ │ ├── post-commit +│ │ ├── post-merge +│ │ ├── post-rewrite +│ │ ├── pre-applypatch +│ │ ├── pre-auto-gc +│ │ ├── pre-commit +│ │ ├── pre-merge-commit +│ │ ├── pre-push +│ │ ├── pre-rebase +│ │ └── prepare-commit-msg │ ├── pre-commit │ └── pre-push ├── assets │ ├── banner.png │ └── omegabot.png ├── data +│ ├── faqs.json │ ├── last-seen.json │ └── timezones.json ├── docs @@ -183,7 +202,9 @@ OmegaBot is online │ │ │ ├── fetchChannelMessages.ts │ │ │ └── interactionHandler.ts │ │ ├── faq -│ │ │ └── type.ts +│ │ │ ├── faqService.ts +│ │ │ ├── store.ts +│ │ │ └── types.ts │ │ ├── github │ │ │ ├── githubApi.ts │ │ │ ├── githubClient.ts diff --git a/src/services/faq/faqService.ts b/src/services/faq/faqService.ts new file mode 100644 index 00000000..32b59ae7 --- /dev/null +++ b/src/services/faq/faqService.ts @@ -0,0 +1,176 @@ +// src/services/faq/services.ts + +import { loadStore, saveStore } from "./store.js"; +import { MAX_KEY_LEN, type CreateFaqInput, type FaqEntry } from "./types.js"; + +/* -------------------------------------------------------------------------- */ +/* Public API */ +/* -------------------------------------------------------------------------- */ + +/** + * Return all FAQ entries. + * + * Note: + * - Returns values only (does not guarantee order). + * - Caller can sort if needed (ex: by usageCount or updatedAt). + */ +export function getAll(): FaqEntry[] { + const store = loadStore(); + return Object.values(store.entries); +} + +/** + * Lookup an FAQ by key. + * + * The key is normalized the same way as create/update/remove. + */ +export function getByKey(key: string): FaqEntry | null { + const store = loadStore(); + const k = assertValidKey(key); + return store.entries[k] ?? null; +} + +/** + * Create a new FAQ entry. + * + * Throws if: + * - key normalizes to empty + * - key is too long + * - key already exists + */ +export function create(input: CreateFaqInput): FaqEntry { + const store = loadStore(); + + const key = assertValidKey(input.key); + if (store.entries[key]) { + throw new Error(`FAQ with this key already exists: ${key}`); + } + + const now = new Date().toISOString(); + + const entry: FaqEntry = { + key, + title: input.title.trim(), + body: input.body.trim(), + tags: normalizeTags(input.tags ?? []), + createdAt: now, + updatedAt: now, + createdBy: input.actor, + updatedBy: input.actor, + usageCount: 0, + }; + + store.entries[key] = entry; + saveStore(store); + + return entry; +} + +/** + * Allowed update fields for an FAQ. + * actor is required so we can attribute updatedBy. + */ +export type UpdateFaqPatch = Partial> & { + actor: string; +}; + +/** + * Update an existing FAQ entry by key. + * + * Throws if: + * - key invalid + * - entry not found + */ +export function update(key: string, patch: UpdateFaqPatch): FaqEntry { + const k = assertValidKey(key); + + const store = loadStore(); + const existing = store.entries[k]; + + if (!existing) { + throw new Error(`FAQ not found: ${k}`); + } + + // Apply patch (only touch provided fields). + if (patch.title !== undefined) existing.title = patch.title.trim(); + if (patch.body !== undefined) existing.body = patch.body.trim(); + if (patch.tags !== undefined) existing.tags = normalizeTags(patch.tags); + + existing.updatedAt = new Date().toISOString(); + existing.updatedBy = patch.actor; + + store.entries[k] = existing; + saveStore(store); + + return existing; +} + +/** + * Remove an FAQ by key. + * + * Returns: + * - true if removed + * - false if it did not exist + */ +export function remove(key: string): boolean { + const store = loadStore(); + const k = assertValidKey(key); + + if (!store.entries[k]) return false; + + delete store.entries[k]; + saveStore(store); + return true; +} + +/* -------------------------------------------------------------------------- */ +/* Helpers */ +/* -------------------------------------------------------------------------- */ + +/** + * Normalize an FAQ key into a stable URL-ish slug. + * + * Example: + * "How do I reset my token?" -> "how-do-i-reset-my-token" + */ +function normalizeKey(key: string): string { + return key + .trim() + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/[^a-z0-9-_]/g, ""); +} + +/** + * Validate and return the normalized key. + * + * Keep this throwing: + * - Callers can catch at the command layer and reply nicely. + * - Services stay strict and predictable. + */ +function assertValidKey(key: string): string { + const k = normalizeKey(key); + + if (k.length === 0) { + throw new Error("FAQ key is empty after normalization"); + } + + if (k.length > MAX_KEY_LEN) { + throw new Error(`FAQ key too long (max ${MAX_KEY_LEN})`); + } + + return k; +} + +/** + * Normalize tags for consistency: + * - trim + * - lowercase + * - remove empties + * - de-dupe + */ +function normalizeTags(tags: string[]): string[] { + const cleaned = tags.map((t) => t.trim().toLowerCase()).filter((t) => t.length > 0); + + return Array.from(new Set(cleaned)); +} diff --git a/src/services/faq/store.ts b/src/services/faq/store.ts new file mode 100644 index 00000000..691376f8 --- /dev/null +++ b/src/services/faq/store.ts @@ -0,0 +1,83 @@ +// src/services/faq/store.ts + +import fs from "fs"; +import path from "path"; +import { logger } from "../../utils/logger.js"; +import type { FaqStoreV1 } from "./types.js"; + +/** + * Absolute path to the FAQ data file. + * Stored outside src/ so it persists across builds. + */ +const STORE_PATH = path.join(process.cwd(), "data", "faqs.json"); + +/** + * Empty FAQ store template (schema v1). + * Used on first run or when recovery is required. + */ +const EMPTY_STORE: FaqStoreV1 = { + version: 1, + entries: {}, +}; + +/** + * Ensure the FAQ store file exists on disk. + * + * - Creates parent directories if missing + * - Writes an empty store if the file does not exist + * - Safe to call multiple times + */ +export function ensureStoreFile(): void { + if (fs.existsSync(STORE_PATH)) { + return; + } + + fs.mkdirSync(path.dirname(STORE_PATH), { recursive: true }); + fs.writeFileSync(STORE_PATH, JSON.stringify(EMPTY_STORE, null, 2), "utf8"); + + logger.info("[faq] created empty FAQ store"); +} + +/** + * Load the FAQ store from disk. + * + * Behavior: + * - Ensures the store file exists + * - Parses JSON from disk + * - Validates basic schema shape + * - Falls back to an empty store on error + */ +export function loadStore(): FaqStoreV1 { + ensureStoreFile(); + + try { + const raw = fs.readFileSync(STORE_PATH, "utf8"); + const parsed = JSON.parse(raw) as FaqStoreV1; + + if ( + parsed.version !== 1 || + typeof parsed.entries !== "object" || + parsed.entries === null + ) { + throw new Error("Invalid FAQ store shape"); + } + + return parsed; + } catch (err) { + logger.error({ err }, "[faq] failed to load store, falling back to empty store"); + + return { + version: 1, + entries: {}, + }; + } +} + +/** + * Persist the FAQ store back to disk. + * + * Overwrites the entire store atomically. + */ +export function saveStore(store: FaqStoreV1): void { + fs.writeFileSync(STORE_PATH, JSON.stringify(store, null, 2), "utf8"); +} diff --git a/src/services/faq/type.ts b/src/services/faq/type.ts deleted file mode 100644 index 6fbc3131..00000000 --- a/src/services/faq/type.ts +++ /dev/null @@ -1,16 +0,0 @@ -export type FaqEntry = { - key: string; - title: string; - body: string; - tags: string[]; - createdAt: string; - updatedAt: string; - createdBy: string; - updatedBy: string; - usageCount: number; -}; - -export type FaqStoreV1 = { - version: 1; - entries: Record; -}; diff --git a/src/services/faq/types.ts b/src/services/faq/types.ts new file mode 100644 index 00000000..011a7c87 --- /dev/null +++ b/src/services/faq/types.ts @@ -0,0 +1,74 @@ +// src/services/faq/types.ts + +/* -------------------------------------------------------------------------- */ +/* Constants */ +/* -------------------------------------------------------------------------- */ + +/** + * Maximum length of an FAQ key after normalization. + * + * Keys are used as: + * - slash command arguments + * - map keys in the JSON store + * - human-readable identifiers + * + * Keeping this short prevents abuse and awkward UX. + */ +export const MAX_KEY_LEN = 48; + +/* -------------------------------------------------------------------------- */ +/* Core Types */ +/* -------------------------------------------------------------------------- */ + +/** + * A single FAQ entry as stored and returned by the service layer. + * + * Notes: + * - `key` is normalized (lowercase, kebab-case). + * - timestamps are ISO strings for easy JSON storage. + * - usageCount is incremented when an FAQ is retrieved. + */ +export type FaqEntry = { + key: string; + title: string; + body: string; + tags: string[]; + + createdAt: string; + updatedAt: string; + + createdBy: string; // user id or "system" + updatedBy: string; // user id or "system" + + usageCount: number; +}; + +/** + * On-disk FAQ store format (v1). + * + * Versioning lets us migrate later without breaking users. + */ +export type FaqStoreV1 = { + version: 1; + entries: Record; +}; + +/* -------------------------------------------------------------------------- */ +/* Input Types */ +/* -------------------------------------------------------------------------- */ + +/** + * Input required to create a new FAQ. + * + * Validation rules: + * - key is normalized and validated by the service layer + * - title/body are trimmed + * - tags are optional and normalized if provided + */ +export type CreateFaqInput = { + key: string; + title: string; + body: string; + tags?: string[]; + actor: string; // user id or "system" +};