From fc2141baae7bab4fced1ca791176112b5433b482 Mon Sep 17 00:00:00 2001 From: Himanshu Singh Date: Thu, 15 Oct 2026 17:14:00 +0530 Subject: [PATCH] =?UTF-8?q?feat!:=20db-kit=202.0.0=20=E2=80=94=20remove=20?= =?UTF-8?q?the=20GraphQL=20layer=20(moved=20to=20mgpl-diagnostics/api)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: the ./graphql export, src/graphql and the graphql / @graphql-tools/schema peer dependencies are removed. The API lives in mgpl-diagnostics/api. Nothing in the org imports @mgpl-diagnostics/db-kit/graphql. Also renames tally-kit → ledger-kit in comments and docs. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 5 +- README.md | 1 - docs/FINANCE-STORAGE.md | 2 +- docs/MOVE-GRAPHQL-TO-BACKEND.md | 3 + llms-full.txt | 1013 +------------------------------ llms.txt | 4 +- package.json | 20 +- scripts/graphql-suite.ts | 128 ---- scripts/test.ts | 3 - src/core/ports.ts | 2 +- src/graphql/context.ts | 128 ---- src/graphql/index.ts | 41 -- src/graphql/loader.ts | 47 -- src/graphql/resolvers.ts | 290 --------- src/graphql/type-defs.ts | 283 --------- tsup.config.ts | 3 +- 16 files changed, 24 insertions(+), 1949 deletions(-) delete mode 100644 scripts/graphql-suite.ts delete mode 100644 src/graphql/context.ts delete mode 100644 src/graphql/index.ts delete mode 100644 src/graphql/loader.ts delete mode 100644 src/graphql/resolvers.ts delete mode 100644 src/graphql/type-defs.ts diff --git a/AGENTS.md b/AGENTS.md index a64c906..1385f8e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,8 +4,8 @@ The data layer for MGPL Diagnostics: one domain model on **MongoDB (Mongoose)** (Sequelize)** behind the same repository interface, Tally-style double-entry accounting, a Redis cache toolkit, and Fastify / Express decorators. -> **Pending move:** `src/graphql/` belongs in the backend repo. Read -> [docs/MOVE-GRAPHQL-TO-BACKEND.md](docs/MOVE-GRAPHQL-TO-BACKEND.md) before touching GraphQL code. +> **No GraphQL here.** The API lives in [mgpl-diagnostics/api](https://github.com/mgpl-diagnostics/api); the old +> `src/graphql` module was removed in 2.0.0 (see [docs/MOVE-GRAPHQL-TO-BACKEND.md](docs/MOVE-GRAPHQL-TO-BACKEND.md)). ## Map @@ -17,7 +17,6 @@ src/pg/ Sequelize models + repositories (row locks, retry on 40001/40P01 src/cache/ cache-aside, single-flight, stampede lock, SWR, negative, tags, namespaces, two-tier L1+L2, rate limiters, distributed lock, cached repositories src/adapters/ fastify (decorates app.db), express (attachDb, dbErrorHandler) -src/graphql/ TO BE MOVED (see the handoff doc) scripts/test.ts one contract suite on real MongoDB + real PostgreSQL (in-process), plus cache and adapters ``` diff --git a/README.md b/README.md index a07d94c..992359a 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,6 @@ Errors carry `statusCode`: | `…/mongo` · `…/pg` | `createMongoRepositories` · `createPgRepositories`, schemas / models | | `…/cache` | the cache toolkit | | `…/fastify` · `…/express` | adapters | -| `…/graphql` | **moving to the backend repo.** See [docs/MOVE-GRAPHQL-TO-BACKEND.md](docs/MOVE-GRAPHQL-TO-BACKEND.md) | Peer dependencies are all optional: install only the ones you use (`mongoose`, or `sequelize` + `pg` + `pg-hstore`; `ioredis`; `fastify`). diff --git a/docs/FINANCE-STORAGE.md b/docs/FINANCE-STORAGE.md index a346a5d..87a54b8 100644 --- a/docs/FINANCE-STORAGE.md +++ b/docs/FINANCE-STORAGE.md @@ -78,7 +78,7 @@ const db = await createDb({ backend: "pg", pg: { url }, posting: financePosting ``` Spreading `{ ...repos, posting }` does **not** reach the `tx` repositories; use the option. -When the accounting engine moves to its own kit (tally-kit), that kit implements this hook and +When the accounting engine moves to its own kit (ledger-kit), that kit implements this hook and db-kit no longer imports the engine. ## Upgrading an existing database diff --git a/docs/MOVE-GRAPHQL-TO-BACKEND.md b/docs/MOVE-GRAPHQL-TO-BACKEND.md index 761e749..2e57711 100644 --- a/docs/MOVE-GRAPHQL-TO-BACKEND.md +++ b/docs/MOVE-GRAPHQL-TO-BACKEND.md @@ -1,5 +1,8 @@ # Handoff: move the GraphQL layer from db-kit to the backend repo +> **Done.** The GraphQL layer now lives in [mgpl-diagnostics/api](https://github.com/mgpl-diagnostics/api). +> db-kit 2.0.0 removed `src/graphql`, the `./graphql` export and the graphql peer dependencies. This page is kept as history. + **Audience:** the AI agent (or developer) doing the move. Follow the checklist top to bottom. **Status (2026-10-13):** not started. Everything below currently lives in db-kit and passes its tests (`npm test`: 194 checks; 38 of them are the GraphQL suite). diff --git a/llms-full.txt b/llms-full.txt index 48fafa4..17ac5b9 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1,6 +1,6 @@ # @mgpl-diagnostics/db-kit — full documentation and source -> Generated by scripts/llms-full.mjs. Do not edit by hand. 36 files. +> Generated by scripts/llms-full.mjs. Do not edit by hand. 30 files. ## Contents @@ -10,7 +10,6 @@ - docs/MOVE-GRAPHQL-TO-BACKEND.md - llms.txt - package.json -- scripts/graphql-suite.ts - scripts/llms-full.mjs - scripts/test.ts - src/adapters/express.ts @@ -30,11 +29,6 @@ - src/core/services.ts - src/core/types.ts - src/create-db.ts -- src/graphql/context.ts -- src/graphql/index.ts -- src/graphql/loader.ts -- src/graphql/resolvers.ts -- src/graphql/type-defs.ts - src/index.ts - src/mongo/index.ts - src/mongo/schemas.ts @@ -185,7 +179,6 @@ Errors carry `statusCode`: | `…/mongo` · `…/pg` | `createMongoRepositories` · `createPgRepositories`, schemas / models | | `…/cache` | the cache toolkit | | `…/fastify` · `…/express` | adapters | -| `…/graphql` | **moving to the backend repo.** See [docs/MOVE-GRAPHQL-TO-BACKEND.md](docs/MOVE-GRAPHQL-TO-BACKEND.md) | Peer dependencies are all optional: install only the ones you use (`mongoose`, or `sequelize` + `pg` + `pg-hstore`; `ioredis`; `fastify`). @@ -227,8 +220,8 @@ The data layer for MGPL Diagnostics: one domain model on **MongoDB (Mongoose)** (Sequelize)** behind the same repository interface, Tally-style double-entry accounting, a Redis cache toolkit, and Fastify / Express decorators. -> **Pending move:** `src/graphql/` belongs in the backend repo. Read -> [docs/MOVE-GRAPHQL-TO-BACKEND.md](docs/MOVE-GRAPHQL-TO-BACKEND.md) before touching GraphQL code. +> **No GraphQL here.** The API lives in [mgpl-diagnostics/api](https://github.com/mgpl-diagnostics/api); the old +> `src/graphql` module was removed in 2.0.0 (see [docs/MOVE-GRAPHQL-TO-BACKEND.md](docs/MOVE-GRAPHQL-TO-BACKEND.md)). ## Map @@ -240,7 +233,6 @@ src/pg/ Sequelize models + repositories (row locks, retry on 40001/40P01 src/cache/ cache-aside, single-flight, stampede lock, SWR, negative, tags, namespaces, two-tier L1+L2, rate limiters, distributed lock, cached repositories src/adapters/ fastify (decorates app.db), express (attachDb, dbErrorHandler) -src/graphql/ TO BE MOVED (see the handoff doc) scripts/test.ts one contract suite on real MongoDB + real PostgreSQL (in-process), plus cache and adapters ``` @@ -355,7 +347,7 @@ const db = await createDb({ backend: "pg", pg: { url }, posting: financePosting ``` Spreading `{ ...repos, posting }` does **not** reach the `tx` repositories; use the option. -When the accounting engine moves to its own kit (tally-kit), that kit implements this hook and +When the accounting engine moves to its own kit (ledger-kit), that kit implements this hook and db-kit no longer imports the engine. ## Upgrading an existing database @@ -392,6 +384,9 @@ as 0, or set it once: `db.invoices.updateMany({ creditedPaise: { $exists: false # Handoff: move the GraphQL layer from db-kit to the backend repo +> **Done.** The GraphQL layer now lives in [mgpl-diagnostics/api](https://github.com/mgpl-diagnostics/api). +> db-kit 2.0.0 removed `src/graphql`, the `./graphql` export and the graphql peer dependencies. This page is kept as history. + **Audience:** the AI agent (or developer) doing the move. Follow the checklist top to bottom. **Status (2026-10-13):** not started. Everything below currently lives in db-kit and passes its tests (`npm test`: 194 checks; 38 of them are the GraphQL suite). @@ -522,14 +517,14 @@ Key facts for agents: - Reports: trialBalance, ledgerStatement, cashBook, dayBook, profitAndLoss, balanceSheet, outstandingReceivables, gstSummary. - Errors carry statusCode: NotFoundError 404, ConflictError 409, ValidationError 400, UnbalancedVoucherError 422; MongoDB without a replica set → TransactionsUnsupportedError. - Change a repository in both src/mongo and src/pg; `npm test` runs one contract suite on real MongoDB and PostgreSQL. -- src/graphql is moving to the backend repo: see docs/MOVE-GRAPHQL-TO-BACKEND.md. +- No GraphQL here (removed in 2.0.0): the API is mgpl-diagnostics/api. ## Docs - [README](README.md): overview, workflows, books, ACID, cache, entry points - [AGENTS.md](AGENTS.md): map and rules - [docs/FINANCE-STORAGE.md](docs/FINANCE-STORAGE.md): storage for finance-kit, lock dates, posting hook, upgrading a database -- [docs/MOVE-GRAPHQL-TO-BACKEND.md](docs/MOVE-GRAPHQL-TO-BACKEND.md): handoff for the GraphQL move +- [docs/MOVE-GRAPHQL-TO-BACKEND.md](docs/MOVE-GRAPHQL-TO-BACKEND.md): the completed GraphQL move (history) - [llms-full.txt](llms-full.txt): every doc and source file in one document ## Source @@ -554,8 +549,8 @@ Key facts for agents: ```json { "name": "@mgpl-diagnostics/db-kit", - "version": "1.1.0", - "description": "MGPL Diagnostics data layer: patients, bookings, samples, reports, invoices, payments, users, roles and audit — on MongoDB (Mongoose) or PostgreSQL (Sequelize) behind one repository API, with Tally-style double-entry accounting, storage for finance-kit (customers, partners, settlements, cash, bank, lock dates, tax rules, credit notes), a GraphQL module, a Redis cache toolkit, sessions, RBAC, and Fastify / Express adapters.", + "version": "2.0.0", + "description": "MGPL Diagnostics data layer: patients, bookings, samples, reports, invoices, payments, users, roles and audit \u2014 on MongoDB (Mongoose) or PostgreSQL (Sequelize) behind one repository API, with Tally-style double-entry accounting, storage for finance-kit (customers, partners, settlements, cash, bank, lock dates, tax rules, credit notes), a Redis cache toolkit, sessions, RBAC, and Fastify / Express adapters.", "type": "module", "license": "SEE LICENSE IN LICENSE", "repository": "github:mgpl-diagnostics/db-kit", @@ -587,10 +582,6 @@ Key facts for agents: "types": "./dist/express.d.ts", "import": "./dist/express.js" }, - "./graphql": { - "types": "./dist/graphql.d.ts", - "import": "./dist/graphql.js" - }, "./package.json": "./package.json" }, "files": [ @@ -613,9 +604,7 @@ Key facts for agents: "llms": "node scripts/llms-full.mjs @mgpl-diagnostics/db-kit README.md AGENTS.md llms.txt docs package.json src scripts" }, "peerDependencies": { - "@graphql-tools/schema": ">=10", "fastify": ">=4", - "graphql": ">=16.10", "ioredis": ">=5", "mongoose": ">=8", "pg": ">=8", @@ -623,15 +612,9 @@ Key facts for agents: "sequelize": ">=6" }, "peerDependenciesMeta": { - "@graphql-tools/schema": { - "optional": true - }, "fastify": { "optional": true }, - "graphql": { - "optional": true - }, "ioredis": { "optional": true }, @@ -649,13 +632,9 @@ Key facts for agents: } }, "devDependencies": { - "@graphql-tools/schema": "^10.1.4", "@types/node": "^22.20.5", - "dataloader": "^2.2.3", "embedded-postgres": "^18.4.0-beta.17", "fastify": "^5.12.7", - "graphql": "^17.0.2", - "graphql-yoga": "^5.24.4", "ioredis": "^5.11.1", "ioredis-mock": "^8.13.1", "mongodb-memory-server": "^11.3.0", @@ -674,142 +653,6 @@ Key facts for agents: ``` ---- - -## File: scripts/graphql-suite.ts - -```ts -// GraphQL module tests: a real Yoga server in memory, viewers chosen by a test header. -import { createYoga } from "graphql-yoga"; -import { seedChartOfAccounts, seedOrganization, type Db, type Repositories, type RoleAssignment } from "../src/index"; -import { createSchema, dbModule, type DbGraphQLContext } from "../src/graphql/index"; - -type Ok = (cond: unknown, msg: string) => void; - -const PERMS: Record = { - owner: ["*:*"], - front_desk: ["patient:read", "patient:create", "booking:read", "booking:create", "invoice:read", "invoice:create", "payment:create"], -}; -const match = (g: string, w: string) => { - const [gr, ga] = g.split(":"); - const [wr, wa] = w.split(":"); - return (gr === "*" || gr === wr) && (ga === "*" || ga === wa); -}; -const can = (roles: RoleAssignment[], perm: string, branchId?: string | null) => - roles.some((a) => (!branchId || !a.branchId || a.branchId === branchId) && (PERMS[a.role] ?? []).some((g) => match(g, perm))); - -export async function graphqlSuite(db: Db, ok: Ok) { - const tag = `[graphql/${db.backend}]`; - console.log(`\n${tag}`); - let getManyCalls = 0; - const repos: Repositories = { - ...db.rawRepos, - patients: { ...db.rawRepos.patients, getMany: (ids) => { getManyCalls++; return db.rawRepos.patients.getMany(ids); } }, - }; - - const a = await seedOrganization(repos, { org: { name: "GQL Lab", code: "GQL" }, branch: { code: "A01", name: "Branch A" }, owner: { name: "O", email: "o@gql.in", passwordHash: "h" } }); - const b2 = await repos.branches.create({ orgId: a.org.id, code: "B01", name: "Branch B", kind: "collection_center", active: true }); - await seedChartOfAccounts(repos, a.org.id); - const other = await seedOrganization(repos, { org: { name: "Other Lab", code: "OTH" }, branch: { code: "Z01", name: "Z" }, owner: { name: "X", email: "x@oth.in", passwordHash: "h" } }); - const cash = (await repos.ledgers.findBySystemKey(a.org.id, "cash"))!; - const capital = (await repos.ledgers.findBySystemKey(a.org.id, "capital"))!; - - const viewers: Record = { - owner: { userId: a.owner.id, orgId: a.org.id, roles: [{ role: "owner", branchId: null }] }, - desk: { userId: "0b0b0b0b-0000-4000-8000-00000000d35c", orgId: a.org.id, roles: [{ role: "front_desk", branchId: a.branch.id }] }, - other: { userId: other.owner.id, orgId: other.org.id, roles: [{ role: "owner", branchId: null }] }, - }; - const yoga = createYoga<{ request: Request }, DbGraphQLContext>({ - schema: createSchema([dbModule]), - logging: false, - context: ({ request }) => { - const v = viewers[request.headers.get("x-test-user") ?? ""] ?? null; - return { - repos, - viewer: v, - can: (p, br) => !!v && can(v.roles, p, br), - branchesFor: (p) => { - if (!v) return []; - if (v.roles.some((r) => !r.branchId && can([r], p))) return "all"; - return v.roles.filter((r) => r.branchId && can([r], p, r.branchId)).map((r) => r.branchId!); - }, - }; - }, - }); - const gql = async (user: string | null, query: string, variables?: Record) => { - const res = await yoga.fetch("http://test/graphql", { - method: "POST", - headers: { "content-type": "application/json", ...(user ? { "x-test-user": user } : {}) }, - body: JSON.stringify({ query, variables }), - }); - return (await res.json()) as { data?: any; errors?: { message: string; extensions?: { code?: string } }[] }; - }; - const code = (r: { errors?: { extensions?: { code?: string } }[] }) => r.errors?.[0]?.extensions?.code; - - ok(code(await gql(null, "{ organization { name } }")) === "UNAUTHENTICATED", `${tag} no viewer → UNAUTHENTICATED`); - const org = await gql("owner", "{ organization { name code branches { code } } }"); - ok(org.data?.organization.code === "GQL" && org.data.organization.branches.length === 2, `${tag} organization with branches`); - - const reg = await gql("owner", "mutation($i: RegisterPatientInput!) { registerPatient(input: $i) { id uhid name sex } }", { - i: { branchId: a.branch.id, name: "Asha Patel", sex: "female", ageYears: 29, phone: "9111111111" }, - }); - const patientId = reg.data?.registerPatient.id; - ok(reg.data?.registerPatient.uhid === "GQL-000001", `${tag} registerPatient → ${reg.data?.registerPatient.uhid}`); - - const book = await gql("owner", "mutation($i: CreateBookingInput!) { createBooking(input: $i) { booking { id bookingNo status patient { name } } samples { barcode sampleType } } }", { - i: { branchId: a.branch.id, patientId, tests: [{ testCode: "CBC", name: "CBC", pricePaise: 39900, sampleType: "EDTA" }, { testCode: "HBA1C", name: "HbA1c", pricePaise: 54900, sampleType: "EDTA" }] }, - }); - const bookingId = book.data?.createBooking.booking.id; - ok(book.data?.createBooking.samples.length === 1 && book.data.createBooking.booking.patient.name === "Asha Patel", `${tag} createBooking ${book.data?.createBooking.booking.bookingNo}: one EDTA sample`); - - const inv = await gql("owner", "mutation($i: IssueInvoiceInput!) { issueInvoice(input: $i) { id invoiceNo status totalPaise paidPaise duePaise } }", { - i: { branchId: a.branch.id, patientId, bookingId, items: [{ code: "CBC", name: "CBC", ratePaise: 39900 }, { code: "HBA1C", name: "HbA1c", ratePaise: 54900 }], payments: [{ amountPaise: 50000, mode: "cash" }] }, - }); - const invoiceId = inv.data?.issueInvoice.id; - ok(inv.data?.issueInvoice.status === "partially_paid" && inv.data.issueInvoice.totalPaise === 94800, `${tag} issueInvoice ${inv.data?.issueInvoice.invoiceNo}: Rs 948, partially paid`); - const over = await gql("owner", "mutation($id: ID!) { recordPayment(invoiceId: $id, payment: { amountPaise: 999999, mode: upi }) { id } }", { id: invoiceId }); - ok(code(over) === "CONFLICT", `${tag} overpayment → CONFLICT`); - const pay = await gql("owner", 'mutation($id: ID!) { recordPayment(invoiceId: $id, payment: { amountPaise: 44800, mode: upi, reference: "UPI9" }) { status duePaise payments { mode amountPaise } vouchers { type voucherNo entries { ledger { name } debitPaise creditPaise } } } }', { id: invoiceId }); - const p = pay.data?.recordPayment; - ok(p?.status === "paid" && p.payments.length === 2 && p.vouchers.length === 3, `${tag} recordPayment → paid; 2 payments, 3 vouchers (sales + 2 receipts)`); - ok(p?.vouchers.find((v: any) => v.type === "sales")?.entries.some((e: any) => e.ledger.name === "Patient Receivables"), `${tag} voucher entries resolve their ledgers`); - - const books = await gql("owner", '{ trialBalance { differencePaise totalDebitPaise } gstSummary(from: "2020-01-01T00:00:00Z", to: "2100-01-01T00:00:00Z") { totalTaxPaise } outstandingReceivables { totalDuePaise } }'); - ok(books.data?.trialBalance.differencePaise === 0 && books.data.trialBalance.totalDebitPaise > 0 && books.data.outstandingReceivables.totalDuePaise === 0, `${tag} trial balance balances; nothing outstanding`); - const unbalanced = await gql("owner", "mutation($i: PostVoucherInput!) { postVoucher(input: $i) { id } }", { - i: { branchId: a.branch.id, type: "journal", entries: [{ ledgerId: cash.id, debitPaise: 100 }, { ledgerId: capital.id, creditPaise: 50 }] }, - }); - ok(code(unbalanced) === "BAD_USER_INPUT", `${tag} unbalanced voucher → BAD_USER_INPUT`); - const foreignLedger = await gql("other", "mutation($i: PostVoucherInput!) { postVoucher(input: $i) { id } }", { - i: { branchId: other.branch.id, type: "journal", entries: [{ ledgerId: cash.id, debitPaise: 100 }, { ledgerId: capital.id, creditPaise: 100 }] }, - }); - ok(code(foreignLedger) === "NOT_FOUND", `${tag} cannot post into another organisation's ledgers`); - const badQty = await gql("owner", "mutation($i: IssueInvoiceInput!) { issueInvoice(input: $i) { id } }", { i: { branchId: a.branch.id, patientId, items: [{ name: "X", qty: 0, ratePaise: 100 }] } }); - ok(code(badQty) === "BAD_USER_INPUT", `${tag} qty 0 → BAD_USER_INPUT`); - - // permissions: front desk limited to branch A - ok(code(await gql("desk", "{ trialBalance { differencePaise } }")) === "FORBIDDEN", `${tag} front desk cannot read the books → FORBIDDEN`); - const deskA = await gql("desk", "mutation($i: RegisterPatientInput!) { registerPatient(input: $i) { uhid } }", { i: { branchId: a.branch.id, name: "Walk In", sex: "male" } }); - const deskB = await gql("desk", "mutation($i: RegisterPatientInput!) { registerPatient(input: $i) { uhid } }", { i: { branchId: b2.id, name: "Walk In", sex: "male" } }); - ok(deskA.data?.registerPatient.uhid && code(deskB) === "FORBIDDEN", `${tag} front desk registers in branch A, not branch B`); - const deskList = await gql("desk", "{ invoices { id branch { code } } }"); - ok(Array.isArray(deskList.data?.invoices) && deskList.data.invoices.every((i: any) => i.branch.code === "A01"), `${tag} lists only show the viewer's branches`); - const discount = await gql("desk", "mutation($i: IssueInvoiceInput!) { issueInvoice(input: $i) { id } }", { i: { branchId: a.branch.id, patientId, items: [{ name: "X", ratePaise: 1000 }], billDiscountPaise: 100 } }); - ok(code(discount) === "FORBIDDEN", `${tag} discounts need invoice:discount`); - - // tenant isolation - ok(code(await gql("other", "query($id: ID!) { invoice(id: $id) { id } }", { id: invoiceId })) === "NOT_FOUND", `${tag} another organisation cannot read this invoice`); - ok((await gql("other", "query($id: ID!) { patient(id: $id) { id } }", { id: patientId })).data?.patient === null, `${tag} …or this patient`); - - // N+1: 10 invoices with their patients → one batched lookup - for (let i = 0; i < 9; i++) await gql("owner", "mutation($i: IssueInvoiceInput!) { issueInvoice(input: $i) { id } }", { i: { branchId: a.branch.id, patientId, items: [{ name: "T", ratePaise: 1000 }] } }); - getManyCalls = 0; - const list = await gql("owner", "{ invoices(limit: 10) { invoiceNo patient { name } } }"); - ok(list.data?.invoices.length === 10 && getManyCalls === 1, `${tag} 10 invoices → ${getManyCalls} batched patient query (no N+1)`); -} -``` - - --- ## File: scripts/llms-full.mjs @@ -895,7 +738,6 @@ import { createMongoRepositories, TransactionsUnsupportedError } from "../src/mo import { createPgRepositories } from "../src/pg/index"; import { mgplDb } from "../src/adapters/fastify"; import { attachDb, dbErrorHandler } from "../src/adapters/express"; -import { graphqlSuite } from "./graphql-suite"; let failed = 0; let passed = 0; @@ -1281,7 +1123,6 @@ async function main() { const mongo = await createDb({ backend: "mongo", mongo: { uri: rs.getUri("mgpl_test") } }); const m = await contract(mongo); await financeStorage(mongo, m.org, m.branch, (posting) => createMongoRepositories({ uri: rs.getUri("mgpl_test"), posting })); - await graphqlSuite(mongo, ok); // a standalone server refuses multi-document work instead of silently losing atomicity const standalone = await MongoMemoryServer.create(); @@ -1301,7 +1142,6 @@ async function main() { const pgdb = await createDb({ backend: "pg", pg: { url: pgUrl } }); const p = await contract(pgdb); await financeStorage(pgdb, p.org, p.branch, (posting) => createPgRepositories({ url: pgUrl, posting })); - await graphqlSuite(pgdb, ok); const { redis, cache } = await cacheSuite(); @@ -2523,7 +2363,7 @@ export interface PeriodLockRepo extends Repo { /** * Called by the billing workflows, inside their transaction, to post the books. The default posts - * with this package's accounting; an accounting kit (finance-kit today, tally-kit later) supplies its own. + * with this package's accounting; an accounting kit (finance-kit today, ledger-kit later) supplies its own. */ export interface PostingHook { invoiceIssued(tx: Repositories, invoice: Invoice, actorId?: string): Promise; @@ -3812,835 +3652,6 @@ export async function createDb(o: CreateDbOptions): Promise { ``` ---- - -## File: src/graphql/context.ts - -```ts -import { GraphQLError } from "graphql"; -import { ConflictError, NotFoundError, ValidationError, type Repositories } from "../core/ports"; -import type { Branch, Doctor, Id, Invoice, Ledger, Patient, AccountGroup, Booking, RoleAssignment } from "../core/types"; -import { BatchLoader } from "./loader"; - -/** The signed-in user, as resolved by auth-kit (or your own auth). */ -export interface Viewer { - userId: Id; - orgId: Id; - roles: RoleAssignment[]; -} - -/** - * What the db-kit resolvers need from a request. auth-kit's context builder provides all of it; - * any other auth can too. - */ -export interface DbGraphQLContext { - repos: Repositories; - viewer: Viewer | null; - /** Throws (or returns false) when the viewer lacks `permission` in `branchId` (or anywhere when omitted). */ - can(permission: string, branchId?: Id | null): boolean | Promise; - /** Branches where the viewer has `permission`: "all" for organisation-wide roles. */ - branchesFor(permission: string): "all" | Id[] | Promise<"all" | Id[]>; - /** Created on first use, one set per request. */ - loaders?: Loaders; -} - -export interface Loaders { - patient: BatchLoader; - branch: BatchLoader; - doctor: BatchLoader; - booking: BatchLoader; - invoice: BatchLoader; - ledger: BatchLoader; - group: BatchLoader; -} - -export function loaders(ctx: DbGraphQLContext): Loaders { - const r = ctx.repos; - return (ctx.loaders ??= { - patient: new BatchLoader((ids) => r.patients.getMany(ids)), - branch: new BatchLoader((ids) => r.branches.getMany(ids)), - doctor: new BatchLoader((ids) => r.doctors.getMany(ids)), - booking: new BatchLoader((ids) => r.bookings.getMany(ids)), - invoice: new BatchLoader((ids) => r.invoices.getMany(ids)), - ledger: new BatchLoader((ids) => r.ledgers.getMany(ids)), - group: new BatchLoader((ids) => r.accountGroups.getMany(ids)), - }); -} - -// ------------------------------------------------------------------ guards - -export function viewerOf(ctx: DbGraphQLContext): Viewer { - if (!ctx.viewer) throw new GraphQLError("Sign in required", { extensions: { code: "UNAUTHENTICATED", http: { status: 401 } } }); - return ctx.viewer; -} - -export async function authorize(ctx: DbGraphQLContext, permission: string, branchId?: Id | null): Promise { - const v = viewerOf(ctx); - if (!(await ctx.can(permission, branchId))) { - throw new GraphQLError(`Missing permission ${permission}`, { extensions: { code: "FORBIDDEN", permission, http: { status: 403 } } }); - } - return v; -} - -/** Null unless the entity belongs to the viewer's organisation (tenant isolation). */ -export function own(ctx: DbGraphQLContext, entity: T | null | undefined): T | null { - return entity && ctx.viewer && entity.orgId === ctx.viewer.orgId ? entity : null; -} - -/** Loads an entity by id, enforces organisation + permission in its branch; NOT_FOUND otherwise. */ -export async function load( - ctx: DbGraphQLContext, - name: string, - fetch: () => Promise, - permission: string, -): Promise { - viewerOf(ctx); - const e = own(ctx, await fetch()); - if (!e) throw notFound(name); - await authorize(ctx, permission, e.branchId ?? null); - return e; -} - -export const notFound = (name: string) => new GraphQLError(`${name} not found`, { extensions: { code: "NOT_FOUND", http: { status: 404 } } }); - -/** The branch filter for a list: the requested one (checked) or the viewer's allowed branches. */ -export async function branchScope(ctx: DbGraphQLContext, permission: string, branchId?: Id | null): Promise<"all" | Id[]> { - if (branchId) { - await authorize(ctx, permission, branchId); - return [branchId]; - } - viewerOf(ctx); - const allowed = await ctx.branchesFor(permission); - if (allowed !== "all" && !allowed.length) await authorize(ctx, permission); // throws FORBIDDEN - return allowed; -} - -/** Domain errors → GraphQL errors with codes. Anything else is re-thrown (and masked by the server). */ -export function toGraphQLError(e: unknown): unknown { - if (e instanceof GraphQLError) return e; - const code = - e instanceof NotFoundError ? "NOT_FOUND" - : e instanceof ConflictError ? "CONFLICT" - : e instanceof ValidationError ? "BAD_USER_INPUT" - : null; - if (!code) return e; - const err = e as Error & { statusCode?: number }; - // no `originalError`: Yoga masks any GraphQLError that wraps a non-GraphQL error as "Unexpected error" - return new GraphQLError(err.message, { extensions: { code, http: { status: err.statusCode } } }); -} - -/** Wraps every resolver of a map so domain errors surface with proper codes. */ -export function withErrorCodes>>(resolvers: R): R { - for (const type of Object.values(resolvers)) { - for (const [field, fn] of Object.entries(type)) { - if (typeof fn !== "function") continue; - type[field] = async (...args: unknown[]) => { - try { - return await (fn as (...a: unknown[]) => unknown)(...args); - } catch (e) { - throw toGraphQLError(e); - } - }; - } - } - return resolvers; -} -``` - - ---- - -## File: src/graphql/index.ts - -```ts -import { makeExecutableSchema } from "@graphql-tools/schema"; -import type { GraphQLSchema } from "graphql"; -import { resolvers } from "./resolvers"; -import { typeDefs } from "./type-defs"; - -export { typeDefs, resolvers }; -export { BatchLoader } from "./loader"; -export { - authorize, branchScope, load, loaders, own, toGraphQLError, viewerOf, withErrorCodes, - type DbGraphQLContext, type Loaders, type Viewer, -} from "./context"; - -/** A GraphQL module: merge several (db-kit, auth-kit, catalog-kit, your own) into one schema. */ -export interface GraphQLModule { - typeDefs: string; - resolvers: Record; -} - -export const dbModule: GraphQLModule = { typeDefs, resolvers }; - -/** Permissions the db-kit resolvers check. auth-kit's roles grant these. */ -export const DB_PERMISSIONS = [ - "org:read", "branch:read", - "patient:read", "patient:create", "patient:update", - "doctor:read", "doctor:manage", - "booking:read", "booking:create", "booking:cancel", - "sample:read", "sample:collect", "sample:receive", "sample:reject", - "report:read", "report:enter", "report:verify", "report:release", "report:amend", - "invoice:read", "invoice:create", "invoice:discount", "invoice:cancel", - "payment:create", "payment:refund", - "accounts:read", "accounts:post", "accounts:manage", -] as const; - -/** - * Builds one executable schema from modules. Each module may `extend type Query/Mutation`. - * const schema = createSchema([dbModule, authModule, catalogModule]); - * const yoga = createYoga({ schema, context: (req) => auth.context(req, db.repos) }); - */ -export function createSchema(modules: GraphQLModule[]): GraphQLSchema { - return makeExecutableSchema({ typeDefs: modules.map((m) => m.typeDefs), resolvers: modules.map((m) => m.resolvers) }); -} -``` - - ---- - -## File: src/graphql/loader.ts - -```ts -/** - * Minimal DataLoader: every load() in the same tick is batched into one getMany(), and results - * are memoised for the request. Prevents N+1 queries when a list resolves nested fields - * (e.g. 50 invoices → 1 query for their patients). - */ -export class BatchLoader { - private cache = new Map>(); - private queue: { id: string; resolve: (v: T | null) => void; reject: (e: unknown) => void }[] = []; - - constructor(private readonly fetchMany: (ids: readonly string[]) => Promise) {} - - load(id: string | null | undefined): Promise { - if (!id) return Promise.resolve(null); - const hit = this.cache.get(id); - if (hit) return hit; - const p = new Promise((resolve, reject) => { - this.queue.push({ id, resolve, reject }); - if (this.queue.length === 1) queueMicrotask(() => void this.flush()); - }); - this.cache.set(id, p); - return p; - } - - /** Seeds the cache with an entity you already have (e.g. just created). */ - prime(entity: T): void { - this.cache.set(entity.id, Promise.resolve(entity)); - } - - clear(id: string): void { - this.cache.delete(id); - } - - private async flush(): Promise { - const batch = this.queue; - this.queue = []; - try { - const rows = await this.fetchMany([...new Set(batch.map((b) => b.id))]); - const byId = new Map(rows.map((r) => [r.id, r])); - for (const b of batch) b.resolve(byId.get(b.id) ?? null); - } catch (e) { - for (const b of batch) { - this.cache.delete(b.id); - b.reject(e); - } - } - } -} -``` - - ---- - -## File: src/graphql/resolvers.ts - -```ts -import { GraphQLScalarType, Kind } from "graphql"; -import { cancelVoucher, postVoucher, seedChartOfAccounts } from "../core/accounting"; -import { ConflictError, ValidationError, type Repo } from "../core/ports"; -import { - balanceSheet, dayBook, gstSummary, ledgerBalance, ledgerStatement, outstandingReceivables, profitAndLoss, trialBalance, -} from "../core/reports"; -import { - cancelInvoice, createBooking, issueInvoice, recordPayment, registerPatient, saveResults, transitionReport, -} from "../core/services"; -import type { - AccountGroup, Booking, Id, Invoice, Ledger, Patient, Report, ResultValue, Sample, Voucher, VoucherEntry, -} from "../core/types"; -import { authorize, branchScope, load, loaders, notFound, own, viewerOf, withErrorCodes, type DbGraphQLContext as Ctx } from "./context"; - -// ------------------------------------------------------------------ scalars & enum mapping - -const DateTime = new GraphQLScalarType({ - name: "DateTime", - description: "ISO-8601 date-time", - serialize: (v) => (v instanceof Date ? v : new Date(v as string)).toISOString(), - parseValue: (v) => { - const d = new Date(v as string); - if (Number.isNaN(d.getTime())) throw new TypeError("Invalid DateTime"); - return d; - }, - parseLiteral: (ast) => (ast.kind === Kind.STRING ? new Date(ast.value) : null), -}); - -const JSONScalar = new GraphQLScalarType({ name: "JSON", serialize: (v) => v, parseValue: (v) => v }); - -const flagOut = (f: ResultValue["flag"]) => (f === "" ? "NONE" : f ?? null); -const flagIn = (f?: string | null): ResultValue["flag"] => (f === "NONE" ? "" : ((f ?? undefined) as ResultValue["flag"])); -const BUCKET = { "0-30": "D0_30", "31-60": "D31_60", "61-90": "D61_90", "90+": "D90_PLUS" } as const; - -const clean = (o: T): Partial => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined && v !== null)) as Partial; -const cap = (n: number | undefined, d: number) => Math.min(Math.max(1, n ?? d), 200); - -/** Lists honour the viewer's branch access: all branches, or only the allowed ones. */ -async function scopedFind(repo: Repo, filter: Record, scope: "all" | Id[], limit: number, offset: number, sortField: string): Promise { - const sort = { field: sortField as keyof T & string, dir: "desc" as const }; - if (scope === "all") return repo.find(filter as any, { limit, offset, sort }); - const lists = await Promise.all(scope.map((b) => repo.find({ ...filter, branchId: b } as any, { limit: limit + offset, sort }))); - return lists.flat().sort((a: any, b: any) => +b[sortField] - +a[sortField]).slice(offset, offset + limit); -} - -async function ownLedgers(ctx: Ctx, ids: Id[]): Promise { - const v = viewerOf(ctx); - const found = await ctx.repos.ledgers.getMany([...new Set(ids)]); - if (found.length !== new Set(ids).size || found.some((l) => l.orgId !== v.orgId)) throw notFound("Ledger"); -} - -const canSee = async (ctx: Ctx, permission: string, branchId?: Id | null) => Boolean(ctx.viewer && (await ctx.can(permission, branchId))); - -// ------------------------------------------------------------------ resolvers - -export const resolvers = withErrorCodes({ - Query: { - organization: async (_: unknown, __: unknown, ctx: Ctx) => ctx.repos.orgs.get(viewerOf(ctx).orgId), - branches: async (_: unknown, __: unknown, ctx: Ctx) => ctx.repos.branches.find({ orgId: viewerOf(ctx).orgId }, { sort: { field: "code" } }), - - patient: async (_: unknown, a: { id: Id }, ctx: Ctx) => { - await authorize(ctx, "patient:read"); - return own(ctx, await ctx.repos.patients.get(a.id)); - }, - patients: async (_: unknown, a: { search: string; limit?: number }, ctx: Ctx) => { - const v = await authorize(ctx, "patient:read"); - return ctx.repos.patients.search(v.orgId, a.search, cap(a.limit, 20)); - }, - doctors: async (_: unknown, __: unknown, ctx: Ctx) => { - const v = await authorize(ctx, "doctor:read"); - return ctx.repos.doctors.find({ orgId: v.orgId }, { sort: { field: "name" } }); - }, - - booking: async (_: unknown, a: { id: Id }, ctx: Ctx) => load(ctx, "Booking", () => ctx.repos.bookings.get(a.id), "booking:read"), - bookings: async (_: unknown, a: { branchId?: Id; status?: string; patientId?: Id; limit?: number; offset?: number }, ctx: Ctx) => { - const scope = await branchScope(ctx, "booking:read", a.branchId); - return scopedFind(ctx.repos.bookings, clean({ orgId: viewerOf(ctx).orgId, status: a.status, patientId: a.patientId }), scope, cap(a.limit, 50), a.offset ?? 0, "createdAt"); - }, - sampleByBarcode: async (_: unknown, a: { barcode: string }, ctx: Ctx) => { - const v = viewerOf(ctx); - const s = await ctx.repos.samples.findOne({ orgId: v.orgId, barcode: a.barcode.trim() }); - if (!s) return null; - await authorize(ctx, "sample:read", s.branchId); - return s; - }, - report: async (_: unknown, a: { id: Id }, ctx: Ctx) => load(ctx, "Report", () => ctx.repos.reports.get(a.id), "report:read"), - invoice: async (_: unknown, a: { id: Id }, ctx: Ctx) => load(ctx, "Invoice", () => ctx.repos.invoices.get(a.id), "invoice:read"), - invoices: async (_: unknown, a: { branchId?: Id; status?: string; patientId?: Id; limit?: number; offset?: number }, ctx: Ctx) => { - const scope = await branchScope(ctx, "invoice:read", a.branchId); - return scopedFind(ctx.repos.invoices, clean({ orgId: viewerOf(ctx).orgId, status: a.status, patientId: a.patientId }), scope, cap(a.limit, 50), a.offset ?? 0, "createdAt"); - }, - - accountGroups: async (_: unknown, __: unknown, ctx: Ctx) => ctx.repos.accountGroups.find({ orgId: (await authorize(ctx, "accounts:read")).orgId }, { sort: { field: "name" } }), - ledgers: async (_: unknown, __: unknown, ctx: Ctx) => ctx.repos.ledgers.find({ orgId: (await authorize(ctx, "accounts:read")).orgId }, { sort: { field: "name" } }), - ledgerStatement: async (_: unknown, a: { ledgerId: Id; from: Date; to: Date; branchId?: Id }, ctx: Ctx) => { - const v = await authorize(ctx, "accounts:read", a.branchId); - await ownLedgers(ctx, [a.ledgerId]); - return ledgerStatement(ctx.repos, { orgId: v.orgId, ...a }); - }, - trialBalance: async (_: unknown, a: { asOf?: Date; branchId?: Id }, ctx: Ctx) => trialBalance(ctx.repos, { orgId: (await authorize(ctx, "accounts:read", a.branchId)).orgId, ...a }), - profitAndLoss: async (_: unknown, a: { from: Date; to: Date; branchId?: Id }, ctx: Ctx) => profitAndLoss(ctx.repos, { orgId: (await authorize(ctx, "accounts:read", a.branchId)).orgId, ...a }), - balanceSheet: async (_: unknown, a: { asOf: Date; branchId?: Id }, ctx: Ctx) => balanceSheet(ctx.repos, { orgId: (await authorize(ctx, "accounts:read", a.branchId)).orgId, ...a }), - dayBook: async (_: unknown, a: { from: Date; to: Date; branchId?: Id; type?: Voucher["type"] }, ctx: Ctx) => dayBook(ctx.repos, { orgId: (await authorize(ctx, "accounts:read", a.branchId)).orgId, ...a }), - outstandingReceivables: async (_: unknown, a: { branchId?: Id }, ctx: Ctx) => { - const out = await outstandingReceivables(ctx.repos, { orgId: (await authorize(ctx, "accounts:read", a.branchId)).orgId, branchId: a.branchId }); - return { - rows: out.rows.map((r) => ({ ...r, bucket: BUCKET[r.bucket] })), - buckets: { d0to30: out.buckets["0-30"], d31to60: out.buckets["31-60"], d61to90: out.buckets["61-90"], d90plus: out.buckets["90+"] }, - totalDuePaise: out.totalDuePaise, - }; - }, - gstSummary: async (_: unknown, a: { from: Date; to: Date; branchId?: Id }, ctx: Ctx) => gstSummary(ctx.repos, { orgId: (await authorize(ctx, "accounts:read", a.branchId)).orgId, ...a }), - }, - - Organization: { - branches: (o: { id: Id }, _: unknown, ctx: Ctx) => ctx.repos.branches.find({ orgId: o.id }, { sort: { field: "code" } }), - }, - Patient: { - branch: (p: Patient, _: unknown, ctx: Ctx) => loaders(ctx).branch.load(p.branchId), - bookings: async (p: Patient, a: { limit?: number }, ctx: Ctx) => { - if (!(await canSee(ctx, "booking:read"))) return []; - const scope = await ctx.branchesFor("booking:read"); - return scopedFind(ctx.repos.bookings, { patientId: p.id }, scope, cap(a.limit, 20), 0, "createdAt"); - }, - invoices: async (p: Patient, a: { limit?: number }, ctx: Ctx) => { - if (!(await canSee(ctx, "invoice:read"))) return []; - const scope = await ctx.branchesFor("invoice:read"); - return scopedFind(ctx.repos.invoices, { patientId: p.id }, scope, cap(a.limit, 20), 0, "createdAt"); - }, - }, - Booking: { - patient: (b: Booking, _: unknown, ctx: Ctx) => loaders(ctx).patient.load(b.patientId), - doctor: (b: Booking, _: unknown, ctx: Ctx) => loaders(ctx).doctor.load(b.doctorId), - branch: (b: Booking, _: unknown, ctx: Ctx) => loaders(ctx).branch.load(b.branchId), - samples: (b: Booking, _: unknown, ctx: Ctx) => ctx.repos.samples.find({ bookingId: b.id }, { sort: { field: "barcode" } }), - reports: async (b: Booking, _: unknown, ctx: Ctx) => ((await canSee(ctx, "report:read", b.branchId)) ? ctx.repos.reports.find({ bookingId: b.id }) : []), - invoice: async (b: Booking, _: unknown, ctx: Ctx) => ((await canSee(ctx, "invoice:read", b.branchId)) ? loaders(ctx).invoice.load(b.invoiceId) : null), - }, - Report: { - results: (r: Report) => r.results.map((x) => ({ ...x, flag: flagOut(x.flag) })), - patient: (r: Report, _: unknown, ctx: Ctx) => loaders(ctx).patient.load(r.patientId), - booking: (r: Report, _: unknown, ctx: Ctx) => loaders(ctx).booking.load(r.bookingId), - }, - Invoice: { - patient: (i: Invoice, _: unknown, ctx: Ctx) => loaders(ctx).patient.load(i.patientId), - branch: (i: Invoice, _: unknown, ctx: Ctx) => loaders(ctx).branch.load(i.branchId), - booking: (i: Invoice, _: unknown, ctx: Ctx) => loaders(ctx).booking.load(i.bookingId), - payments: (i: Invoice, _: unknown, ctx: Ctx) => ctx.repos.payments.find({ invoiceId: i.id }, { sort: { field: "receivedAt" } }), - vouchers: async (i: Invoice, _: unknown, ctx: Ctx) => ((await canSee(ctx, "accounts:read", i.branchId)) ? ctx.repos.vouchers.find({ invoiceId: i.id } as any, { sort: { field: "date" } }) : []), - }, - AccountGroup: { - parent: (g: AccountGroup, _: unknown, ctx: Ctx) => loaders(ctx).group.load(g.parentId), - }, - Ledger: { - group: (l: Ledger, _: unknown, ctx: Ctx) => loaders(ctx).group.load(l.groupId), - balancePaise: (l: Ledger, a: { asOf?: Date; branchId?: Id }, ctx: Ctx) => ledgerBalance(ctx.repos, { orgId: l.orgId, ledgerId: l.id, ...a }), - }, - VoucherEntry: { - ledger: (e: VoucherEntry, _: unknown, ctx: Ctx) => loaders(ctx).ledger.load(e.ledgerId), - }, - - Mutation: { - registerPatient: async (_: unknown, { input }: { input: any }, ctx: Ctx) => { - const v = await authorize(ctx, "patient:create", input.branchId); - if (!own(ctx, await ctx.repos.branches.get(input.branchId))) throw notFound("Branch"); - return registerPatient(ctx.repos, { ...input, orgId: v.orgId }, v.userId); - }, - updatePatient: async (_: unknown, { id, input }: { id: Id; input: any }, ctx: Ctx) => { - const v = await authorize(ctx, "patient:update"); - if (!own(ctx, await ctx.repos.patients.get(id))) throw notFound("Patient"); - const p = await ctx.repos.patients.update(id, clean(input)); - await ctx.repos.audit.log({ orgId: v.orgId, userId: v.userId, action: "patient.update", entity: "patient", entityId: id }); - return p; - }, - createDoctor: async (_: unknown, { input }: { input: any }, ctx: Ctx) => { - const v = await authorize(ctx, "doctor:manage"); - return ctx.repos.doctors.create({ ...input, orgId: v.orgId, active: input.active ?? true }); - }, - - createBooking: async (_: unknown, { input }: { input: any }, ctx: Ctx) => { - const v = await authorize(ctx, "booking:create", input.branchId); - if (!own(ctx, await ctx.repos.branches.get(input.branchId))) throw notFound("Branch"); - if (!own(ctx, await ctx.repos.patients.get(input.patientId))) throw notFound("Patient"); - if (input.doctorId && !own(ctx, await ctx.repos.doctors.get(input.doctorId))) throw notFound("Doctor"); - return createBooking(ctx.repos, { - orgId: v.orgId, branchId: input.branchId, patientId: input.patientId, doctorId: input.doctorId ?? null, tests: input.tests, - sampleCollection: { type: input.collectionType ?? "walk_in", scheduledAt: input.scheduledAt ?? null }, notes: input.notes, - }, v.userId); - }, - cancelBooking: async (_: unknown, { id }: { id: Id }, ctx: Ctx) => { - const b = await load(ctx, "Booking", () => ctx.repos.bookings.get(id), "booking:cancel"); - if (b.invoiceId) { - const inv = await ctx.repos.invoices.get(b.invoiceId); - if (inv && inv.status !== "cancelled") throw new ConflictError("Cancel the booking's invoice first"); - } - const out = await ctx.repos.bookings.update(id, { status: "cancelled" }); - await ctx.repos.audit.log({ orgId: b.orgId, branchId: b.branchId, userId: ctx.viewer!.userId, action: "booking.cancel", entity: "booking", entityId: id }); - return out; - }, - collectSample: async (_: unknown, { id }: { id: Id }, ctx: Ctx) => { - const s = await load(ctx, "Sample", () => ctx.repos.samples.get(id), "sample:collect"); - if (s.status !== "pending") throw new ConflictError(`Sample is ${s.status}`); - const out = await ctx.repos.samples.update(id, { status: "collected", collectedAt: new Date(), collectedBy: ctx.viewer!.userId }); - const b = await ctx.repos.bookings.get(s.bookingId); - if (b?.status === "booked") await ctx.repos.bookings.update(b.id, { status: "sample_collected" }); - return out; - }, - receiveSample: async (_: unknown, { id }: { id: Id }, ctx: Ctx) => { - const s = await load(ctx, "Sample", () => ctx.repos.samples.get(id), "sample:receive"); - if (s.status !== "collected") throw new ConflictError(`Sample is ${s.status}`); - const out = await ctx.repos.samples.update(id, { status: "received", receivedAt: new Date() }); - await ctx.repos.bookings.update(s.bookingId, { status: "in_process" }); - return out; - }, - rejectSample: async (_: unknown, { id, reason }: { id: Id; reason: string }, ctx: Ctx) => { - await load(ctx, "Sample", () => ctx.repos.samples.get(id), "sample:reject"); - return ctx.repos.samples.update(id, { status: "rejected", rejectReason: reason }); - }, - - createReport: async (_: unknown, a: { bookingId: Id; testCode: string; testName: string }, ctx: Ctx) => { - const b = await load(ctx, "Booking", () => ctx.repos.bookings.get(a.bookingId), "report:enter"); - if (!b.tests.some((t) => t.testCode === a.testCode)) throw new ValidationError(`Test ${a.testCode} is not in booking ${b.bookingNo}`); - return ctx.repos.reports.create({ orgId: b.orgId, branchId: b.branchId, bookingId: b.id, patientId: b.patientId, testCode: a.testCode, testName: a.testName, results: [], status: "draft", version: 1 }); - }, - saveResults: async (_: unknown, a: { reportId: Id; results: any[] }, ctx: Ctx) => { - await load(ctx, "Report", () => ctx.repos.reports.get(a.reportId), "report:enter"); - return saveResults(ctx.repos, a.reportId, a.results.map((r) => ({ ...r, flag: flagIn(r.flag) })), ctx.viewer!.userId); - }, - verifyReport: async (_: unknown, { id }: { id: Id }, ctx: Ctx) => { - await load(ctx, "Report", () => ctx.repos.reports.get(id), "report:verify"); - return transitionReport(ctx.repos, id, "verified", ctx.viewer!.userId); - }, - releaseReport: async (_: unknown, { id }: { id: Id }, ctx: Ctx) => { - const r = await load(ctx, "Report", () => ctx.repos.reports.get(id), "report:release"); - const out = await transitionReport(ctx.repos, id, "released", ctx.viewer!.userId); - const all = await ctx.repos.reports.find({ bookingId: r.bookingId }); - if (all.every((x) => x.status === "released")) await ctx.repos.bookings.update(r.bookingId, { status: "completed" }); - return out; - }, - amendReport: async (_: unknown, { id }: { id: Id }, ctx: Ctx) => { - await load(ctx, "Report", () => ctx.repos.reports.get(id), "report:amend"); - return transitionReport(ctx.repos, id, "amended", ctx.viewer!.userId); - }, - - issueInvoice: async (_: unknown, { input }: { input: any }, ctx: Ctx) => { - const v = await authorize(ctx, "invoice:create", input.branchId); - const discounted = (input.billDiscountPaise ?? 0) > 0 || input.items.some((i: any) => (i.discountPaise ?? 0) > 0); - if (discounted) await authorize(ctx, "invoice:discount", input.branchId); - if (input.payments?.length) await authorize(ctx, "payment:create", input.branchId); - if (!own(ctx, await ctx.repos.branches.get(input.branchId))) throw notFound("Branch"); - if (!own(ctx, await ctx.repos.patients.get(input.patientId))) throw notFound("Patient"); - if (input.bookingId && !own(ctx, await ctx.repos.bookings.get(input.bookingId))) throw notFound("Booking"); - const { invoice } = await issueInvoice(ctx.repos, { ...input, orgId: v.orgId }, v.userId); - return invoice; - }, - recordPayment: async (_: unknown, a: { invoiceId: Id; payment: any }, ctx: Ctx) => { - await load(ctx, "Invoice", () => ctx.repos.invoices.get(a.invoiceId), "payment:create"); - return (await recordPayment(ctx.repos, { invoiceId: a.invoiceId, ...a.payment, kind: "payment" }, ctx.viewer!.userId)).invoice; - }, - refundPayment: async (_: unknown, a: { invoiceId: Id; payment: any }, ctx: Ctx) => { - await load(ctx, "Invoice", () => ctx.repos.invoices.get(a.invoiceId), "payment:refund"); - return (await recordPayment(ctx.repos, { invoiceId: a.invoiceId, ...a.payment, kind: "refund" }, ctx.viewer!.userId)).invoice; - }, - cancelInvoice: async (_: unknown, a: { id: Id; reason: string }, ctx: Ctx) => { - await load(ctx, "Invoice", () => ctx.repos.invoices.get(a.id), "invoice:cancel"); - return cancelInvoice(ctx.repos, a.id, a.reason, ctx.viewer!.userId); - }, - - setupChartOfAccounts: async (_: unknown, __: unknown, ctx: Ctx) => (await seedChartOfAccounts(ctx.repos, (await authorize(ctx, "accounts:manage")).orgId)).ledgers, - createLedger: async (_: unknown, { input }: { input: any }, ctx: Ctx) => { - const v = await authorize(ctx, "accounts:manage"); - if (!own(ctx, await ctx.repos.accountGroups.get(input.groupId))) throw notFound("Account group"); - return ctx.repos.ledgers.create({ orgId: v.orgId, name: input.name, groupId: input.groupId, openingPaise: input.openingPaise ?? 0, gstin: input.gstin, systemKey: null, active: true }); - }, - postVoucher: async (_: unknown, { input }: { input: any }, ctx: Ctx) => { - const v = await authorize(ctx, "accounts:post", input.branchId); - if (!own(ctx, await ctx.repos.branches.get(input.branchId))) throw notFound("Branch"); - await ownLedgers(ctx, input.entries.map((e: VoucherEntry) => e.ledgerId)); // no posting into another organisation's books - for (const e of input.entries) if (e.invoiceId && !own(ctx, await ctx.repos.invoices.get(e.invoiceId))) throw notFound("Invoice"); - return postVoucher(ctx.repos, { ...input, orgId: v.orgId, entries: input.entries.map((e: any) => ({ ...e, debitPaise: e.debitPaise ?? 0, creditPaise: e.creditPaise ?? 0 })) }, v.userId); - }, - cancelVoucher: async (_: unknown, { id }: { id: Id }, ctx: Ctx) => { - await load(ctx, "Voucher", () => ctx.repos.vouchers.get(id), "accounts:post"); - return cancelVoucher(ctx.repos, id, ctx.viewer!.userId); - }, - }, -}); - -// scalars are added after wrapping (they are not functions to wrap) -Object.assign(resolvers, { DateTime, JSON: JSONScalar }); -``` - - ---- - -## File: src/graphql/type-defs.ts - -```ts -/** - * GraphQL schema for the data layer. Money is Int paise (₹1 = 100). Dates are ISO-8601 DateTime. - * Everything is scoped to the signed-in user's organisation; no query takes an orgId. - */ -export const typeDefs = /* GraphQL */ ` - scalar DateTime - scalar JSON - - enum Sex { male female other } - enum BranchKind { lab collection_center } - enum BookingStatus { booked sample_collected in_process completed cancelled } - enum CollectionType { walk_in home } - enum SampleStatus { pending collected received rejected } - enum ReportStatus { draft entered verified released amended } - enum InvoiceStatus { draft issued partially_paid paid cancelled refunded credited } - enum ItemCategory { test package service consumable medicine other } - enum PaymentMode { cash upi card netbanking wallet cheque insurance } - enum PaymentKind { payment refund } - enum ResultFlag { L H A NONE } - enum AccountNature { assets liabilities income expenses } - enum VoucherType { sales receipt payment journal contra credit_note debit_note purchase closing } - enum VoucherStatus { posted cancelled } - enum AgeBucket { D0_30 D31_60 D61_90 D90_PLUS } - - type Address { line1: String, line2: String, city: String, district: String, state: String, pincode: String } - - type Organization { - id: ID! - name: String! - code: String! - legalName: String - gstin: String - phone: String - email: String - address: Address - branches: [Branch!]! - } - - type Branch { id: ID!, code: String!, name: String!, kind: BranchKind!, phone: String, gstin: String, address: Address, active: Boolean! } - - type Patient { - id: ID! - uhid: String! - title: String - name: String! - sex: Sex! - dob: DateTime - ageYears: Int - phone: String - email: String - address: Address - branch: Branch - createdAt: DateTime! - bookings(limit: Int = 20): [Booking!]! - invoices(limit: Int = 20): [Invoice!]! - } - - type Doctor { id: ID!, name: String!, qualification: String, phone: String, clinic: String, active: Boolean! } - - type BookingTest { testCode: String!, name: String!, pricePaise: Int!, sampleType: String } - type SampleCollection { type: CollectionType!, scheduledAt: DateTime, address: Address } - - type Booking { - id: ID! - bookingNo: String! - status: BookingStatus! - patient: Patient! - doctor: Doctor - branch: Branch! - tests: [BookingTest!]! - sampleCollection: SampleCollection! - samples: [Sample!]! - reports: [Report!]! - invoice: Invoice - notes: String - createdAt: DateTime! - } - - type Sample { - id: ID! - barcode: String! - sampleType: String! - status: SampleStatus! - collectedAt: DateTime - receivedAt: DateTime - rejectReason: String - } - - type ResultValue { parameter: String!, value: String!, unit: String, flag: ResultFlag, range: String } - - type Report { - id: ID! - testCode: String! - testName: String! - status: ReportStatus! - results: [ResultValue!]! - version: Int! - verifiedAt: DateTime - releasedAt: DateTime - patient: Patient! - booking: Booking! - updatedAt: DateTime! - } - - type InvoiceItem { - code: String - name: String! - category: ItemCategory! - qty: Float! - ratePaise: Int! - discountPaise: Int! - taxPercent: Float! - amountPaise: Int! - } - - type Invoice { - id: ID! - invoiceNo: String! - status: InvoiceStatus! - patient: Patient! - branch: Branch! - booking: Booking - items: [InvoiceItem!]! - subtotalPaise: Int! - discountPaise: Int! - taxPaise: Int! - roundOffPaise: Int! - totalPaise: Int! - paidPaise: Int! - duePaise: Int! - issuedAt: DateTime - cancelReason: String - payments: [Payment!]! - vouchers: [Voucher!]! - } - - type Payment { id: ID!, kind: PaymentKind!, amountPaise: Int!, mode: PaymentMode!, reference: String, receivedAt: DateTime! } - - # ---------------------------------------------------------------- accounting (Tally-style) - - type AccountGroup { id: ID!, name: String!, nature: AccountNature!, parent: AccountGroup, affectsGrossProfit: Boolean! } - - type Ledger { - id: ID! - name: String! - group: AccountGroup! - openingPaise: Int! - systemKey: String - gstin: String - active: Boolean! - "Closing balance, positive = Debit." - balancePaise(asOf: DateTime, branchId: ID): Int! - } - - type VoucherEntry { ledger: Ledger!, debitPaise: Int!, creditPaise: Int!, invoiceId: ID } - - type Voucher { - id: ID! - type: VoucherType! - voucherNo: String! - date: DateTime! - narration: String - status: VoucherStatus! - entries: [VoucherEntry!]! - invoiceId: ID - } - - type StatementRow { date: DateTime!, voucherNo: String!, voucherType: VoucherType!, debitPaise: Int!, creditPaise: Int!, balancePaise: Int!, invoiceId: ID } - type LedgerStatement { ledger: Ledger!, openingPaise: Int!, closingPaise: Int!, totalDebitPaise: Int!, totalCreditPaise: Int!, rows: [StatementRow!]! } - type TrialBalanceRow { ledger: Ledger!, group: AccountGroup!, primaryGroup: AccountGroup!, debitPaise: Int!, creditPaise: Int! } - type TrialBalance { rows: [TrialBalanceRow!]!, totalDebitPaise: Int!, totalCreditPaise: Int!, differencePaise: Int! } - type LedgerAmount { ledger: Ledger!, amountPaise: Int! } - type GroupLine { group: AccountGroup!, amountPaise: Int!, ledgers: [LedgerAmount!]! } - type ProfitAndLoss { income: [GroupLine!]!, expenses: [GroupLine!]!, grossProfitPaise: Int!, netProfitPaise: Int! } - type BalanceSheet { assets: [GroupLine!]!, liabilities: [GroupLine!]!, profitPaise: Int!, totalAssetsPaise: Int!, totalLiabilitiesPaise: Int!, differencePaise: Int! } - type OutstandingRow { invoice: Invoice!, ageDays: Int!, bucket: AgeBucket! } - type BucketTotals { d0to30: Int!, d31to60: Int!, d61to90: Int!, d90plus: Int! } - type Outstanding { rows: [OutstandingRow!]!, buckets: BucketTotals!, totalDuePaise: Int! } - type GstSummary { taxableValuePaise: Int!, cgstPaise: Int!, sgstPaise: Int!, igstPaise: Int!, totalTaxPaise: Int! } - - type Query { - organization: Organization! - branches: [Branch!]! - patient(id: ID!): Patient - "Name, phone or UHID." - patients(search: String!, limit: Int = 20): [Patient!]! - doctors: [Doctor!]! - booking(id: ID!): Booking - bookings(branchId: ID, status: BookingStatus, patientId: ID, limit: Int = 50, offset: Int = 0): [Booking!]! - sampleByBarcode(barcode: String!): Sample - report(id: ID!): Report - invoice(id: ID!): Invoice - invoices(branchId: ID, status: InvoiceStatus, patientId: ID, limit: Int = 50, offset: Int = 0): [Invoice!]! - - accountGroups: [AccountGroup!]! - ledgers: [Ledger!]! - ledgerStatement(ledgerId: ID!, from: DateTime!, to: DateTime!, branchId: ID): LedgerStatement! - trialBalance(asOf: DateTime, branchId: ID): TrialBalance! - profitAndLoss(from: DateTime!, to: DateTime!, branchId: ID): ProfitAndLoss! - balanceSheet(asOf: DateTime!, branchId: ID): BalanceSheet! - dayBook(from: DateTime!, to: DateTime!, branchId: ID, type: VoucherType): [Voucher!]! - outstandingReceivables(branchId: ID): Outstanding! - gstSummary(from: DateTime!, to: DateTime!, branchId: ID): GstSummary! - } - - input AddressInput { line1: String, line2: String, city: String, district: String, state: String, pincode: String } - - input RegisterPatientInput { - branchId: ID! - title: String - name: String! - sex: Sex! - dob: DateTime - ageYears: Int - phone: String - email: String - address: AddressInput - } - - input UpdatePatientInput { title: String, name: String, sex: Sex, dob: DateTime, ageYears: Int, phone: String, email: String, address: AddressInput } - - input DoctorInput { name: String!, qualification: String, phone: String, clinic: String, active: Boolean = true } - - input BookingTestInput { testCode: String!, name: String!, pricePaise: Int!, sampleType: String } - - input CreateBookingInput { - branchId: ID! - patientId: ID! - doctorId: ID - tests: [BookingTestInput!]! - collectionType: CollectionType = walk_in - scheduledAt: DateTime - notes: String - } - - input ResultInput { parameter: String!, value: String!, unit: String, flag: ResultFlag, range: String } - - input InvoiceItemInput { code: String, name: String!, category: ItemCategory = test, qty: Float = 1, ratePaise: Int!, discountPaise: Int = 0, taxPercent: Float = 0 } - input PaymentInput { amountPaise: Int!, mode: PaymentMode!, reference: String } - - input IssueInvoiceInput { - branchId: ID! - patientId: ID! - bookingId: ID - items: [InvoiceItemInput!]! - billDiscountPaise: Int = 0 - payments: [PaymentInput!] - } - - input VoucherEntryInput { ledgerId: ID!, debitPaise: Int = 0, creditPaise: Int = 0, invoiceId: ID } - input PostVoucherInput { branchId: ID!, type: VoucherType!, date: DateTime, narration: String, entries: [VoucherEntryInput!]! } - input LedgerInput { name: String!, groupId: ID!, openingPaise: Int = 0, gstin: String } - - type CreateBookingResult { booking: Booking!, samples: [Sample!]! } - - type Mutation { - registerPatient(input: RegisterPatientInput!): Patient! - updatePatient(id: ID!, input: UpdatePatientInput!): Patient! - createDoctor(input: DoctorInput!): Doctor! - - createBooking(input: CreateBookingInput!): CreateBookingResult! - cancelBooking(id: ID!): Booking! - collectSample(id: ID!): Sample! - receiveSample(id: ID!): Sample! - rejectSample(id: ID!, reason: String!): Sample! - - createReport(bookingId: ID!, testCode: String!, testName: String!): Report! - saveResults(reportId: ID!, results: [ResultInput!]!): Report! - verifyReport(id: ID!): Report! - releaseReport(id: ID!): Report! - amendReport(id: ID!): Report! - - issueInvoice(input: IssueInvoiceInput!): Invoice! - recordPayment(invoiceId: ID!, payment: PaymentInput!): Invoice! - refundPayment(invoiceId: ID!, payment: PaymentInput!): Invoice! - cancelInvoice(id: ID!, reason: String!): Invoice! - - setupChartOfAccounts: [Ledger!]! - createLedger(input: LedgerInput!): Ledger! - postVoucher(input: PostVoucherInput!): Voucher! - cancelVoucher(id: ID!): Voucher! - } -`; -``` - - --- ## File: src/index.ts diff --git a/llms.txt b/llms.txt index 139d795..2bfa07f 100644 --- a/llms.txt +++ b/llms.txt @@ -9,14 +9,14 @@ Key facts for agents: - Reports: trialBalance, ledgerStatement, cashBook, dayBook, profitAndLoss, balanceSheet, outstandingReceivables, gstSummary. - Errors carry statusCode: NotFoundError 404, ConflictError 409, ValidationError 400, UnbalancedVoucherError 422; MongoDB without a replica set → TransactionsUnsupportedError. - Change a repository in both src/mongo and src/pg; `npm test` runs one contract suite on real MongoDB and PostgreSQL. -- src/graphql is moving to the backend repo: see docs/MOVE-GRAPHQL-TO-BACKEND.md. +- No GraphQL here (removed in 2.0.0): the API is mgpl-diagnostics/api. ## Docs - [README](README.md): overview, workflows, books, ACID, cache, entry points - [AGENTS.md](AGENTS.md): map and rules - [docs/FINANCE-STORAGE.md](docs/FINANCE-STORAGE.md): storage for finance-kit, lock dates, posting hook, upgrading a database -- [docs/MOVE-GRAPHQL-TO-BACKEND.md](docs/MOVE-GRAPHQL-TO-BACKEND.md): handoff for the GraphQL move +- [docs/MOVE-GRAPHQL-TO-BACKEND.md](docs/MOVE-GRAPHQL-TO-BACKEND.md): the completed GraphQL move (history) - [llms-full.txt](llms-full.txt): every doc and source file in one document ## Source diff --git a/package.json b/package.json index cd246b1..a18b09f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@mgpl-diagnostics/db-kit", - "version": "1.1.0", - "description": "MGPL Diagnostics data layer: patients, bookings, samples, reports, invoices, payments, users, roles and audit — on MongoDB (Mongoose) or PostgreSQL (Sequelize) behind one repository API, with Tally-style double-entry accounting, storage for finance-kit (customers, partners, settlements, cash, bank, lock dates, tax rules, credit notes), a GraphQL module, a Redis cache toolkit, sessions, RBAC, and Fastify / Express adapters.", + "version": "2.0.0", + "description": "MGPL Diagnostics data layer: patients, bookings, samples, reports, invoices, payments, users, roles and audit \u2014 on MongoDB (Mongoose) or PostgreSQL (Sequelize) behind one repository API, with Tally-style double-entry accounting, storage for finance-kit (customers, partners, settlements, cash, bank, lock dates, tax rules, credit notes), a Redis cache toolkit, sessions, RBAC, and Fastify / Express adapters.", "type": "module", "license": "SEE LICENSE IN LICENSE", "repository": "github:mgpl-diagnostics/db-kit", @@ -33,10 +33,6 @@ "types": "./dist/express.d.ts", "import": "./dist/express.js" }, - "./graphql": { - "types": "./dist/graphql.d.ts", - "import": "./dist/graphql.js" - }, "./package.json": "./package.json" }, "files": [ @@ -59,9 +55,7 @@ "llms": "node scripts/llms-full.mjs @mgpl-diagnostics/db-kit README.md AGENTS.md llms.txt docs package.json src scripts" }, "peerDependencies": { - "@graphql-tools/schema": ">=10", "fastify": ">=4", - "graphql": ">=16.10", "ioredis": ">=5", "mongoose": ">=8", "pg": ">=8", @@ -69,15 +63,9 @@ "sequelize": ">=6" }, "peerDependenciesMeta": { - "@graphql-tools/schema": { - "optional": true - }, "fastify": { "optional": true }, - "graphql": { - "optional": true - }, "ioredis": { "optional": true }, @@ -95,13 +83,9 @@ } }, "devDependencies": { - "@graphql-tools/schema": "^10.1.4", "@types/node": "^22.20.5", - "dataloader": "^2.2.3", "embedded-postgres": "^18.4.0-beta.17", "fastify": "^5.12.7", - "graphql": "^17.0.2", - "graphql-yoga": "^5.24.4", "ioredis": "^5.11.1", "ioredis-mock": "^8.13.1", "mongodb-memory-server": "^11.3.0", diff --git a/scripts/graphql-suite.ts b/scripts/graphql-suite.ts deleted file mode 100644 index 1a68cdf..0000000 --- a/scripts/graphql-suite.ts +++ /dev/null @@ -1,128 +0,0 @@ -// GraphQL module tests: a real Yoga server in memory, viewers chosen by a test header. -import { createYoga } from "graphql-yoga"; -import { seedChartOfAccounts, seedOrganization, type Db, type Repositories, type RoleAssignment } from "../src/index"; -import { createSchema, dbModule, type DbGraphQLContext } from "../src/graphql/index"; - -type Ok = (cond: unknown, msg: string) => void; - -const PERMS: Record = { - owner: ["*:*"], - front_desk: ["patient:read", "patient:create", "booking:read", "booking:create", "invoice:read", "invoice:create", "payment:create"], -}; -const match = (g: string, w: string) => { - const [gr, ga] = g.split(":"); - const [wr, wa] = w.split(":"); - return (gr === "*" || gr === wr) && (ga === "*" || ga === wa); -}; -const can = (roles: RoleAssignment[], perm: string, branchId?: string | null) => - roles.some((a) => (!branchId || !a.branchId || a.branchId === branchId) && (PERMS[a.role] ?? []).some((g) => match(g, perm))); - -export async function graphqlSuite(db: Db, ok: Ok) { - const tag = `[graphql/${db.backend}]`; - console.log(`\n${tag}`); - let getManyCalls = 0; - const repos: Repositories = { - ...db.rawRepos, - patients: { ...db.rawRepos.patients, getMany: (ids) => { getManyCalls++; return db.rawRepos.patients.getMany(ids); } }, - }; - - const a = await seedOrganization(repos, { org: { name: "GQL Lab", code: "GQL" }, branch: { code: "A01", name: "Branch A" }, owner: { name: "O", email: "o@gql.in", passwordHash: "h" } }); - const b2 = await repos.branches.create({ orgId: a.org.id, code: "B01", name: "Branch B", kind: "collection_center", active: true }); - await seedChartOfAccounts(repos, a.org.id); - const other = await seedOrganization(repos, { org: { name: "Other Lab", code: "OTH" }, branch: { code: "Z01", name: "Z" }, owner: { name: "X", email: "x@oth.in", passwordHash: "h" } }); - const cash = (await repos.ledgers.findBySystemKey(a.org.id, "cash"))!; - const capital = (await repos.ledgers.findBySystemKey(a.org.id, "capital"))!; - - const viewers: Record = { - owner: { userId: a.owner.id, orgId: a.org.id, roles: [{ role: "owner", branchId: null }] }, - desk: { userId: "0b0b0b0b-0000-4000-8000-00000000d35c", orgId: a.org.id, roles: [{ role: "front_desk", branchId: a.branch.id }] }, - other: { userId: other.owner.id, orgId: other.org.id, roles: [{ role: "owner", branchId: null }] }, - }; - const yoga = createYoga<{ request: Request }, DbGraphQLContext>({ - schema: createSchema([dbModule]), - logging: false, - context: ({ request }) => { - const v = viewers[request.headers.get("x-test-user") ?? ""] ?? null; - return { - repos, - viewer: v, - can: (p, br) => !!v && can(v.roles, p, br), - branchesFor: (p) => { - if (!v) return []; - if (v.roles.some((r) => !r.branchId && can([r], p))) return "all"; - return v.roles.filter((r) => r.branchId && can([r], p, r.branchId)).map((r) => r.branchId!); - }, - }; - }, - }); - const gql = async (user: string | null, query: string, variables?: Record) => { - const res = await yoga.fetch("http://test/graphql", { - method: "POST", - headers: { "content-type": "application/json", ...(user ? { "x-test-user": user } : {}) }, - body: JSON.stringify({ query, variables }), - }); - return (await res.json()) as { data?: any; errors?: { message: string; extensions?: { code?: string } }[] }; - }; - const code = (r: { errors?: { extensions?: { code?: string } }[] }) => r.errors?.[0]?.extensions?.code; - - ok(code(await gql(null, "{ organization { name } }")) === "UNAUTHENTICATED", `${tag} no viewer → UNAUTHENTICATED`); - const org = await gql("owner", "{ organization { name code branches { code } } }"); - ok(org.data?.organization.code === "GQL" && org.data.organization.branches.length === 2, `${tag} organization with branches`); - - const reg = await gql("owner", "mutation($i: RegisterPatientInput!) { registerPatient(input: $i) { id uhid name sex } }", { - i: { branchId: a.branch.id, name: "Asha Patel", sex: "female", ageYears: 29, phone: "9111111111" }, - }); - const patientId = reg.data?.registerPatient.id; - ok(reg.data?.registerPatient.uhid === "GQL-000001", `${tag} registerPatient → ${reg.data?.registerPatient.uhid}`); - - const book = await gql("owner", "mutation($i: CreateBookingInput!) { createBooking(input: $i) { booking { id bookingNo status patient { name } } samples { barcode sampleType } } }", { - i: { branchId: a.branch.id, patientId, tests: [{ testCode: "CBC", name: "CBC", pricePaise: 39900, sampleType: "EDTA" }, { testCode: "HBA1C", name: "HbA1c", pricePaise: 54900, sampleType: "EDTA" }] }, - }); - const bookingId = book.data?.createBooking.booking.id; - ok(book.data?.createBooking.samples.length === 1 && book.data.createBooking.booking.patient.name === "Asha Patel", `${tag} createBooking ${book.data?.createBooking.booking.bookingNo}: one EDTA sample`); - - const inv = await gql("owner", "mutation($i: IssueInvoiceInput!) { issueInvoice(input: $i) { id invoiceNo status totalPaise paidPaise duePaise } }", { - i: { branchId: a.branch.id, patientId, bookingId, items: [{ code: "CBC", name: "CBC", ratePaise: 39900 }, { code: "HBA1C", name: "HbA1c", ratePaise: 54900 }], payments: [{ amountPaise: 50000, mode: "cash" }] }, - }); - const invoiceId = inv.data?.issueInvoice.id; - ok(inv.data?.issueInvoice.status === "partially_paid" && inv.data.issueInvoice.totalPaise === 94800, `${tag} issueInvoice ${inv.data?.issueInvoice.invoiceNo}: Rs 948, partially paid`); - const over = await gql("owner", "mutation($id: ID!) { recordPayment(invoiceId: $id, payment: { amountPaise: 999999, mode: upi }) { id } }", { id: invoiceId }); - ok(code(over) === "CONFLICT", `${tag} overpayment → CONFLICT`); - const pay = await gql("owner", 'mutation($id: ID!) { recordPayment(invoiceId: $id, payment: { amountPaise: 44800, mode: upi, reference: "UPI9" }) { status duePaise payments { mode amountPaise } vouchers { type voucherNo entries { ledger { name } debitPaise creditPaise } } } }', { id: invoiceId }); - const p = pay.data?.recordPayment; - ok(p?.status === "paid" && p.payments.length === 2 && p.vouchers.length === 3, `${tag} recordPayment → paid; 2 payments, 3 vouchers (sales + 2 receipts)`); - ok(p?.vouchers.find((v: any) => v.type === "sales")?.entries.some((e: any) => e.ledger.name === "Patient Receivables"), `${tag} voucher entries resolve their ledgers`); - - const books = await gql("owner", '{ trialBalance { differencePaise totalDebitPaise } gstSummary(from: "2020-01-01T00:00:00Z", to: "2100-01-01T00:00:00Z") { totalTaxPaise } outstandingReceivables { totalDuePaise } }'); - ok(books.data?.trialBalance.differencePaise === 0 && books.data.trialBalance.totalDebitPaise > 0 && books.data.outstandingReceivables.totalDuePaise === 0, `${tag} trial balance balances; nothing outstanding`); - const unbalanced = await gql("owner", "mutation($i: PostVoucherInput!) { postVoucher(input: $i) { id } }", { - i: { branchId: a.branch.id, type: "journal", entries: [{ ledgerId: cash.id, debitPaise: 100 }, { ledgerId: capital.id, creditPaise: 50 }] }, - }); - ok(code(unbalanced) === "BAD_USER_INPUT", `${tag} unbalanced voucher → BAD_USER_INPUT`); - const foreignLedger = await gql("other", "mutation($i: PostVoucherInput!) { postVoucher(input: $i) { id } }", { - i: { branchId: other.branch.id, type: "journal", entries: [{ ledgerId: cash.id, debitPaise: 100 }, { ledgerId: capital.id, creditPaise: 100 }] }, - }); - ok(code(foreignLedger) === "NOT_FOUND", `${tag} cannot post into another organisation's ledgers`); - const badQty = await gql("owner", "mutation($i: IssueInvoiceInput!) { issueInvoice(input: $i) { id } }", { i: { branchId: a.branch.id, patientId, items: [{ name: "X", qty: 0, ratePaise: 100 }] } }); - ok(code(badQty) === "BAD_USER_INPUT", `${tag} qty 0 → BAD_USER_INPUT`); - - // permissions: front desk limited to branch A - ok(code(await gql("desk", "{ trialBalance { differencePaise } }")) === "FORBIDDEN", `${tag} front desk cannot read the books → FORBIDDEN`); - const deskA = await gql("desk", "mutation($i: RegisterPatientInput!) { registerPatient(input: $i) { uhid } }", { i: { branchId: a.branch.id, name: "Walk In", sex: "male" } }); - const deskB = await gql("desk", "mutation($i: RegisterPatientInput!) { registerPatient(input: $i) { uhid } }", { i: { branchId: b2.id, name: "Walk In", sex: "male" } }); - ok(deskA.data?.registerPatient.uhid && code(deskB) === "FORBIDDEN", `${tag} front desk registers in branch A, not branch B`); - const deskList = await gql("desk", "{ invoices { id branch { code } } }"); - ok(Array.isArray(deskList.data?.invoices) && deskList.data.invoices.every((i: any) => i.branch.code === "A01"), `${tag} lists only show the viewer's branches`); - const discount = await gql("desk", "mutation($i: IssueInvoiceInput!) { issueInvoice(input: $i) { id } }", { i: { branchId: a.branch.id, patientId, items: [{ name: "X", ratePaise: 1000 }], billDiscountPaise: 100 } }); - ok(code(discount) === "FORBIDDEN", `${tag} discounts need invoice:discount`); - - // tenant isolation - ok(code(await gql("other", "query($id: ID!) { invoice(id: $id) { id } }", { id: invoiceId })) === "NOT_FOUND", `${tag} another organisation cannot read this invoice`); - ok((await gql("other", "query($id: ID!) { patient(id: $id) { id } }", { id: patientId })).data?.patient === null, `${tag} …or this patient`); - - // N+1: 10 invoices with their patients → one batched lookup - for (let i = 0; i < 9; i++) await gql("owner", "mutation($i: IssueInvoiceInput!) { issueInvoice(input: $i) { id } }", { i: { branchId: a.branch.id, patientId, items: [{ name: "T", ratePaise: 1000 }] } }); - getManyCalls = 0; - const list = await gql("owner", "{ invoices(limit: 10) { invoiceNo patient { name } } }"); - ok(list.data?.invoices.length === 10 && getManyCalls === 1, `${tag} 10 invoices → ${getManyCalls} batched patient query (no N+1)`); -} diff --git a/scripts/test.ts b/scripts/test.ts index 5c82b5f..c69bb79 100644 --- a/scripts/test.ts +++ b/scripts/test.ts @@ -17,7 +17,6 @@ import { createMongoRepositories, TransactionsUnsupportedError } from "../src/mo import { createPgRepositories } from "../src/pg/index"; import { mgplDb } from "../src/adapters/fastify"; import { attachDb, dbErrorHandler } from "../src/adapters/express"; -import { graphqlSuite } from "./graphql-suite"; let failed = 0; let passed = 0; @@ -403,7 +402,6 @@ async function main() { const mongo = await createDb({ backend: "mongo", mongo: { uri: rs.getUri("mgpl_test") } }); const m = await contract(mongo); await financeStorage(mongo, m.org, m.branch, (posting) => createMongoRepositories({ uri: rs.getUri("mgpl_test"), posting })); - await graphqlSuite(mongo, ok); // a standalone server refuses multi-document work instead of silently losing atomicity const standalone = await MongoMemoryServer.create(); @@ -423,7 +421,6 @@ async function main() { const pgdb = await createDb({ backend: "pg", pg: { url: pgUrl } }); const p = await contract(pgdb); await financeStorage(pgdb, p.org, p.branch, (posting) => createPgRepositories({ url: pgUrl, posting })); - await graphqlSuite(pgdb, ok); const { redis, cache } = await cacheSuite(); diff --git a/src/core/ports.ts b/src/core/ports.ts index f01856e..71b8e7b 100644 --- a/src/core/ports.ts +++ b/src/core/ports.ts @@ -136,7 +136,7 @@ export interface PeriodLockRepo extends Repo { /** * Called by the billing workflows, inside their transaction, to post the books. The default posts - * with this package's accounting; an accounting kit (finance-kit today, tally-kit later) supplies its own. + * with this package's accounting; an accounting kit (finance-kit today, ledger-kit later) supplies its own. */ export interface PostingHook { invoiceIssued(tx: Repositories, invoice: Invoice, actorId?: string): Promise; diff --git a/src/graphql/context.ts b/src/graphql/context.ts deleted file mode 100644 index 47e03ab..0000000 --- a/src/graphql/context.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { GraphQLError } from "graphql"; -import { ConflictError, NotFoundError, ValidationError, type Repositories } from "../core/ports"; -import type { Branch, Doctor, Id, Invoice, Ledger, Patient, AccountGroup, Booking, RoleAssignment } from "../core/types"; -import { BatchLoader } from "./loader"; - -/** The signed-in user, as resolved by auth-kit (or your own auth). */ -export interface Viewer { - userId: Id; - orgId: Id; - roles: RoleAssignment[]; -} - -/** - * What the db-kit resolvers need from a request. auth-kit's context builder provides all of it; - * any other auth can too. - */ -export interface DbGraphQLContext { - repos: Repositories; - viewer: Viewer | null; - /** Throws (or returns false) when the viewer lacks `permission` in `branchId` (or anywhere when omitted). */ - can(permission: string, branchId?: Id | null): boolean | Promise; - /** Branches where the viewer has `permission`: "all" for organisation-wide roles. */ - branchesFor(permission: string): "all" | Id[] | Promise<"all" | Id[]>; - /** Created on first use, one set per request. */ - loaders?: Loaders; -} - -export interface Loaders { - patient: BatchLoader; - branch: BatchLoader; - doctor: BatchLoader; - booking: BatchLoader; - invoice: BatchLoader; - ledger: BatchLoader; - group: BatchLoader; -} - -export function loaders(ctx: DbGraphQLContext): Loaders { - const r = ctx.repos; - return (ctx.loaders ??= { - patient: new BatchLoader((ids) => r.patients.getMany(ids)), - branch: new BatchLoader((ids) => r.branches.getMany(ids)), - doctor: new BatchLoader((ids) => r.doctors.getMany(ids)), - booking: new BatchLoader((ids) => r.bookings.getMany(ids)), - invoice: new BatchLoader((ids) => r.invoices.getMany(ids)), - ledger: new BatchLoader((ids) => r.ledgers.getMany(ids)), - group: new BatchLoader((ids) => r.accountGroups.getMany(ids)), - }); -} - -// ------------------------------------------------------------------ guards - -export function viewerOf(ctx: DbGraphQLContext): Viewer { - if (!ctx.viewer) throw new GraphQLError("Sign in required", { extensions: { code: "UNAUTHENTICATED", http: { status: 401 } } }); - return ctx.viewer; -} - -export async function authorize(ctx: DbGraphQLContext, permission: string, branchId?: Id | null): Promise { - const v = viewerOf(ctx); - if (!(await ctx.can(permission, branchId))) { - throw new GraphQLError(`Missing permission ${permission}`, { extensions: { code: "FORBIDDEN", permission, http: { status: 403 } } }); - } - return v; -} - -/** Null unless the entity belongs to the viewer's organisation (tenant isolation). */ -export function own(ctx: DbGraphQLContext, entity: T | null | undefined): T | null { - return entity && ctx.viewer && entity.orgId === ctx.viewer.orgId ? entity : null; -} - -/** Loads an entity by id, enforces organisation + permission in its branch; NOT_FOUND otherwise. */ -export async function load( - ctx: DbGraphQLContext, - name: string, - fetch: () => Promise, - permission: string, -): Promise { - viewerOf(ctx); - const e = own(ctx, await fetch()); - if (!e) throw notFound(name); - await authorize(ctx, permission, e.branchId ?? null); - return e; -} - -export const notFound = (name: string) => new GraphQLError(`${name} not found`, { extensions: { code: "NOT_FOUND", http: { status: 404 } } }); - -/** The branch filter for a list: the requested one (checked) or the viewer's allowed branches. */ -export async function branchScope(ctx: DbGraphQLContext, permission: string, branchId?: Id | null): Promise<"all" | Id[]> { - if (branchId) { - await authorize(ctx, permission, branchId); - return [branchId]; - } - viewerOf(ctx); - const allowed = await ctx.branchesFor(permission); - if (allowed !== "all" && !allowed.length) await authorize(ctx, permission); // throws FORBIDDEN - return allowed; -} - -/** Domain errors → GraphQL errors with codes. Anything else is re-thrown (and masked by the server). */ -export function toGraphQLError(e: unknown): unknown { - if (e instanceof GraphQLError) return e; - const code = - e instanceof NotFoundError ? "NOT_FOUND" - : e instanceof ConflictError ? "CONFLICT" - : e instanceof ValidationError ? "BAD_USER_INPUT" - : null; - if (!code) return e; - const err = e as Error & { statusCode?: number }; - // no `originalError`: Yoga masks any GraphQLError that wraps a non-GraphQL error as "Unexpected error" - return new GraphQLError(err.message, { extensions: { code, http: { status: err.statusCode } } }); -} - -/** Wraps every resolver of a map so domain errors surface with proper codes. */ -export function withErrorCodes>>(resolvers: R): R { - for (const type of Object.values(resolvers)) { - for (const [field, fn] of Object.entries(type)) { - if (typeof fn !== "function") continue; - type[field] = async (...args: unknown[]) => { - try { - return await (fn as (...a: unknown[]) => unknown)(...args); - } catch (e) { - throw toGraphQLError(e); - } - }; - } - } - return resolvers; -} diff --git a/src/graphql/index.ts b/src/graphql/index.ts deleted file mode 100644 index eafde53..0000000 --- a/src/graphql/index.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { makeExecutableSchema } from "@graphql-tools/schema"; -import type { GraphQLSchema } from "graphql"; -import { resolvers } from "./resolvers"; -import { typeDefs } from "./type-defs"; - -export { typeDefs, resolvers }; -export { BatchLoader } from "./loader"; -export { - authorize, branchScope, load, loaders, own, toGraphQLError, viewerOf, withErrorCodes, - type DbGraphQLContext, type Loaders, type Viewer, -} from "./context"; - -/** A GraphQL module: merge several (db-kit, auth-kit, catalog-kit, your own) into one schema. */ -export interface GraphQLModule { - typeDefs: string; - resolvers: Record; -} - -export const dbModule: GraphQLModule = { typeDefs, resolvers }; - -/** Permissions the db-kit resolvers check. auth-kit's roles grant these. */ -export const DB_PERMISSIONS = [ - "org:read", "branch:read", - "patient:read", "patient:create", "patient:update", - "doctor:read", "doctor:manage", - "booking:read", "booking:create", "booking:cancel", - "sample:read", "sample:collect", "sample:receive", "sample:reject", - "report:read", "report:enter", "report:verify", "report:release", "report:amend", - "invoice:read", "invoice:create", "invoice:discount", "invoice:cancel", - "payment:create", "payment:refund", - "accounts:read", "accounts:post", "accounts:manage", -] as const; - -/** - * Builds one executable schema from modules. Each module may `extend type Query/Mutation`. - * const schema = createSchema([dbModule, authModule, catalogModule]); - * const yoga = createYoga({ schema, context: (req) => auth.context(req, db.repos) }); - */ -export function createSchema(modules: GraphQLModule[]): GraphQLSchema { - return makeExecutableSchema({ typeDefs: modules.map((m) => m.typeDefs), resolvers: modules.map((m) => m.resolvers) }); -} diff --git a/src/graphql/loader.ts b/src/graphql/loader.ts deleted file mode 100644 index 1a70aa9..0000000 --- a/src/graphql/loader.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Minimal DataLoader: every load() in the same tick is batched into one getMany(), and results - * are memoised for the request. Prevents N+1 queries when a list resolves nested fields - * (e.g. 50 invoices → 1 query for their patients). - */ -export class BatchLoader { - private cache = new Map>(); - private queue: { id: string; resolve: (v: T | null) => void; reject: (e: unknown) => void }[] = []; - - constructor(private readonly fetchMany: (ids: readonly string[]) => Promise) {} - - load(id: string | null | undefined): Promise { - if (!id) return Promise.resolve(null); - const hit = this.cache.get(id); - if (hit) return hit; - const p = new Promise((resolve, reject) => { - this.queue.push({ id, resolve, reject }); - if (this.queue.length === 1) queueMicrotask(() => void this.flush()); - }); - this.cache.set(id, p); - return p; - } - - /** Seeds the cache with an entity you already have (e.g. just created). */ - prime(entity: T): void { - this.cache.set(entity.id, Promise.resolve(entity)); - } - - clear(id: string): void { - this.cache.delete(id); - } - - private async flush(): Promise { - const batch = this.queue; - this.queue = []; - try { - const rows = await this.fetchMany([...new Set(batch.map((b) => b.id))]); - const byId = new Map(rows.map((r) => [r.id, r])); - for (const b of batch) b.resolve(byId.get(b.id) ?? null); - } catch (e) { - for (const b of batch) { - this.cache.delete(b.id); - b.reject(e); - } - } - } -} diff --git a/src/graphql/resolvers.ts b/src/graphql/resolvers.ts deleted file mode 100644 index 862d67e..0000000 --- a/src/graphql/resolvers.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { GraphQLScalarType, Kind } from "graphql"; -import { cancelVoucher, postVoucher, seedChartOfAccounts } from "../core/accounting"; -import { ConflictError, ValidationError, type Repo } from "../core/ports"; -import { - balanceSheet, dayBook, gstSummary, ledgerBalance, ledgerStatement, outstandingReceivables, profitAndLoss, trialBalance, -} from "../core/reports"; -import { - cancelInvoice, createBooking, issueInvoice, recordPayment, registerPatient, saveResults, transitionReport, -} from "../core/services"; -import type { - AccountGroup, Booking, Id, Invoice, Ledger, Patient, Report, ResultValue, Sample, Voucher, VoucherEntry, -} from "../core/types"; -import { authorize, branchScope, load, loaders, notFound, own, viewerOf, withErrorCodes, type DbGraphQLContext as Ctx } from "./context"; - -// ------------------------------------------------------------------ scalars & enum mapping - -const DateTime = new GraphQLScalarType({ - name: "DateTime", - description: "ISO-8601 date-time", - serialize: (v) => (v instanceof Date ? v : new Date(v as string)).toISOString(), - parseValue: (v) => { - const d = new Date(v as string); - if (Number.isNaN(d.getTime())) throw new TypeError("Invalid DateTime"); - return d; - }, - parseLiteral: (ast) => (ast.kind === Kind.STRING ? new Date(ast.value) : null), -}); - -const JSONScalar = new GraphQLScalarType({ name: "JSON", serialize: (v) => v, parseValue: (v) => v }); - -const flagOut = (f: ResultValue["flag"]) => (f === "" ? "NONE" : f ?? null); -const flagIn = (f?: string | null): ResultValue["flag"] => (f === "NONE" ? "" : ((f ?? undefined) as ResultValue["flag"])); -const BUCKET = { "0-30": "D0_30", "31-60": "D31_60", "61-90": "D61_90", "90+": "D90_PLUS" } as const; - -const clean = (o: T): Partial => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined && v !== null)) as Partial; -const cap = (n: number | undefined, d: number) => Math.min(Math.max(1, n ?? d), 200); - -/** Lists honour the viewer's branch access: all branches, or only the allowed ones. */ -async function scopedFind(repo: Repo, filter: Record, scope: "all" | Id[], limit: number, offset: number, sortField: string): Promise { - const sort = { field: sortField as keyof T & string, dir: "desc" as const }; - if (scope === "all") return repo.find(filter as any, { limit, offset, sort }); - const lists = await Promise.all(scope.map((b) => repo.find({ ...filter, branchId: b } as any, { limit: limit + offset, sort }))); - return lists.flat().sort((a: any, b: any) => +b[sortField] - +a[sortField]).slice(offset, offset + limit); -} - -async function ownLedgers(ctx: Ctx, ids: Id[]): Promise { - const v = viewerOf(ctx); - const found = await ctx.repos.ledgers.getMany([...new Set(ids)]); - if (found.length !== new Set(ids).size || found.some((l) => l.orgId !== v.orgId)) throw notFound("Ledger"); -} - -const canSee = async (ctx: Ctx, permission: string, branchId?: Id | null) => Boolean(ctx.viewer && (await ctx.can(permission, branchId))); - -// ------------------------------------------------------------------ resolvers - -export const resolvers = withErrorCodes({ - Query: { - organization: async (_: unknown, __: unknown, ctx: Ctx) => ctx.repos.orgs.get(viewerOf(ctx).orgId), - branches: async (_: unknown, __: unknown, ctx: Ctx) => ctx.repos.branches.find({ orgId: viewerOf(ctx).orgId }, { sort: { field: "code" } }), - - patient: async (_: unknown, a: { id: Id }, ctx: Ctx) => { - await authorize(ctx, "patient:read"); - return own(ctx, await ctx.repos.patients.get(a.id)); - }, - patients: async (_: unknown, a: { search: string; limit?: number }, ctx: Ctx) => { - const v = await authorize(ctx, "patient:read"); - return ctx.repos.patients.search(v.orgId, a.search, cap(a.limit, 20)); - }, - doctors: async (_: unknown, __: unknown, ctx: Ctx) => { - const v = await authorize(ctx, "doctor:read"); - return ctx.repos.doctors.find({ orgId: v.orgId }, { sort: { field: "name" } }); - }, - - booking: async (_: unknown, a: { id: Id }, ctx: Ctx) => load(ctx, "Booking", () => ctx.repos.bookings.get(a.id), "booking:read"), - bookings: async (_: unknown, a: { branchId?: Id; status?: string; patientId?: Id; limit?: number; offset?: number }, ctx: Ctx) => { - const scope = await branchScope(ctx, "booking:read", a.branchId); - return scopedFind(ctx.repos.bookings, clean({ orgId: viewerOf(ctx).orgId, status: a.status, patientId: a.patientId }), scope, cap(a.limit, 50), a.offset ?? 0, "createdAt"); - }, - sampleByBarcode: async (_: unknown, a: { barcode: string }, ctx: Ctx) => { - const v = viewerOf(ctx); - const s = await ctx.repos.samples.findOne({ orgId: v.orgId, barcode: a.barcode.trim() }); - if (!s) return null; - await authorize(ctx, "sample:read", s.branchId); - return s; - }, - report: async (_: unknown, a: { id: Id }, ctx: Ctx) => load(ctx, "Report", () => ctx.repos.reports.get(a.id), "report:read"), - invoice: async (_: unknown, a: { id: Id }, ctx: Ctx) => load(ctx, "Invoice", () => ctx.repos.invoices.get(a.id), "invoice:read"), - invoices: async (_: unknown, a: { branchId?: Id; status?: string; patientId?: Id; limit?: number; offset?: number }, ctx: Ctx) => { - const scope = await branchScope(ctx, "invoice:read", a.branchId); - return scopedFind(ctx.repos.invoices, clean({ orgId: viewerOf(ctx).orgId, status: a.status, patientId: a.patientId }), scope, cap(a.limit, 50), a.offset ?? 0, "createdAt"); - }, - - accountGroups: async (_: unknown, __: unknown, ctx: Ctx) => ctx.repos.accountGroups.find({ orgId: (await authorize(ctx, "accounts:read")).orgId }, { sort: { field: "name" } }), - ledgers: async (_: unknown, __: unknown, ctx: Ctx) => ctx.repos.ledgers.find({ orgId: (await authorize(ctx, "accounts:read")).orgId }, { sort: { field: "name" } }), - ledgerStatement: async (_: unknown, a: { ledgerId: Id; from: Date; to: Date; branchId?: Id }, ctx: Ctx) => { - const v = await authorize(ctx, "accounts:read", a.branchId); - await ownLedgers(ctx, [a.ledgerId]); - return ledgerStatement(ctx.repos, { orgId: v.orgId, ...a }); - }, - trialBalance: async (_: unknown, a: { asOf?: Date; branchId?: Id }, ctx: Ctx) => trialBalance(ctx.repos, { orgId: (await authorize(ctx, "accounts:read", a.branchId)).orgId, ...a }), - profitAndLoss: async (_: unknown, a: { from: Date; to: Date; branchId?: Id }, ctx: Ctx) => profitAndLoss(ctx.repos, { orgId: (await authorize(ctx, "accounts:read", a.branchId)).orgId, ...a }), - balanceSheet: async (_: unknown, a: { asOf: Date; branchId?: Id }, ctx: Ctx) => balanceSheet(ctx.repos, { orgId: (await authorize(ctx, "accounts:read", a.branchId)).orgId, ...a }), - dayBook: async (_: unknown, a: { from: Date; to: Date; branchId?: Id; type?: Voucher["type"] }, ctx: Ctx) => dayBook(ctx.repos, { orgId: (await authorize(ctx, "accounts:read", a.branchId)).orgId, ...a }), - outstandingReceivables: async (_: unknown, a: { branchId?: Id }, ctx: Ctx) => { - const out = await outstandingReceivables(ctx.repos, { orgId: (await authorize(ctx, "accounts:read", a.branchId)).orgId, branchId: a.branchId }); - return { - rows: out.rows.map((r) => ({ ...r, bucket: BUCKET[r.bucket] })), - buckets: { d0to30: out.buckets["0-30"], d31to60: out.buckets["31-60"], d61to90: out.buckets["61-90"], d90plus: out.buckets["90+"] }, - totalDuePaise: out.totalDuePaise, - }; - }, - gstSummary: async (_: unknown, a: { from: Date; to: Date; branchId?: Id }, ctx: Ctx) => gstSummary(ctx.repos, { orgId: (await authorize(ctx, "accounts:read", a.branchId)).orgId, ...a }), - }, - - Organization: { - branches: (o: { id: Id }, _: unknown, ctx: Ctx) => ctx.repos.branches.find({ orgId: o.id }, { sort: { field: "code" } }), - }, - Patient: { - branch: (p: Patient, _: unknown, ctx: Ctx) => loaders(ctx).branch.load(p.branchId), - bookings: async (p: Patient, a: { limit?: number }, ctx: Ctx) => { - if (!(await canSee(ctx, "booking:read"))) return []; - const scope = await ctx.branchesFor("booking:read"); - return scopedFind(ctx.repos.bookings, { patientId: p.id }, scope, cap(a.limit, 20), 0, "createdAt"); - }, - invoices: async (p: Patient, a: { limit?: number }, ctx: Ctx) => { - if (!(await canSee(ctx, "invoice:read"))) return []; - const scope = await ctx.branchesFor("invoice:read"); - return scopedFind(ctx.repos.invoices, { patientId: p.id }, scope, cap(a.limit, 20), 0, "createdAt"); - }, - }, - Booking: { - patient: (b: Booking, _: unknown, ctx: Ctx) => loaders(ctx).patient.load(b.patientId), - doctor: (b: Booking, _: unknown, ctx: Ctx) => loaders(ctx).doctor.load(b.doctorId), - branch: (b: Booking, _: unknown, ctx: Ctx) => loaders(ctx).branch.load(b.branchId), - samples: (b: Booking, _: unknown, ctx: Ctx) => ctx.repos.samples.find({ bookingId: b.id }, { sort: { field: "barcode" } }), - reports: async (b: Booking, _: unknown, ctx: Ctx) => ((await canSee(ctx, "report:read", b.branchId)) ? ctx.repos.reports.find({ bookingId: b.id }) : []), - invoice: async (b: Booking, _: unknown, ctx: Ctx) => ((await canSee(ctx, "invoice:read", b.branchId)) ? loaders(ctx).invoice.load(b.invoiceId) : null), - }, - Report: { - results: (r: Report) => r.results.map((x) => ({ ...x, flag: flagOut(x.flag) })), - patient: (r: Report, _: unknown, ctx: Ctx) => loaders(ctx).patient.load(r.patientId), - booking: (r: Report, _: unknown, ctx: Ctx) => loaders(ctx).booking.load(r.bookingId), - }, - Invoice: { - patient: (i: Invoice, _: unknown, ctx: Ctx) => loaders(ctx).patient.load(i.patientId), - branch: (i: Invoice, _: unknown, ctx: Ctx) => loaders(ctx).branch.load(i.branchId), - booking: (i: Invoice, _: unknown, ctx: Ctx) => loaders(ctx).booking.load(i.bookingId), - payments: (i: Invoice, _: unknown, ctx: Ctx) => ctx.repos.payments.find({ invoiceId: i.id }, { sort: { field: "receivedAt" } }), - vouchers: async (i: Invoice, _: unknown, ctx: Ctx) => ((await canSee(ctx, "accounts:read", i.branchId)) ? ctx.repos.vouchers.find({ invoiceId: i.id } as any, { sort: { field: "date" } }) : []), - }, - AccountGroup: { - parent: (g: AccountGroup, _: unknown, ctx: Ctx) => loaders(ctx).group.load(g.parentId), - }, - Ledger: { - group: (l: Ledger, _: unknown, ctx: Ctx) => loaders(ctx).group.load(l.groupId), - balancePaise: (l: Ledger, a: { asOf?: Date; branchId?: Id }, ctx: Ctx) => ledgerBalance(ctx.repos, { orgId: l.orgId, ledgerId: l.id, ...a }), - }, - VoucherEntry: { - ledger: (e: VoucherEntry, _: unknown, ctx: Ctx) => loaders(ctx).ledger.load(e.ledgerId), - }, - - Mutation: { - registerPatient: async (_: unknown, { input }: { input: any }, ctx: Ctx) => { - const v = await authorize(ctx, "patient:create", input.branchId); - if (!own(ctx, await ctx.repos.branches.get(input.branchId))) throw notFound("Branch"); - return registerPatient(ctx.repos, { ...input, orgId: v.orgId }, v.userId); - }, - updatePatient: async (_: unknown, { id, input }: { id: Id; input: any }, ctx: Ctx) => { - const v = await authorize(ctx, "patient:update"); - if (!own(ctx, await ctx.repos.patients.get(id))) throw notFound("Patient"); - const p = await ctx.repos.patients.update(id, clean(input)); - await ctx.repos.audit.log({ orgId: v.orgId, userId: v.userId, action: "patient.update", entity: "patient", entityId: id }); - return p; - }, - createDoctor: async (_: unknown, { input }: { input: any }, ctx: Ctx) => { - const v = await authorize(ctx, "doctor:manage"); - return ctx.repos.doctors.create({ ...input, orgId: v.orgId, active: input.active ?? true }); - }, - - createBooking: async (_: unknown, { input }: { input: any }, ctx: Ctx) => { - const v = await authorize(ctx, "booking:create", input.branchId); - if (!own(ctx, await ctx.repos.branches.get(input.branchId))) throw notFound("Branch"); - if (!own(ctx, await ctx.repos.patients.get(input.patientId))) throw notFound("Patient"); - if (input.doctorId && !own(ctx, await ctx.repos.doctors.get(input.doctorId))) throw notFound("Doctor"); - return createBooking(ctx.repos, { - orgId: v.orgId, branchId: input.branchId, patientId: input.patientId, doctorId: input.doctorId ?? null, tests: input.tests, - sampleCollection: { type: input.collectionType ?? "walk_in", scheduledAt: input.scheduledAt ?? null }, notes: input.notes, - }, v.userId); - }, - cancelBooking: async (_: unknown, { id }: { id: Id }, ctx: Ctx) => { - const b = await load(ctx, "Booking", () => ctx.repos.bookings.get(id), "booking:cancel"); - if (b.invoiceId) { - const inv = await ctx.repos.invoices.get(b.invoiceId); - if (inv && inv.status !== "cancelled") throw new ConflictError("Cancel the booking's invoice first"); - } - const out = await ctx.repos.bookings.update(id, { status: "cancelled" }); - await ctx.repos.audit.log({ orgId: b.orgId, branchId: b.branchId, userId: ctx.viewer!.userId, action: "booking.cancel", entity: "booking", entityId: id }); - return out; - }, - collectSample: async (_: unknown, { id }: { id: Id }, ctx: Ctx) => { - const s = await load(ctx, "Sample", () => ctx.repos.samples.get(id), "sample:collect"); - if (s.status !== "pending") throw new ConflictError(`Sample is ${s.status}`); - const out = await ctx.repos.samples.update(id, { status: "collected", collectedAt: new Date(), collectedBy: ctx.viewer!.userId }); - const b = await ctx.repos.bookings.get(s.bookingId); - if (b?.status === "booked") await ctx.repos.bookings.update(b.id, { status: "sample_collected" }); - return out; - }, - receiveSample: async (_: unknown, { id }: { id: Id }, ctx: Ctx) => { - const s = await load(ctx, "Sample", () => ctx.repos.samples.get(id), "sample:receive"); - if (s.status !== "collected") throw new ConflictError(`Sample is ${s.status}`); - const out = await ctx.repos.samples.update(id, { status: "received", receivedAt: new Date() }); - await ctx.repos.bookings.update(s.bookingId, { status: "in_process" }); - return out; - }, - rejectSample: async (_: unknown, { id, reason }: { id: Id; reason: string }, ctx: Ctx) => { - await load(ctx, "Sample", () => ctx.repos.samples.get(id), "sample:reject"); - return ctx.repos.samples.update(id, { status: "rejected", rejectReason: reason }); - }, - - createReport: async (_: unknown, a: { bookingId: Id; testCode: string; testName: string }, ctx: Ctx) => { - const b = await load(ctx, "Booking", () => ctx.repos.bookings.get(a.bookingId), "report:enter"); - if (!b.tests.some((t) => t.testCode === a.testCode)) throw new ValidationError(`Test ${a.testCode} is not in booking ${b.bookingNo}`); - return ctx.repos.reports.create({ orgId: b.orgId, branchId: b.branchId, bookingId: b.id, patientId: b.patientId, testCode: a.testCode, testName: a.testName, results: [], status: "draft", version: 1 }); - }, - saveResults: async (_: unknown, a: { reportId: Id; results: any[] }, ctx: Ctx) => { - await load(ctx, "Report", () => ctx.repos.reports.get(a.reportId), "report:enter"); - return saveResults(ctx.repos, a.reportId, a.results.map((r) => ({ ...r, flag: flagIn(r.flag) })), ctx.viewer!.userId); - }, - verifyReport: async (_: unknown, { id }: { id: Id }, ctx: Ctx) => { - await load(ctx, "Report", () => ctx.repos.reports.get(id), "report:verify"); - return transitionReport(ctx.repos, id, "verified", ctx.viewer!.userId); - }, - releaseReport: async (_: unknown, { id }: { id: Id }, ctx: Ctx) => { - const r = await load(ctx, "Report", () => ctx.repos.reports.get(id), "report:release"); - const out = await transitionReport(ctx.repos, id, "released", ctx.viewer!.userId); - const all = await ctx.repos.reports.find({ bookingId: r.bookingId }); - if (all.every((x) => x.status === "released")) await ctx.repos.bookings.update(r.bookingId, { status: "completed" }); - return out; - }, - amendReport: async (_: unknown, { id }: { id: Id }, ctx: Ctx) => { - await load(ctx, "Report", () => ctx.repos.reports.get(id), "report:amend"); - return transitionReport(ctx.repos, id, "amended", ctx.viewer!.userId); - }, - - issueInvoice: async (_: unknown, { input }: { input: any }, ctx: Ctx) => { - const v = await authorize(ctx, "invoice:create", input.branchId); - const discounted = (input.billDiscountPaise ?? 0) > 0 || input.items.some((i: any) => (i.discountPaise ?? 0) > 0); - if (discounted) await authorize(ctx, "invoice:discount", input.branchId); - if (input.payments?.length) await authorize(ctx, "payment:create", input.branchId); - if (!own(ctx, await ctx.repos.branches.get(input.branchId))) throw notFound("Branch"); - if (!own(ctx, await ctx.repos.patients.get(input.patientId))) throw notFound("Patient"); - if (input.bookingId && !own(ctx, await ctx.repos.bookings.get(input.bookingId))) throw notFound("Booking"); - const { invoice } = await issueInvoice(ctx.repos, { ...input, orgId: v.orgId }, v.userId); - return invoice; - }, - recordPayment: async (_: unknown, a: { invoiceId: Id; payment: any }, ctx: Ctx) => { - await load(ctx, "Invoice", () => ctx.repos.invoices.get(a.invoiceId), "payment:create"); - return (await recordPayment(ctx.repos, { invoiceId: a.invoiceId, ...a.payment, kind: "payment" }, ctx.viewer!.userId)).invoice; - }, - refundPayment: async (_: unknown, a: { invoiceId: Id; payment: any }, ctx: Ctx) => { - await load(ctx, "Invoice", () => ctx.repos.invoices.get(a.invoiceId), "payment:refund"); - return (await recordPayment(ctx.repos, { invoiceId: a.invoiceId, ...a.payment, kind: "refund" }, ctx.viewer!.userId)).invoice; - }, - cancelInvoice: async (_: unknown, a: { id: Id; reason: string }, ctx: Ctx) => { - await load(ctx, "Invoice", () => ctx.repos.invoices.get(a.id), "invoice:cancel"); - return cancelInvoice(ctx.repos, a.id, a.reason, ctx.viewer!.userId); - }, - - setupChartOfAccounts: async (_: unknown, __: unknown, ctx: Ctx) => (await seedChartOfAccounts(ctx.repos, (await authorize(ctx, "accounts:manage")).orgId)).ledgers, - createLedger: async (_: unknown, { input }: { input: any }, ctx: Ctx) => { - const v = await authorize(ctx, "accounts:manage"); - if (!own(ctx, await ctx.repos.accountGroups.get(input.groupId))) throw notFound("Account group"); - return ctx.repos.ledgers.create({ orgId: v.orgId, name: input.name, groupId: input.groupId, openingPaise: input.openingPaise ?? 0, gstin: input.gstin, systemKey: null, active: true }); - }, - postVoucher: async (_: unknown, { input }: { input: any }, ctx: Ctx) => { - const v = await authorize(ctx, "accounts:post", input.branchId); - if (!own(ctx, await ctx.repos.branches.get(input.branchId))) throw notFound("Branch"); - await ownLedgers(ctx, input.entries.map((e: VoucherEntry) => e.ledgerId)); // no posting into another organisation's books - for (const e of input.entries) if (e.invoiceId && !own(ctx, await ctx.repos.invoices.get(e.invoiceId))) throw notFound("Invoice"); - return postVoucher(ctx.repos, { ...input, orgId: v.orgId, entries: input.entries.map((e: any) => ({ ...e, debitPaise: e.debitPaise ?? 0, creditPaise: e.creditPaise ?? 0 })) }, v.userId); - }, - cancelVoucher: async (_: unknown, { id }: { id: Id }, ctx: Ctx) => { - await load(ctx, "Voucher", () => ctx.repos.vouchers.get(id), "accounts:post"); - return cancelVoucher(ctx.repos, id, ctx.viewer!.userId); - }, - }, -}); - -// scalars are added after wrapping (they are not functions to wrap) -Object.assign(resolvers, { DateTime, JSON: JSONScalar }); diff --git a/src/graphql/type-defs.ts b/src/graphql/type-defs.ts deleted file mode 100644 index a5c805d..0000000 --- a/src/graphql/type-defs.ts +++ /dev/null @@ -1,283 +0,0 @@ -/** - * GraphQL schema for the data layer. Money is Int paise (₹1 = 100). Dates are ISO-8601 DateTime. - * Everything is scoped to the signed-in user's organisation; no query takes an orgId. - */ -export const typeDefs = /* GraphQL */ ` - scalar DateTime - scalar JSON - - enum Sex { male female other } - enum BranchKind { lab collection_center } - enum BookingStatus { booked sample_collected in_process completed cancelled } - enum CollectionType { walk_in home } - enum SampleStatus { pending collected received rejected } - enum ReportStatus { draft entered verified released amended } - enum InvoiceStatus { draft issued partially_paid paid cancelled refunded credited } - enum ItemCategory { test package service consumable medicine other } - enum PaymentMode { cash upi card netbanking wallet cheque insurance } - enum PaymentKind { payment refund } - enum ResultFlag { L H A NONE } - enum AccountNature { assets liabilities income expenses } - enum VoucherType { sales receipt payment journal contra credit_note debit_note purchase closing } - enum VoucherStatus { posted cancelled } - enum AgeBucket { D0_30 D31_60 D61_90 D90_PLUS } - - type Address { line1: String, line2: String, city: String, district: String, state: String, pincode: String } - - type Organization { - id: ID! - name: String! - code: String! - legalName: String - gstin: String - phone: String - email: String - address: Address - branches: [Branch!]! - } - - type Branch { id: ID!, code: String!, name: String!, kind: BranchKind!, phone: String, gstin: String, address: Address, active: Boolean! } - - type Patient { - id: ID! - uhid: String! - title: String - name: String! - sex: Sex! - dob: DateTime - ageYears: Int - phone: String - email: String - address: Address - branch: Branch - createdAt: DateTime! - bookings(limit: Int = 20): [Booking!]! - invoices(limit: Int = 20): [Invoice!]! - } - - type Doctor { id: ID!, name: String!, qualification: String, phone: String, clinic: String, active: Boolean! } - - type BookingTest { testCode: String!, name: String!, pricePaise: Int!, sampleType: String } - type SampleCollection { type: CollectionType!, scheduledAt: DateTime, address: Address } - - type Booking { - id: ID! - bookingNo: String! - status: BookingStatus! - patient: Patient! - doctor: Doctor - branch: Branch! - tests: [BookingTest!]! - sampleCollection: SampleCollection! - samples: [Sample!]! - reports: [Report!]! - invoice: Invoice - notes: String - createdAt: DateTime! - } - - type Sample { - id: ID! - barcode: String! - sampleType: String! - status: SampleStatus! - collectedAt: DateTime - receivedAt: DateTime - rejectReason: String - } - - type ResultValue { parameter: String!, value: String!, unit: String, flag: ResultFlag, range: String } - - type Report { - id: ID! - testCode: String! - testName: String! - status: ReportStatus! - results: [ResultValue!]! - version: Int! - verifiedAt: DateTime - releasedAt: DateTime - patient: Patient! - booking: Booking! - updatedAt: DateTime! - } - - type InvoiceItem { - code: String - name: String! - category: ItemCategory! - qty: Float! - ratePaise: Int! - discountPaise: Int! - taxPercent: Float! - amountPaise: Int! - } - - type Invoice { - id: ID! - invoiceNo: String! - status: InvoiceStatus! - patient: Patient! - branch: Branch! - booking: Booking - items: [InvoiceItem!]! - subtotalPaise: Int! - discountPaise: Int! - taxPaise: Int! - roundOffPaise: Int! - totalPaise: Int! - paidPaise: Int! - duePaise: Int! - issuedAt: DateTime - cancelReason: String - payments: [Payment!]! - vouchers: [Voucher!]! - } - - type Payment { id: ID!, kind: PaymentKind!, amountPaise: Int!, mode: PaymentMode!, reference: String, receivedAt: DateTime! } - - # ---------------------------------------------------------------- accounting (Tally-style) - - type AccountGroup { id: ID!, name: String!, nature: AccountNature!, parent: AccountGroup, affectsGrossProfit: Boolean! } - - type Ledger { - id: ID! - name: String! - group: AccountGroup! - openingPaise: Int! - systemKey: String - gstin: String - active: Boolean! - "Closing balance, positive = Debit." - balancePaise(asOf: DateTime, branchId: ID): Int! - } - - type VoucherEntry { ledger: Ledger!, debitPaise: Int!, creditPaise: Int!, invoiceId: ID } - - type Voucher { - id: ID! - type: VoucherType! - voucherNo: String! - date: DateTime! - narration: String - status: VoucherStatus! - entries: [VoucherEntry!]! - invoiceId: ID - } - - type StatementRow { date: DateTime!, voucherNo: String!, voucherType: VoucherType!, debitPaise: Int!, creditPaise: Int!, balancePaise: Int!, invoiceId: ID } - type LedgerStatement { ledger: Ledger!, openingPaise: Int!, closingPaise: Int!, totalDebitPaise: Int!, totalCreditPaise: Int!, rows: [StatementRow!]! } - type TrialBalanceRow { ledger: Ledger!, group: AccountGroup!, primaryGroup: AccountGroup!, debitPaise: Int!, creditPaise: Int! } - type TrialBalance { rows: [TrialBalanceRow!]!, totalDebitPaise: Int!, totalCreditPaise: Int!, differencePaise: Int! } - type LedgerAmount { ledger: Ledger!, amountPaise: Int! } - type GroupLine { group: AccountGroup!, amountPaise: Int!, ledgers: [LedgerAmount!]! } - type ProfitAndLoss { income: [GroupLine!]!, expenses: [GroupLine!]!, grossProfitPaise: Int!, netProfitPaise: Int! } - type BalanceSheet { assets: [GroupLine!]!, liabilities: [GroupLine!]!, profitPaise: Int!, totalAssetsPaise: Int!, totalLiabilitiesPaise: Int!, differencePaise: Int! } - type OutstandingRow { invoice: Invoice!, ageDays: Int!, bucket: AgeBucket! } - type BucketTotals { d0to30: Int!, d31to60: Int!, d61to90: Int!, d90plus: Int! } - type Outstanding { rows: [OutstandingRow!]!, buckets: BucketTotals!, totalDuePaise: Int! } - type GstSummary { taxableValuePaise: Int!, cgstPaise: Int!, sgstPaise: Int!, igstPaise: Int!, totalTaxPaise: Int! } - - type Query { - organization: Organization! - branches: [Branch!]! - patient(id: ID!): Patient - "Name, phone or UHID." - patients(search: String!, limit: Int = 20): [Patient!]! - doctors: [Doctor!]! - booking(id: ID!): Booking - bookings(branchId: ID, status: BookingStatus, patientId: ID, limit: Int = 50, offset: Int = 0): [Booking!]! - sampleByBarcode(barcode: String!): Sample - report(id: ID!): Report - invoice(id: ID!): Invoice - invoices(branchId: ID, status: InvoiceStatus, patientId: ID, limit: Int = 50, offset: Int = 0): [Invoice!]! - - accountGroups: [AccountGroup!]! - ledgers: [Ledger!]! - ledgerStatement(ledgerId: ID!, from: DateTime!, to: DateTime!, branchId: ID): LedgerStatement! - trialBalance(asOf: DateTime, branchId: ID): TrialBalance! - profitAndLoss(from: DateTime!, to: DateTime!, branchId: ID): ProfitAndLoss! - balanceSheet(asOf: DateTime!, branchId: ID): BalanceSheet! - dayBook(from: DateTime!, to: DateTime!, branchId: ID, type: VoucherType): [Voucher!]! - outstandingReceivables(branchId: ID): Outstanding! - gstSummary(from: DateTime!, to: DateTime!, branchId: ID): GstSummary! - } - - input AddressInput { line1: String, line2: String, city: String, district: String, state: String, pincode: String } - - input RegisterPatientInput { - branchId: ID! - title: String - name: String! - sex: Sex! - dob: DateTime - ageYears: Int - phone: String - email: String - address: AddressInput - } - - input UpdatePatientInput { title: String, name: String, sex: Sex, dob: DateTime, ageYears: Int, phone: String, email: String, address: AddressInput } - - input DoctorInput { name: String!, qualification: String, phone: String, clinic: String, active: Boolean = true } - - input BookingTestInput { testCode: String!, name: String!, pricePaise: Int!, sampleType: String } - - input CreateBookingInput { - branchId: ID! - patientId: ID! - doctorId: ID - tests: [BookingTestInput!]! - collectionType: CollectionType = walk_in - scheduledAt: DateTime - notes: String - } - - input ResultInput { parameter: String!, value: String!, unit: String, flag: ResultFlag, range: String } - - input InvoiceItemInput { code: String, name: String!, category: ItemCategory = test, qty: Float = 1, ratePaise: Int!, discountPaise: Int = 0, taxPercent: Float = 0 } - input PaymentInput { amountPaise: Int!, mode: PaymentMode!, reference: String } - - input IssueInvoiceInput { - branchId: ID! - patientId: ID! - bookingId: ID - items: [InvoiceItemInput!]! - billDiscountPaise: Int = 0 - payments: [PaymentInput!] - } - - input VoucherEntryInput { ledgerId: ID!, debitPaise: Int = 0, creditPaise: Int = 0, invoiceId: ID } - input PostVoucherInput { branchId: ID!, type: VoucherType!, date: DateTime, narration: String, entries: [VoucherEntryInput!]! } - input LedgerInput { name: String!, groupId: ID!, openingPaise: Int = 0, gstin: String } - - type CreateBookingResult { booking: Booking!, samples: [Sample!]! } - - type Mutation { - registerPatient(input: RegisterPatientInput!): Patient! - updatePatient(id: ID!, input: UpdatePatientInput!): Patient! - createDoctor(input: DoctorInput!): Doctor! - - createBooking(input: CreateBookingInput!): CreateBookingResult! - cancelBooking(id: ID!): Booking! - collectSample(id: ID!): Sample! - receiveSample(id: ID!): Sample! - rejectSample(id: ID!, reason: String!): Sample! - - createReport(bookingId: ID!, testCode: String!, testName: String!): Report! - saveResults(reportId: ID!, results: [ResultInput!]!): Report! - verifyReport(id: ID!): Report! - releaseReport(id: ID!): Report! - amendReport(id: ID!): Report! - - issueInvoice(input: IssueInvoiceInput!): Invoice! - recordPayment(invoiceId: ID!, payment: PaymentInput!): Invoice! - refundPayment(invoiceId: ID!, payment: PaymentInput!): Invoice! - cancelInvoice(id: ID!, reason: String!): Invoice! - - setupChartOfAccounts: [Ledger!]! - createLedger(input: LedgerInput!): Ledger! - postVoucher(input: PostVoucherInput!): Voucher! - cancelVoucher(id: ID!): Voucher! - } -`; diff --git a/tsup.config.ts b/tsup.config.ts index 4628206..bc7dd3d 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -8,12 +8,11 @@ export default defineConfig({ cache: "src/cache/index.ts", fastify: "src/adapters/fastify.ts", express: "src/adapters/express.ts", - graphql: "src/graphql/index.ts", }, format: ["esm"], dts: true, target: "node20", platform: "node", clean: true, - external: [/^node:/, "mongoose", "sequelize", "pg", "pg-hstore", "ioredis", "fastify", "graphql", "@graphql-tools/schema"], + external: [/^node:/, "mongoose", "sequelize", "pg", "pg-hstore", "ioredis", "fastify"], });