health-survey-cli 1.0.0

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.
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { password, input } from "@inquirer/prompts";
4
+ import { writeFileSync } from "fs";
5
+ import { loadConfig, saveConfig, CONFIG_PATH } from "../core/config.js";
6
+ import { fetchStats, fetchReportPdf, fetchLatestReportDate } from "../core/api.js";
7
+ import { formatStats, formatAlerts } from "../core/format.js";
8
+ const DEFAULT_API_URL = "https://health-bd.nonas.app";
9
+ const program = new Command();
10
+ program.name("bayan").description("Hospital satisfaction survey CLI").version("1.0.0");
11
+ // bayan setup
12
+ program
13
+ .command("setup")
14
+ .description("Configure API URL and API key")
15
+ .action(async () => {
16
+ const apiUrl = await input({ message: "API URL:", default: DEFAULT_API_URL });
17
+ const apiKey = await password({ message: "API key (sk_...):" });
18
+ saveConfig({ apiUrl, apiKey });
19
+ console.log(`✓ Config saved to ${CONFIG_PATH}`);
20
+ });
21
+ // bayan whoami
22
+ program
23
+ .command("whoami")
24
+ .description("Show current config")
25
+ .action(() => {
26
+ const config = loadConfig();
27
+ console.log(`API URL: ${config.apiUrl}`);
28
+ console.log(`API key: ${config.apiKey.slice(0, 8)}...`);
29
+ });
30
+ // bayan stats
31
+ program
32
+ .command("stats")
33
+ .description("Show satisfaction statistics")
34
+ .option("--period <period>", "7d, 30d, or all", "7d")
35
+ .action(async (opts) => {
36
+ try {
37
+ const config = loadConfig();
38
+ const data = await fetchStats(config, opts.period);
39
+ console.log(formatStats(data, opts.period));
40
+ }
41
+ catch (err) {
42
+ console.error(err instanceof Error ? err.message : String(err));
43
+ process.exit(1);
44
+ }
45
+ });
46
+ // bayan report
47
+ program
48
+ .command("report")
49
+ .description("Download a daily PDF report")
50
+ .option("--date <date>", "YYYY-MM-DD (defaults to latest available)")
51
+ .option("--latest", "Download the most recent available report")
52
+ .option("--output <path>", "Output file path")
53
+ .action(async (opts) => {
54
+ try {
55
+ const config = loadConfig();
56
+ const date = opts.date ??
57
+ (opts.latest
58
+ ? await fetchLatestReportDate(config)
59
+ : new Date().toISOString().split("T")[0]);
60
+ const outPath = opts.output ?? `report-${date}.pdf`;
61
+ const buffer = await fetchReportPdf(config, date);
62
+ writeFileSync(outPath, buffer);
63
+ console.log(`✓ Report saved to ${outPath}`);
64
+ }
65
+ catch (err) {
66
+ console.error(err instanceof Error ? err.message : String(err));
67
+ process.exit(1);
68
+ }
69
+ });
70
+ // bayan alerts
71
+ program
72
+ .command("alerts")
73
+ .description("Show critical alerts from the last 7 days")
74
+ .action(async () => {
75
+ try {
76
+ const config = loadConfig();
77
+ const data = await fetchStats(config, "7d");
78
+ console.log(formatAlerts(data));
79
+ }
80
+ catch (err) {
81
+ console.error(err instanceof Error ? err.message : String(err));
82
+ process.exit(1);
83
+ }
84
+ });
85
+ program.parse();
@@ -0,0 +1,62 @@
1
+ const TIMEOUT_MS = 10_000;
2
+ async function apiFetch(config, path) {
3
+ const controller = new AbortController();
4
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
5
+ try {
6
+ const res = await fetch(`${config.apiUrl}${path}`, {
7
+ headers: { Authorization: `Bearer ${config.apiKey}` },
8
+ signal: controller.signal,
9
+ });
10
+ if (res.status === 401)
11
+ throw new Error("Invalid API key. Run: bayan setup");
12
+ if (!res.ok) {
13
+ const text = await res.text().catch(() => res.statusText);
14
+ throw new Error(`API error ${res.status}: ${text}`);
15
+ }
16
+ return res.json();
17
+ }
18
+ catch (err) {
19
+ if (err instanceof Error && err.name === "AbortError") {
20
+ throw new Error("Request timed out. Check your connection.");
21
+ }
22
+ throw err;
23
+ }
24
+ finally {
25
+ clearTimeout(timer);
26
+ }
27
+ }
28
+ export async function fetchStats(config, period) {
29
+ return apiFetch(config, `/api/analytics?range=${period}`);
30
+ }
31
+ export async function fetchReportPdf(config, date) {
32
+ const controller = new AbortController();
33
+ const timer = setTimeout(() => controller.abort(), 30_000);
34
+ try {
35
+ const res = await fetch(`${config.apiUrl}/api/reports/${date}/pdf`, {
36
+ headers: { Authorization: `Bearer ${config.apiKey}` },
37
+ signal: controller.signal,
38
+ });
39
+ if (res.status === 401)
40
+ throw new Error("Invalid API key. Run: bayan setup");
41
+ if (res.status === 404)
42
+ throw new Error(`No report found for ${date}`);
43
+ if (!res.ok)
44
+ throw new Error(`Failed to download report: ${res.status}`);
45
+ return Buffer.from(await res.arrayBuffer());
46
+ }
47
+ catch (err) {
48
+ if (err instanceof Error && err.name === "AbortError") {
49
+ throw new Error("Report download timed out.");
50
+ }
51
+ throw err;
52
+ }
53
+ finally {
54
+ clearTimeout(timer);
55
+ }
56
+ }
57
+ export async function fetchLatestReportDate(config) {
58
+ const reports = await apiFetch(config, "/api/reports");
59
+ if (!reports.length)
60
+ throw new Error("No reports available yet.");
61
+ return reports[0].date;
62
+ }
@@ -0,0 +1,64 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { fetchStats, fetchReportPdf, fetchLatestReportDate } from "./api.js";
3
+ const mockConfig = { apiUrl: "https://test.example.com", apiKey: "sk_test" };
4
+ beforeEach(() => {
5
+ vi.resetAllMocks();
6
+ });
7
+ describe("fetchStats", () => {
8
+ it("throws on 401", async () => {
9
+ vi.stubGlobal("fetch", async () => ({ ok: false, status: 401, json: async () => ({}) }));
10
+ await expect(fetchStats(mockConfig, "7d")).rejects.toThrow("Invalid API key");
11
+ });
12
+ it("throws on network timeout", async () => {
13
+ vi.stubGlobal("fetch", () => new Promise((_, reject) => {
14
+ const err = new Error("aborted");
15
+ err.name = "AbortError";
16
+ setTimeout(() => reject(err), 0);
17
+ }));
18
+ await expect(fetchStats(mockConfig, "7d")).rejects.toThrow("timed out");
19
+ });
20
+ it("returns data on success", async () => {
21
+ const mockData = {
22
+ totalSubmissions: 10,
23
+ averageScore: 8,
24
+ nps: 50,
25
+ serviceScores: [],
26
+ criticalAlerts: [],
27
+ };
28
+ vi.stubGlobal("fetch", async () => ({ ok: true, status: 200, json: async () => mockData }));
29
+ const result = await fetchStats(mockConfig, "7d");
30
+ expect(result.totalSubmissions).toBe(10);
31
+ });
32
+ });
33
+ describe("fetchReportPdf", () => {
34
+ it("throws on 401", async () => {
35
+ vi.stubGlobal("fetch", async () => ({ ok: false, status: 401, arrayBuffer: async () => new ArrayBuffer(0) }));
36
+ await expect(fetchReportPdf(mockConfig, "2026-03-22")).rejects.toThrow("Invalid API key");
37
+ });
38
+ it("throws on 404", async () => {
39
+ vi.stubGlobal("fetch", async () => ({ ok: false, status: 404 }));
40
+ await expect(fetchReportPdf(mockConfig, "2026-03-22")).rejects.toThrow("No report found");
41
+ });
42
+ it("returns buffer on success", async () => {
43
+ const bytes = new Uint8Array([1, 2, 3]);
44
+ vi.stubGlobal("fetch", async () => ({ ok: true, status: 200, arrayBuffer: async () => bytes.buffer }));
45
+ const buf = await fetchReportPdf(mockConfig, "2026-03-22");
46
+ expect(buf).toBeInstanceOf(Buffer);
47
+ expect(buf.length).toBe(3);
48
+ });
49
+ });
50
+ describe("fetchLatestReportDate", () => {
51
+ it("throws when no reports available", async () => {
52
+ vi.stubGlobal("fetch", async () => ({ ok: true, status: 200, json: async () => [] }));
53
+ await expect(fetchLatestReportDate(mockConfig)).rejects.toThrow("No reports available");
54
+ });
55
+ it("returns the first report date", async () => {
56
+ vi.stubGlobal("fetch", async () => ({
57
+ ok: true,
58
+ status: 200,
59
+ json: async () => [{ date: "2026-03-22" }, { date: "2026-03-15" }],
60
+ }));
61
+ const date = await fetchLatestReportDate(mockConfig);
62
+ expect(date).toBe("2026-03-22");
63
+ });
64
+ });
@@ -0,0 +1,20 @@
1
+ import { homedir } from "os";
2
+ import { join } from "path";
3
+ import { readFileSync, writeFileSync, existsSync } from "fs";
4
+ export const CONFIG_PATH = join(homedir(), ".health-survey-rc");
5
+ export function loadConfig() {
6
+ if (!existsSync(CONFIG_PATH)) {
7
+ console.error("Not configured. Run: bayan setup");
8
+ process.exit(1);
9
+ }
10
+ try {
11
+ return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
12
+ }
13
+ catch {
14
+ console.error("Config file is corrupted. Run: bayan setup");
15
+ process.exit(1);
16
+ }
17
+ }
18
+ export function saveConfig(config) {
19
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
20
+ }
@@ -0,0 +1,30 @@
1
+ export function formatStats(data, period) {
2
+ const lines = [
3
+ ``,
4
+ `📊 Hospital Satisfaction — last ${period}`,
5
+ ` Responses: ${data.totalSubmissions}`,
6
+ ` Avg score: ${data.averageScore}/10`,
7
+ ` NPS: ${data.nps}`,
8
+ ];
9
+ if (data.serviceScores.length > 0) {
10
+ lines.push(``, ` By service:`);
11
+ [...data.serviceScores]
12
+ .sort((a, b) => b.avgScore - a.avgScore)
13
+ .forEach((s) => lines.push(` ${s.service.padEnd(20)} ${s.avgScore}/10 (${s.count} responses)`));
14
+ }
15
+ if (data.criticalAlerts.length > 0) {
16
+ lines.push(``, `⚠️ Critical alerts: ${data.criticalAlerts.length}`);
17
+ data.criticalAlerts.forEach((a) => lines.push(` - ${a.message}`));
18
+ }
19
+ return lines.join("\n");
20
+ }
21
+ export function formatAlerts(data) {
22
+ if (data.criticalAlerts.length === 0) {
23
+ return "✓ No critical alerts in the last 7 days.";
24
+ }
25
+ const lines = [``, `⚠️ ${data.criticalAlerts.length} critical alert(s):`, ``];
26
+ data.criticalAlerts.forEach((a) => {
27
+ lines.push(` - ${a.message}${a.service ? ` [${a.service}]` : ""}${a.date ? ` — ${a.date}` : ""}`);
28
+ });
29
+ return lines.join("\n");
30
+ }
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { formatStats, formatAlerts } from "./format.js";
3
+ const mockData = {
4
+ totalSubmissions: 42,
5
+ averageScore: 7.8,
6
+ nps: 35,
7
+ serviceScores: [
8
+ { service: "Emergency", avgScore: 8.2, count: 20 },
9
+ { service: "Maternity", avgScore: 7.1, count: 22 },
10
+ ],
11
+ criticalAlerts: [],
12
+ };
13
+ describe("formatStats", () => {
14
+ it("shows key metrics", () => {
15
+ const out = formatStats(mockData, "7d");
16
+ expect(out).toContain("42");
17
+ expect(out).toContain("7.8/10");
18
+ expect(out).toContain("35");
19
+ });
20
+ it("sorts services by score descending", () => {
21
+ const out = formatStats(mockData, "7d");
22
+ expect(out.indexOf("Emergency")).toBeLessThan(out.indexOf("Maternity"));
23
+ });
24
+ it("shows alerts when present", () => {
25
+ const data = { ...mockData, criticalAlerts: [{ message: "Score below 5" }] };
26
+ expect(formatStats(data, "7d")).toContain("Score below 5");
27
+ });
28
+ });
29
+ describe("formatAlerts", () => {
30
+ it("returns ok message when no alerts", () => {
31
+ expect(formatAlerts(mockData)).toContain("No critical alerts");
32
+ });
33
+ it("lists alerts with service and date", () => {
34
+ const data = {
35
+ ...mockData,
36
+ criticalAlerts: [{ message: "Low score", service: "ER", date: "2026-03-21" }],
37
+ };
38
+ const out = formatAlerts(data);
39
+ expect(out).toContain("Low score");
40
+ expect(out).toContain("[ER]");
41
+ expect(out).toContain("2026-03-21");
42
+ });
43
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,50 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { z } from "zod";
4
+ import { writeFileSync } from "fs";
5
+ import { loadConfig } from "../core/config.js";
6
+ import { fetchStats, fetchReportPdf, fetchLatestReportDate } from "../core/api.js";
7
+ import { formatStats, formatAlerts } from "../core/format.js";
8
+ const server = new McpServer({
9
+ name: "health-survey",
10
+ version: "1.0.0",
11
+ });
12
+ server.tool("get_stats", "Get hospital satisfaction statistics", { period: z.enum(["7d", "30d", "all"]).default("7d").describe("Time period") }, async ({ period }) => {
13
+ const config = loadConfig();
14
+ const data = await fetchStats(config, period);
15
+ return { content: [{ type: "text", text: formatStats(data, period) }] };
16
+ });
17
+ server.tool("get_alerts", "Get critical satisfaction alerts from the last 7 days", {}, async () => {
18
+ const config = loadConfig();
19
+ const data = await fetchStats(config, "7d");
20
+ return { content: [{ type: "text", text: formatAlerts(data) }] };
21
+ });
22
+ server.tool("download_report", "Download a hospital satisfaction PDF report", {
23
+ date: z.string().optional().describe("YYYY-MM-DD — omit for latest"),
24
+ output: z.string().optional().describe("Output file path"),
25
+ }, async ({ date, output }) => {
26
+ const config = loadConfig();
27
+ const reportDate = date ?? (await fetchLatestReportDate(config));
28
+ const outPath = output ?? `report-${reportDate}.pdf`;
29
+ const buffer = await fetchReportPdf(config, reportDate);
30
+ writeFileSync(outPath, buffer);
31
+ return { content: [{ type: "text", text: `✓ Report saved to ${outPath}` }] };
32
+ });
33
+ server.tool("display_report", "Display a hospital satisfaction PDF report (returns base64 for rendering)", {
34
+ date: z.string().optional().describe("YYYY-MM-DD — omit for latest"),
35
+ }, async ({ date }) => {
36
+ const config = loadConfig();
37
+ const reportDate = date ?? (await fetchLatestReportDate(config));
38
+ const buffer = await fetchReportPdf(config, reportDate);
39
+ const base64 = buffer.toString("base64");
40
+ return {
41
+ content: [
42
+ {
43
+ type: "text",
44
+ text: `Hospital Satisfaction Report — ${reportDate}\n\nPDF (base64-encoded, ${buffer.length} bytes):\ndata:application/pdf;base64,${base64}`,
45
+ },
46
+ ],
47
+ };
48
+ });
49
+ const transport = new StdioServerTransport();
50
+ await server.connect(transport);
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "health-survey-cli",
3
+ "version": "1.0.0",
4
+ "description": "CLI and MCP server for hospital satisfaction survey data",
5
+ "type": "module",
6
+ "bin": {
7
+ "bayan": "./dist/cli/index.js"
8
+ },
9
+ "exports": {
10
+ "./mcp": "./dist/mcp/index.js"
11
+ },
12
+ "scripts": {
13
+ "build": "tsc",
14
+ "dev:cli": "tsx src/cli/index.ts",
15
+ "dev:mcp": "tsx src/mcp/index.ts",
16
+ "test": "vitest run",
17
+ "prepublishOnly": "npm run build"
18
+ },
19
+ "dependencies": {
20
+ "commander": "^12.0.0",
21
+ "@inquirer/prompts": "^5.0.0",
22
+ "@modelcontextprotocol/sdk": "^1.0.0",
23
+ "zod": "^3.0.0"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^20.0.0",
27
+ "typescript": "^5.4.0",
28
+ "tsx": "^4.0.0",
29
+ "vitest": "^1.6.0"
30
+ },
31
+ "engines": { "node": ">=18" }
32
+ }
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { password, input } from "@inquirer/prompts";
4
+ import { writeFileSync } from "fs";
5
+ import { loadConfig, saveConfig, CONFIG_PATH } from "../core/config.js";
6
+ import { fetchStats, fetchReportPdf, fetchLatestReportDate } from "../core/api.js";
7
+ import { formatStats, formatAlerts } from "../core/format.js";
8
+
9
+ const DEFAULT_API_URL = "https://health-bd.nonas.app";
10
+
11
+ const program = new Command();
12
+ program.name("bayan").description("Hospital satisfaction survey CLI").version("1.0.0");
13
+
14
+ // bayan setup
15
+ program
16
+ .command("setup")
17
+ .description("Configure API URL and API key")
18
+ .action(async () => {
19
+ const apiUrl = await input({ message: "API URL:", default: DEFAULT_API_URL });
20
+ const apiKey = await password({ message: "API key (sk_...):" });
21
+ saveConfig({ apiUrl, apiKey });
22
+ console.log(`✓ Config saved to ${CONFIG_PATH}`);
23
+ });
24
+
25
+ // bayan whoami
26
+ program
27
+ .command("whoami")
28
+ .description("Show current config")
29
+ .action(() => {
30
+ const config = loadConfig();
31
+ console.log(`API URL: ${config.apiUrl}`);
32
+ console.log(`API key: ${config.apiKey.slice(0, 8)}...`);
33
+ });
34
+
35
+ // bayan stats
36
+ program
37
+ .command("stats")
38
+ .description("Show satisfaction statistics")
39
+ .option("--period <period>", "7d, 30d, or all", "7d")
40
+ .action(async (opts) => {
41
+ try {
42
+ const config = loadConfig();
43
+ const data = await fetchStats(config, opts.period);
44
+ console.log(formatStats(data, opts.period));
45
+ } catch (err: unknown) {
46
+ console.error(err instanceof Error ? err.message : String(err));
47
+ process.exit(1);
48
+ }
49
+ });
50
+
51
+ // bayan report
52
+ program
53
+ .command("report")
54
+ .description("Download a daily PDF report")
55
+ .option("--date <date>", "YYYY-MM-DD (defaults to latest available)")
56
+ .option("--latest", "Download the most recent available report")
57
+ .option("--output <path>", "Output file path")
58
+ .action(async (opts) => {
59
+ try {
60
+ const config = loadConfig();
61
+ const date =
62
+ opts.date ??
63
+ (opts.latest
64
+ ? await fetchLatestReportDate(config)
65
+ : new Date().toISOString().split("T")[0]);
66
+ const outPath = opts.output ?? `report-${date}.pdf`;
67
+ const buffer = await fetchReportPdf(config, date);
68
+ writeFileSync(outPath, buffer);
69
+ console.log(`✓ Report saved to ${outPath}`);
70
+ } catch (err: unknown) {
71
+ console.error(err instanceof Error ? err.message : String(err));
72
+ process.exit(1);
73
+ }
74
+ });
75
+
76
+ // bayan alerts
77
+ program
78
+ .command("alerts")
79
+ .description("Show critical alerts from the last 7 days")
80
+ .action(async () => {
81
+ try {
82
+ const config = loadConfig();
83
+ const data = await fetchStats(config, "7d");
84
+ console.log(formatAlerts(data));
85
+ } catch (err: unknown) {
86
+ console.error(err instanceof Error ? err.message : String(err));
87
+ process.exit(1);
88
+ }
89
+ });
90
+
91
+ program.parse();
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { fetchStats, fetchReportPdf, fetchLatestReportDate } from "./api.js";
3
+
4
+ const mockConfig = { apiUrl: "https://test.example.com", apiKey: "sk_test" };
5
+
6
+ beforeEach(() => {
7
+ vi.resetAllMocks();
8
+ });
9
+
10
+ describe("fetchStats", () => {
11
+ it("throws on 401", async () => {
12
+ vi.stubGlobal("fetch", async () => ({ ok: false, status: 401, json: async () => ({}) }));
13
+ await expect(fetchStats(mockConfig, "7d")).rejects.toThrow("Invalid API key");
14
+ });
15
+
16
+ it("throws on network timeout", async () => {
17
+ vi.stubGlobal("fetch", () =>
18
+ new Promise((_, reject) => {
19
+ const err = new Error("aborted");
20
+ err.name = "AbortError";
21
+ setTimeout(() => reject(err), 0);
22
+ })
23
+ );
24
+ await expect(fetchStats(mockConfig, "7d")).rejects.toThrow("timed out");
25
+ });
26
+
27
+ it("returns data on success", async () => {
28
+ const mockData = {
29
+ totalSubmissions: 10,
30
+ averageScore: 8,
31
+ nps: 50,
32
+ serviceScores: [],
33
+ criticalAlerts: [],
34
+ };
35
+ vi.stubGlobal("fetch", async () => ({ ok: true, status: 200, json: async () => mockData }));
36
+ const result = await fetchStats(mockConfig, "7d");
37
+ expect(result.totalSubmissions).toBe(10);
38
+ });
39
+ });
40
+
41
+ describe("fetchReportPdf", () => {
42
+ it("throws on 401", async () => {
43
+ vi.stubGlobal("fetch", async () => ({ ok: false, status: 401, arrayBuffer: async () => new ArrayBuffer(0) }));
44
+ await expect(fetchReportPdf(mockConfig, "2026-03-22")).rejects.toThrow("Invalid API key");
45
+ });
46
+
47
+ it("throws on 404", async () => {
48
+ vi.stubGlobal("fetch", async () => ({ ok: false, status: 404 }));
49
+ await expect(fetchReportPdf(mockConfig, "2026-03-22")).rejects.toThrow("No report found");
50
+ });
51
+
52
+ it("returns buffer on success", async () => {
53
+ const bytes = new Uint8Array([1, 2, 3]);
54
+ vi.stubGlobal("fetch", async () => ({ ok: true, status: 200, arrayBuffer: async () => bytes.buffer }));
55
+ const buf = await fetchReportPdf(mockConfig, "2026-03-22");
56
+ expect(buf).toBeInstanceOf(Buffer);
57
+ expect(buf.length).toBe(3);
58
+ });
59
+ });
60
+
61
+ describe("fetchLatestReportDate", () => {
62
+ it("throws when no reports available", async () => {
63
+ vi.stubGlobal("fetch", async () => ({ ok: true, status: 200, json: async () => [] }));
64
+ await expect(fetchLatestReportDate(mockConfig)).rejects.toThrow("No reports available");
65
+ });
66
+
67
+ it("returns the first report date", async () => {
68
+ vi.stubGlobal("fetch", async () => ({
69
+ ok: true,
70
+ status: 200,
71
+ json: async () => [{ date: "2026-03-22" }, { date: "2026-03-15" }],
72
+ }));
73
+ const date = await fetchLatestReportDate(mockConfig);
74
+ expect(date).toBe("2026-03-22");
75
+ });
76
+ });
@@ -0,0 +1,65 @@
1
+ import { Config, AnalyticsData, Period } from "./types.js";
2
+
3
+ const TIMEOUT_MS = 10_000;
4
+
5
+ async function apiFetch<T>(config: Config, path: string): Promise<T> {
6
+ const controller = new AbortController();
7
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
8
+
9
+ try {
10
+ const res = await fetch(`${config.apiUrl}${path}`, {
11
+ headers: { Authorization: `Bearer ${config.apiKey}` },
12
+ signal: controller.signal,
13
+ });
14
+
15
+ if (res.status === 401) throw new Error("Invalid API key. Run: bayan setup");
16
+ if (!res.ok) {
17
+ const text = await res.text().catch(() => res.statusText);
18
+ throw new Error(`API error ${res.status}: ${text}`);
19
+ }
20
+
21
+ return res.json() as Promise<T>;
22
+ } catch (err: unknown) {
23
+ if (err instanceof Error && err.name === "AbortError") {
24
+ throw new Error("Request timed out. Check your connection.");
25
+ }
26
+ throw err;
27
+ } finally {
28
+ clearTimeout(timer);
29
+ }
30
+ }
31
+
32
+ export async function fetchStats(config: Config, period: Period): Promise<AnalyticsData> {
33
+ return apiFetch<AnalyticsData>(config, `/api/analytics?range=${period}`);
34
+ }
35
+
36
+ export async function fetchReportPdf(config: Config, date: string): Promise<Buffer> {
37
+ const controller = new AbortController();
38
+ const timer = setTimeout(() => controller.abort(), 30_000);
39
+
40
+ try {
41
+ const res = await fetch(`${config.apiUrl}/api/reports/${date}/pdf`, {
42
+ headers: { Authorization: `Bearer ${config.apiKey}` },
43
+ signal: controller.signal,
44
+ });
45
+
46
+ if (res.status === 401) throw new Error("Invalid API key. Run: bayan setup");
47
+ if (res.status === 404) throw new Error(`No report found for ${date}`);
48
+ if (!res.ok) throw new Error(`Failed to download report: ${res.status}`);
49
+
50
+ return Buffer.from(await res.arrayBuffer());
51
+ } catch (err: unknown) {
52
+ if (err instanceof Error && err.name === "AbortError") {
53
+ throw new Error("Report download timed out.");
54
+ }
55
+ throw err;
56
+ } finally {
57
+ clearTimeout(timer);
58
+ }
59
+ }
60
+
61
+ export async function fetchLatestReportDate(config: Config): Promise<string> {
62
+ const reports = await apiFetch<Array<{ date: string }>>(config, "/api/reports");
63
+ if (!reports.length) throw new Error("No reports available yet.");
64
+ return reports[0].date;
65
+ }
@@ -0,0 +1,23 @@
1
+ import { homedir } from "os";
2
+ import { join } from "path";
3
+ import { readFileSync, writeFileSync, existsSync } from "fs";
4
+ import { Config } from "./types.js";
5
+
6
+ export const CONFIG_PATH = join(homedir(), ".health-survey-rc");
7
+
8
+ export function loadConfig(): Config {
9
+ if (!existsSync(CONFIG_PATH)) {
10
+ console.error("Not configured. Run: bayan setup");
11
+ process.exit(1);
12
+ }
13
+ try {
14
+ return JSON.parse(readFileSync(CONFIG_PATH, "utf-8")) as Config;
15
+ } catch {
16
+ console.error("Config file is corrupted. Run: bayan setup");
17
+ process.exit(1);
18
+ }
19
+ }
20
+
21
+ export function saveConfig(config: Config): void {
22
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
23
+ }
@@ -0,0 +1,50 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { formatStats, formatAlerts } from "./format.js";
3
+ import { AnalyticsData } from "./types.js";
4
+
5
+ const mockData: AnalyticsData = {
6
+ totalSubmissions: 42,
7
+ averageScore: 7.8,
8
+ nps: 35,
9
+ serviceScores: [
10
+ { service: "Emergency", avgScore: 8.2, count: 20 },
11
+ { service: "Maternity", avgScore: 7.1, count: 22 },
12
+ ],
13
+ criticalAlerts: [],
14
+ };
15
+
16
+ describe("formatStats", () => {
17
+ it("shows key metrics", () => {
18
+ const out = formatStats(mockData, "7d");
19
+ expect(out).toContain("42");
20
+ expect(out).toContain("7.8/10");
21
+ expect(out).toContain("35");
22
+ });
23
+
24
+ it("sorts services by score descending", () => {
25
+ const out = formatStats(mockData, "7d");
26
+ expect(out.indexOf("Emergency")).toBeLessThan(out.indexOf("Maternity"));
27
+ });
28
+
29
+ it("shows alerts when present", () => {
30
+ const data = { ...mockData, criticalAlerts: [{ message: "Score below 5" }] };
31
+ expect(formatStats(data, "7d")).toContain("Score below 5");
32
+ });
33
+ });
34
+
35
+ describe("formatAlerts", () => {
36
+ it("returns ok message when no alerts", () => {
37
+ expect(formatAlerts(mockData)).toContain("No critical alerts");
38
+ });
39
+
40
+ it("lists alerts with service and date", () => {
41
+ const data = {
42
+ ...mockData,
43
+ criticalAlerts: [{ message: "Low score", service: "ER", date: "2026-03-21" }],
44
+ };
45
+ const out = formatAlerts(data);
46
+ expect(out).toContain("Low score");
47
+ expect(out).toContain("[ER]");
48
+ expect(out).toContain("2026-03-21");
49
+ });
50
+ });
@@ -0,0 +1,40 @@
1
+ import { AnalyticsData, Period } from "./types.js";
2
+
3
+ export function formatStats(data: AnalyticsData, period: Period): string {
4
+ const lines: string[] = [
5
+ ``,
6
+ `📊 Hospital Satisfaction — last ${period}`,
7
+ ` Responses: ${data.totalSubmissions}`,
8
+ ` Avg score: ${data.averageScore}/10`,
9
+ ` NPS: ${data.nps}`,
10
+ ];
11
+
12
+ if (data.serviceScores.length > 0) {
13
+ lines.push(``, ` By service:`);
14
+ [...data.serviceScores]
15
+ .sort((a, b) => b.avgScore - a.avgScore)
16
+ .forEach((s) =>
17
+ lines.push(` ${s.service.padEnd(20)} ${s.avgScore}/10 (${s.count} responses)`)
18
+ );
19
+ }
20
+
21
+ if (data.criticalAlerts.length > 0) {
22
+ lines.push(``, `⚠️ Critical alerts: ${data.criticalAlerts.length}`);
23
+ data.criticalAlerts.forEach((a) => lines.push(` - ${a.message}`));
24
+ }
25
+
26
+ return lines.join("\n");
27
+ }
28
+
29
+ export function formatAlerts(data: AnalyticsData): string {
30
+ if (data.criticalAlerts.length === 0) {
31
+ return "✓ No critical alerts in the last 7 days.";
32
+ }
33
+ const lines = [``, `⚠️ ${data.criticalAlerts.length} critical alert(s):`, ``];
34
+ data.criticalAlerts.forEach((a) => {
35
+ lines.push(
36
+ ` - ${a.message}${a.service ? ` [${a.service}]` : ""}${a.date ? ` — ${a.date}` : ""}`
37
+ );
38
+ });
39
+ return lines.join("\n");
40
+ }
@@ -0,0 +1,19 @@
1
+ export interface Config {
2
+ apiUrl: string;
3
+ apiKey: string;
4
+ }
5
+
6
+ export interface AnalyticsData {
7
+ totalSubmissions: number;
8
+ averageScore: number;
9
+ nps: number;
10
+ serviceScores: Array<{ service: string; avgScore: number; count: number }>;
11
+ criticalAlerts: Array<{ message: string; service?: string; date?: string }>;
12
+ }
13
+
14
+ export interface ReportMeta {
15
+ date: string;
16
+ url?: string;
17
+ }
18
+
19
+ export type Period = "7d" | "30d" | "all";
@@ -0,0 +1,76 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { z } from "zod";
4
+ import { writeFileSync } from "fs";
5
+ import { loadConfig } from "../core/config.js";
6
+ import { fetchStats, fetchReportPdf, fetchLatestReportDate } from "../core/api.js";
7
+ import { formatStats, formatAlerts } from "../core/format.js";
8
+
9
+ const server = new McpServer({
10
+ name: "health-survey",
11
+ version: "1.0.0",
12
+ });
13
+
14
+ server.tool(
15
+ "get_stats",
16
+ "Get hospital satisfaction statistics",
17
+ { period: z.enum(["7d", "30d", "all"]).default("7d").describe("Time period") },
18
+ async ({ period }) => {
19
+ const config = loadConfig();
20
+ const data = await fetchStats(config, period);
21
+ return { content: [{ type: "text", text: formatStats(data, period) }] };
22
+ }
23
+ );
24
+
25
+ server.tool(
26
+ "get_alerts",
27
+ "Get critical satisfaction alerts from the last 7 days",
28
+ {},
29
+ async () => {
30
+ const config = loadConfig();
31
+ const data = await fetchStats(config, "7d");
32
+ return { content: [{ type: "text", text: formatAlerts(data) }] };
33
+ }
34
+ );
35
+
36
+ server.tool(
37
+ "download_report",
38
+ "Download a hospital satisfaction PDF report",
39
+ {
40
+ date: z.string().optional().describe("YYYY-MM-DD — omit for latest"),
41
+ output: z.string().optional().describe("Output file path"),
42
+ },
43
+ async ({ date, output }) => {
44
+ const config = loadConfig();
45
+ const reportDate = date ?? (await fetchLatestReportDate(config));
46
+ const outPath = output ?? `report-${reportDate}.pdf`;
47
+ const buffer = await fetchReportPdf(config, reportDate);
48
+ writeFileSync(outPath, buffer);
49
+ return { content: [{ type: "text", text: `✓ Report saved to ${outPath}` }] };
50
+ }
51
+ );
52
+
53
+ server.tool(
54
+ "display_report",
55
+ "Display a hospital satisfaction PDF report (returns base64 for rendering)",
56
+ {
57
+ date: z.string().optional().describe("YYYY-MM-DD — omit for latest"),
58
+ },
59
+ async ({ date }) => {
60
+ const config = loadConfig();
61
+ const reportDate = date ?? (await fetchLatestReportDate(config));
62
+ const buffer = await fetchReportPdf(config, reportDate);
63
+ const base64 = buffer.toString("base64");
64
+ return {
65
+ content: [
66
+ {
67
+ type: "text",
68
+ text: `Hospital Satisfaction Report — ${reportDate}\n\nPDF (base64-encoded, ${buffer.length} bytes):\ndata:application/pdf;base64,${base64}`,
69
+ },
70
+ ],
71
+ };
72
+ }
73
+ );
74
+
75
+ const transport = new StdioServerTransport();
76
+ await server.connect(transport);
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true
11
+ },
12
+ "include": ["src"]
13
+ }