@digimuza/system-core 0.2.0-rc.2 → 0.3.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/INTEGRATION.md +209 -3
  2. package/README.md +23 -0
  3. package/dist/ai/api.d.ts +120 -0
  4. package/dist/ai/api.d.ts.map +1 -0
  5. package/dist/ai/api.js +446 -0
  6. package/dist/ai/api.js.map +1 -0
  7. package/dist/ai/card.d.ts +27 -0
  8. package/dist/ai/card.d.ts.map +1 -0
  9. package/dist/ai/card.js +84 -0
  10. package/dist/ai/card.js.map +1 -0
  11. package/dist/ai/contract.d.ts +364 -0
  12. package/dist/ai/contract.d.ts.map +1 -0
  13. package/dist/ai/contract.js +101 -0
  14. package/dist/ai/contract.js.map +1 -0
  15. package/dist/ai/display.d.ts +20 -0
  16. package/dist/ai/display.d.ts.map +1 -0
  17. package/dist/ai/display.js +35 -0
  18. package/dist/ai/display.js.map +1 -0
  19. package/dist/ai/index.d.ts +7 -0
  20. package/dist/ai/index.d.ts.map +1 -0
  21. package/dist/ai/index.js +10 -0
  22. package/dist/ai/index.js.map +1 -0
  23. package/dist/ai/manifest.d.ts +25 -0
  24. package/dist/ai/manifest.d.ts.map +1 -0
  25. package/dist/ai/manifest.js +15 -0
  26. package/dist/ai/manifest.js.map +1 -0
  27. package/dist/ai/runner.d.ts +71 -0
  28. package/dist/ai/runner.d.ts.map +1 -0
  29. package/dist/ai/runner.js +246 -0
  30. package/dist/ai/runner.js.map +1 -0
  31. package/dist/ai/tools.d.ts +42 -0
  32. package/dist/ai/tools.d.ts.map +1 -0
  33. package/dist/ai/tools.js +117 -0
  34. package/dist/ai/tools.js.map +1 -0
  35. package/dist/audit/csv.d.ts +31 -0
  36. package/dist/audit/csv.d.ts.map +1 -0
  37. package/dist/audit/csv.js +39 -0
  38. package/dist/audit/csv.js.map +1 -0
  39. package/dist/audit/index.d.ts +5 -0
  40. package/dist/audit/index.d.ts.map +1 -0
  41. package/dist/audit/index.js +6 -0
  42. package/dist/audit/index.js.map +1 -0
  43. package/dist/convex/functions.d.ts +39 -0
  44. package/dist/convex/functions.d.ts.map +1 -1
  45. package/dist/convex/functions.js +38 -6
  46. package/dist/convex/functions.js.map +1 -1
  47. package/dist/convex/index.d.ts +1 -1
  48. package/dist/convex/index.d.ts.map +1 -1
  49. package/dist/convex/index.js.map +1 -1
  50. package/dist/core/access.d.ts +14 -0
  51. package/dist/core/access.d.ts.map +1 -1
  52. package/dist/core/access.js +62 -0
  53. package/dist/core/access.js.map +1 -1
  54. package/dist/core/audit-module.d.ts +5 -0
  55. package/dist/core/audit-module.d.ts.map +1 -0
  56. package/dist/core/audit-module.js +25 -0
  57. package/dist/core/audit-module.js.map +1 -0
  58. package/dist/core/audit.d.ts +31 -0
  59. package/dist/core/audit.d.ts.map +1 -1
  60. package/dist/core/audit.js +46 -2
  61. package/dist/core/audit.js.map +1 -1
  62. package/dist/core/catalog.d.ts +41 -0
  63. package/dist/core/catalog.d.ts.map +1 -1
  64. package/dist/core/catalog.js +45 -0
  65. package/dist/core/catalog.js.map +1 -1
  66. package/dist/core/context.d.ts +8 -2
  67. package/dist/core/context.d.ts.map +1 -1
  68. package/dist/core/context.js +4 -1
  69. package/dist/core/context.js.map +1 -1
  70. package/dist/core/index.d.ts +2 -0
  71. package/dist/core/index.d.ts.map +1 -1
  72. package/dist/core/index.js +2 -0
  73. package/dist/core/index.js.map +1 -1
  74. package/dist/core/notify.d.ts +37 -0
  75. package/dist/core/notify.d.ts.map +1 -0
  76. package/dist/core/notify.js +16 -0
  77. package/dist/core/notify.js.map +1 -0
  78. package/dist/core/roles.d.ts.map +1 -1
  79. package/dist/core/roles.js +5 -1
  80. package/dist/core/roles.js.map +1 -1
  81. package/dist/core/system-modules.d.ts +4 -0
  82. package/dist/core/system-modules.d.ts.map +1 -1
  83. package/dist/core/system-modules.js +11 -0
  84. package/dist/core/system-modules.js.map +1 -1
  85. package/dist/finance/ai-tools.d.ts +55 -0
  86. package/dist/finance/ai-tools.d.ts.map +1 -0
  87. package/dist/finance/ai-tools.js +65 -0
  88. package/dist/finance/ai-tools.js.map +1 -0
  89. package/dist/finance/index.d.ts +87 -1
  90. package/dist/finance/index.d.ts.map +1 -1
  91. package/dist/finance/index.js +92 -38
  92. package/dist/finance/index.js.map +1 -1
  93. package/dist/finance/search.d.ts +15 -0
  94. package/dist/finance/search.d.ts.map +1 -0
  95. package/dist/finance/search.js +51 -0
  96. package/dist/finance/search.js.map +1 -0
  97. package/dist/index.d.ts +2 -0
  98. package/dist/index.d.ts.map +1 -1
  99. package/dist/index.js +2 -0
  100. package/dist/index.js.map +1 -1
  101. package/dist/modules/index.d.ts +1 -1
  102. package/dist/modules/index.d.ts.map +1 -1
  103. package/dist/modules/index.js +1 -1
  104. package/dist/modules/index.js.map +1 -1
  105. package/dist/notifications/api.d.ts +130 -0
  106. package/dist/notifications/api.d.ts.map +1 -0
  107. package/dist/notifications/api.js +384 -0
  108. package/dist/notifications/api.js.map +1 -0
  109. package/dist/notifications/contract.d.ts +132 -0
  110. package/dist/notifications/contract.d.ts.map +1 -0
  111. package/dist/notifications/contract.js +59 -0
  112. package/dist/notifications/contract.js.map +1 -0
  113. package/dist/notifications/index.d.ts +6 -0
  114. package/dist/notifications/index.d.ts.map +1 -0
  115. package/dist/notifications/index.js +9 -0
  116. package/dist/notifications/index.js.map +1 -0
  117. package/dist/search/index.d.ts +95 -0
  118. package/dist/search/index.d.ts.map +1 -0
  119. package/dist/search/index.js +151 -0
  120. package/dist/search/index.js.map +1 -0
  121. package/package.json +41 -2
  122. package/src/ai/api.ts +535 -0
  123. package/src/ai/card.ts +91 -0
  124. package/src/ai/contract.ts +245 -0
  125. package/src/ai/display.ts +38 -0
  126. package/src/ai/index.ts +9 -0
  127. package/src/ai/manifest.ts +16 -0
  128. package/src/ai/runner.ts +275 -0
  129. package/src/ai/tools.ts +128 -0
  130. package/src/audit/csv.ts +60 -0
  131. package/src/audit/index.ts +7 -0
  132. package/src/convex/functions.ts +54 -7
  133. package/src/convex/index.ts +1 -1
  134. package/src/core/access.ts +55 -1
  135. package/src/core/audit-module.ts +26 -0
  136. package/src/core/audit.ts +68 -2
  137. package/src/core/catalog.ts +63 -0
  138. package/src/core/context.ts +13 -2
  139. package/src/core/index.ts +2 -0
  140. package/src/core/notify.ts +52 -0
  141. package/src/core/roles.ts +5 -1
  142. package/src/core/system-modules.ts +12 -0
  143. package/src/finance/ai-tools.ts +68 -0
  144. package/src/finance/index.ts +119 -42
  145. package/src/finance/search.ts +58 -0
  146. package/src/index.ts +2 -0
  147. package/src/modules/index.ts +3 -0
  148. package/src/notifications/api.ts +477 -0
  149. package/src/notifications/contract.ts +142 -0
  150. package/src/notifications/index.ts +8 -0
  151. package/src/search/index.ts +198 -0
@@ -1,6 +1,7 @@
1
1
  import { buildCatalog, type Catalog, type ModuleManifest } from "./catalog.js"
2
2
  import { newTraceId } from "../diagnostics/tracing.js"
3
3
  import { silentLogger, type Logger } from "../diagnostics/logging.js"
4
+ import type { Notifier } from "./notify.js"
4
5
  import type { Store } from "./store.js"
5
6
  import { SYSTEM_MODULES, SYSTEM_ROLES } from "./system-modules.js"
6
7
 
@@ -57,12 +58,20 @@ export interface SystemContext {
57
58
  readonly operationId: string
58
59
  readonly logger: Logger
59
60
  readonly superEmails: ReadonlySet<string>
61
+ /** Present when the app installed notifications (see `notifyIfInstalled`). */
62
+ readonly notifier?: Notifier
60
63
  now(): number
61
64
  }
62
65
 
63
- export function bindSystem(def: SystemDefinition, store: Store, options: { operationId?: string } = {}): SystemContext {
66
+ export interface BindOptions {
67
+ operationId?: string
68
+ /** Builds the transaction's notifier (Convex layer, when notifications are installed). */
69
+ notifier?: (sys: SystemContext) => Notifier
70
+ }
71
+
72
+ export function bindSystem(def: SystemDefinition, store: Store, options: BindOptions = {}): SystemContext {
64
73
  const operationId = options.operationId ?? newTraceId()
65
- return {
74
+ const sys: { -readonly [K in keyof SystemContext]: SystemContext[K] } = {
66
75
  def,
67
76
  store,
68
77
  operationId,
@@ -70,4 +79,6 @@ export function bindSystem(def: SystemDefinition, store: Store, options: { opera
70
79
  superEmails: def.superUserEmails(),
71
80
  now: () => def.now(),
72
81
  }
82
+ if (options.notifier) sys.notifier = options.notifier(sys)
83
+ return sys
73
84
  }
package/src/core/index.ts CHANGED
@@ -18,3 +18,5 @@ export * from "./modules-service.js"
18
18
  export * from "./feature-flags.js"
19
19
  export * from "./jobs.js"
20
20
  export * from "./role-view.js"
21
+ export * from "./notify.js"
22
+ export * from "./audit-module.js"
@@ -0,0 +1,52 @@
1
+ // The core's seam to `@digimuza/system-core/notifications` (DIG-74).
2
+ //
3
+ // Core services (roles) and package modules (finance) call `notifyIfInstalled`
4
+ // inside their mutation. When the app installed notifications
5
+ // (`defineConvexSystem({ notifications: notificationsHook() })`), the bound
6
+ // SystemContext carries a `notifier` for this transaction; otherwise the call is
7
+ // a no-op. A notification never breaks the business operation that caused it.
8
+ import type { Actor } from "./access.js"
9
+ import type { SystemContext } from "./context.js"
10
+
11
+ /** JSON value stored as notification params. */
12
+ export type NotificationParams = Record<string, unknown>
13
+
14
+ export type NotifyTarget =
15
+ /** Explicit recipients (active users only; others are skipped). */
16
+ | { userIds: readonly string[] }
17
+ /** Active users who hold the permission right now (system-core `decide`). */
18
+ | { permission: string }
19
+
20
+ export interface NotifyRequest {
21
+ to: NotifyTarget
22
+ /** A registered notification type id, e.g. `finance.invoice_awaiting_approval`. */
23
+ type: string
24
+ /** JSON params; the app renders the text from them. Never pre-rendered text. */
25
+ params: NotificationParams
26
+ /** App-relative link (`/app/...`). */
27
+ href?: string
28
+ /** Who caused it. With `to.permission`, this user (or an agent's owner) is excluded. */
29
+ actor?: Actor | "system" | null
30
+ /** Also notify the acting user when resolving `to.permission`. Default false. */
31
+ includeActor?: boolean
32
+ }
33
+
34
+ /** Bound per transaction by the Convex layer. */
35
+ export interface Notifier {
36
+ /** Inserts notification rows and schedules emails. Returns the number of recipients. */
37
+ notify(request: NotifyRequest): Promise<number>
38
+ }
39
+
40
+ /**
41
+ * Sends a notification when the app installed notifications; returns 0 otherwise.
42
+ * Errors are logged (without params) and swallowed.
43
+ */
44
+ export async function notifyIfInstalled(sys: SystemContext, request: NotifyRequest): Promise<number> {
45
+ if (!sys.notifier) return 0
46
+ try {
47
+ return await sys.notifier.notify(request)
48
+ } catch (error) {
49
+ sys.logger.error("notify_failed", { type: request.type, error })
50
+ return 0
51
+ }
52
+ }
package/src/core/roles.ts CHANGED
@@ -17,8 +17,9 @@ import { recordAudit } from "./audit.js"
17
17
  import type { SystemContext } from "./context.js"
18
18
  import { newId, randomString } from "./crypto.js"
19
19
  import { forbidden, notFound, validation } from "./errors.js"
20
+ import { notifyIfInstalled } from "./notify.js"
20
21
  import { clampLimit, pairKey, type Page, type RoleRow, type SubroleRow } from "./store.js"
21
- import { P, PROFILE_ROLE_ID, WILDCARD_SCOPE } from "./system-modules.js"
22
+ import { CORE_NOTIFICATION_TYPES, P, PROFILE_ROLE_ID, WILDCARD_SCOPE } from "./system-modules.js"
22
23
 
23
24
  export type GrantCheck = { ok: true } | { ok: false; reason: string }
24
25
 
@@ -183,6 +184,9 @@ export async function assignRoles(sys: SystemContext, actor: Actor | null, targe
183
184
  }
184
185
  if (assigned.length) {
185
186
  await recordAudit(sys, human, { action: "role.assign", targetType: "user", targetId: target.userId, changes: { addedRoles: assigned } })
187
+ // No-op unless the app installed notifications.
188
+ const roleNames = roles.filter((r) => assigned.includes(r.key)).map((r) => r.name)
189
+ await notifyIfInstalled(sys, { to: { userIds: [target.userId] }, type: CORE_NOTIFICATION_TYPES.roleAssigned, params: { roleNames }, actor: human })
186
190
  }
187
191
  return { assigned }
188
192
  }
@@ -121,6 +121,15 @@ export const SYSTEM_MODULES: readonly ModuleManifest[] = [
121
121
  p(P.assignmentDelete, "Revoke roles", "Revoke role assignments", true),
122
122
  p(P.delegationUpdate, "Edit delegation scope", "Change which roles a role may delegate", true),
123
123
  ],
124
+ notificationTypes: [
125
+ {
126
+ id: "roles.role_assigned",
127
+ moduleId: "roles",
128
+ defaultChannels: { inApp: true, email: true },
129
+ description: "You were given new roles",
130
+ params: ["roleNames"],
131
+ },
132
+ ],
124
133
  initialSubroles: [
125
134
  { id: "roles:viewer", name: "Role viewer", description: "Can view roles", permissions: [P.rolesRead] },
126
135
  {
@@ -178,6 +187,9 @@ export const SYSTEM_MODULES: readonly ModuleManifest[] = [
178
187
  },
179
188
  ]
180
189
 
190
+ /** Notification types sent by the core itself. */
191
+ export const CORE_NOTIFICATION_TYPES = { roleAssigned: "roles.role_assigned" } as const
192
+
181
193
  export const ADMIN_ROLE_ID = "system:admin"
182
194
  export const PROFILE_ROLE_ID = "system:profile"
183
195
  export const WILDCARD_SCOPE = "*"
@@ -0,0 +1,68 @@
1
+ // AI tools of the finance module (DIG-71). Give them to an agent:
2
+ //
3
+ // defineAiAgent({ id: "assistant", ..., tools: financeTools })
4
+ //
5
+ // Each tool runs through `executeAiTool`: arguments are validated, the actor is
6
+ // re-authorized for the tool's permission, and the call is audited. The business
7
+ // rules live in the shared invoice operations of `./index.ts`.
8
+ //
9
+ // Note: `./index.ts` re-exports this file, so only function declarations from it
10
+ // may be used here at module top level (no consts: circular import).
11
+ import { z } from "zod"
12
+ import { defineAiTool } from "../ai/tools.js"
13
+ import { notFound } from "../core/errors.js"
14
+ import { approveInvoiceRecord, createInvoiceRecord, findInvoiceByNumber, listInvoiceViews } from "./index.js"
15
+
16
+ const invoiceNumber = z.string().trim().min(1).max(64).describe("Invoice number, e.g. A-2025-001")
17
+
18
+ export const financeListInvoicesTool = defineAiTool({
19
+ id: "finance.list_invoices",
20
+ description: "Lists invoices, newest first. Optionally filter by status (draft or approved). Amounts are in major units (e.g. 12.50 EUR).",
21
+ permission: "finance:invoice:read",
22
+ input: z.object({
23
+ status: z.enum(["draft", "approved"]).optional().describe("Only invoices with this status"),
24
+ limit: z.number().int().min(1).max(50).optional().describe("How many invoices to return (1-50, default 20)"),
25
+ }),
26
+ handler: async ({ db }, input) => {
27
+ const rows = await listInvoiceViews(db, { status: input.status, limit: input.limit ?? 20 })
28
+ return rows.map((r) => ({ number: r.number, customer: r.customer, amount: r.amountCents / 100, currency: r.currency, status: r.status }))
29
+ },
30
+ })
31
+
32
+ export const financeCreateInvoiceTool = defineAiTool({
33
+ id: "finance.create_invoice",
34
+ description: "Creates a draft invoice in EUR. Invoice numbers are unique. The user approves this call before it runs.",
35
+ permission: "finance:invoice:create",
36
+ requiresApproval: true,
37
+ input: z.object({
38
+ number: invoiceNumber,
39
+ customer: z.string().trim().min(1).max(200).describe("Customer name"),
40
+ amountEur: z.number().positive().max(1_000_000_000).describe("Amount in EUR, e.g. 12.50"),
41
+ }),
42
+ handler: async ({ db, sys, actor }, input) => {
43
+ const { invoiceId, number } = await createInvoiceRecord(db, sys, actor, {
44
+ number: input.number,
45
+ customer: input.customer,
46
+ amountCents: Math.round(input.amountEur * 100),
47
+ currency: "EUR",
48
+ })
49
+ return { invoiceId: invoiceId as string, number }
50
+ },
51
+ })
52
+
53
+ export const financeApproveInvoiceTool = defineAiTool({
54
+ id: "finance.approve_invoice",
55
+ description: "Approves a draft invoice by its number. Only drafts can be approved. The user approves this call before it runs.",
56
+ permission: "finance:invoice:approve",
57
+ requiresApproval: true,
58
+ input: z.object({ number: invoiceNumber }),
59
+ handler: async ({ db, sys, actor }, input) => {
60
+ const invoice = await findInvoiceByNumber(db, input.number)
61
+ if (!invoice) throw notFound("invoice_not_found")
62
+ const res = await approveInvoiceRecord(db, sys, actor, invoice.id, { via: "ai" })
63
+ return { number: res.number, status: res.status }
64
+ },
65
+ })
66
+
67
+ /** All finance tools, for an agent's `tools`. */
68
+ export const financeTools = [financeListInvoicesTool, financeCreateInvoiceTool, financeApproveInvoiceTool] as const
@@ -13,10 +13,23 @@
13
13
  import { defineTable, mutationGeneric, queryGeneric } from "convex/server"
14
14
  import type { GenericId } from "convex/values"
15
15
  import { v } from "convex/values"
16
- import { isFeatureEnabled, recordAudit, SystemError, type Actor } from "../core/index.js"
16
+ import { defineNotificationType, isFeatureEnabled, notifyIfInstalled, recordAudit, SystemError, type Actor, type SystemContext } from "../core/index.js"
17
17
  import { toConvexError, type ConvexSystem } from "../convex/functions.js"
18
18
  import { defineModule, type PermissionOf } from "../modules/index.js"
19
19
 
20
+ /**
21
+ * Sent to holders of `finance:invoice:approve` (not the creator) when a draft is
22
+ * created. Params `{ number, customer, amountCents, currency }`; href
23
+ * `/app/finance?invoice=<number>`. Only when the app installed notifications.
24
+ */
25
+ export const financeInvoiceAwaitingApproval = defineNotificationType({
26
+ id: "finance.invoice_awaiting_approval",
27
+ moduleId: "finance",
28
+ defaultChannels: { inApp: true, email: true },
29
+ description: "Nauja sąskaita laukia patvirtinimo",
30
+ params: ["number", "customer", "amountCents", "currency"],
31
+ })
32
+
20
33
  export const finance = defineModule({
21
34
  id: "finance",
22
35
  kind: "business",
@@ -36,6 +49,7 @@ export const finance = defineModule({
36
49
  },
37
50
  ],
38
51
  featureFlags: [{ id: "finance:bulk-approve", description: "Tvirtinti kelias sąskaitas vienu veiksmu", default: false }],
52
+ notificationTypes: [financeInvoiceAwaitingApproval],
39
53
  })
40
54
 
41
55
  export const FinancePermissions = {
@@ -60,9 +74,14 @@ export const financeTables = {
60
74
  approvedAt: v.optional(v.number()),
61
75
  })
62
76
  .index("by_number", ["number"])
63
- .index("by_status", ["status"]),
77
+ .index("by_status", ["status"])
78
+ // Global search (`financeSearch`): full text over the customer name.
79
+ .searchIndex("search_customer", { searchField: "customer" }),
64
80
  }
65
81
 
82
+ /** App link to one invoice (search hits, notifications). */
83
+ export const invoiceHref = (number: string) => `/app/finance?invoice=${encodeURIComponent(number)}`
84
+
66
85
  export type InvoiceStatus = "draft" | "approved"
67
86
 
68
87
  /** Convex ID of an invoice in the app's `invoices` table. */
@@ -79,7 +98,7 @@ export interface InvoiceView {
79
98
  approvedAt: number | null
80
99
  }
81
100
 
82
- interface InvoiceDoc {
101
+ export interface InvoiceDoc {
83
102
  _id: InvoiceId
84
103
  _creationTime: number
85
104
  number: string
@@ -106,29 +125,94 @@ const actorArgs = { apiKey: v.optional(v.string()), operationId: v.optional(v.st
106
125
  const opts = (a: { apiKey?: string | undefined; operationId?: string | undefined }) => ({ apiKey: a.apiKey, operationId: a.operationId })
107
126
 
108
127
  function fail(code: "NOT_FOUND" | "CONFLICT" | "VALIDATION" | "FORBIDDEN", reason: string, operationId: string): never {
109
- throw toConvexError(new SystemError(code, undefined, { reason, operationId }), operationId)
128
+ throw new SystemError(code, undefined, { reason, operationId })
129
+ }
130
+
131
+ // --- Invoice operations ---------------------------------------------------------------
132
+ // Shared by the Convex functions below and the AI tools (`./ai-tools.ts`). They hold
133
+ // the business rules and the audit; the caller has already checked the permission.
134
+ // They throw SystemError (with the operation ID); Convex callers translate it.
135
+
136
+ /** A loosely typed db handle: the app's schema contains `financeTables`. */
137
+ export interface FinanceDb {
138
+ query(t: "invoices"): {
139
+ withIndex(i: string, f: (q: { eq(field: string, value: unknown): unknown }) => unknown): { order(o: "asc" | "desc"): { take(n: number): Promise<InvoiceDoc[]> }; unique(): Promise<InvoiceDoc | null> }
140
+ order(o: "asc" | "desc"): { take(n: number): Promise<InvoiceDoc[]> }
141
+ }
142
+ get(t: "invoices", id: string): Promise<InvoiceDoc | null>
143
+ insert(t: "invoices", doc: Record<string, unknown>): Promise<InvoiceId>
144
+ patch(t: "invoices", id: string, doc: Record<string, unknown>): Promise<void>
145
+ }
146
+
147
+ /** Newest first, optionally by status. */
148
+ export async function listInvoiceViews(db: unknown, filter: { status?: InvoiceStatus | undefined; limit: number }): Promise<InvoiceView[]> {
149
+ const q = (db as FinanceDb).query("invoices")
150
+ const rows = filter.status
151
+ ? await q.withIndex("by_status", (i) => i.eq("status", filter.status)).order("desc").take(filter.limit)
152
+ : await q.order("desc").take(filter.limit)
153
+ return rows.map(view)
154
+ }
155
+
156
+ export async function findInvoiceByNumber(db: unknown, number: string): Promise<InvoiceView | null> {
157
+ const row = await (db as FinanceDb).query("invoices").withIndex("by_number", (i) => i.eq("number", number.trim())).unique()
158
+ return row ? view(row) : null
159
+ }
160
+
161
+ /** Validates, enforces unique numbers (CONFLICT), inserts a draft and audits `finance.invoice.create`. */
162
+ export async function createInvoiceRecord(
163
+ db: unknown,
164
+ sys: SystemContext,
165
+ actor: Actor,
166
+ input: { number: string; customer: string; amountCents: number; currency?: string | undefined },
167
+ ): Promise<{ invoiceId: InvoiceId; number: string }> {
168
+ const number = input.number.trim()
169
+ const customer = input.customer.trim()
170
+ if (!number || !customer || !Number.isInteger(input.amountCents) || input.amountCents <= 0) fail("VALIDATION", "invalid_invoice", sys.operationId)
171
+ const currency = (input.currency ?? "EUR").toUpperCase()
172
+ if (!/^[A-Z]{3}$/.test(currency)) fail("VALIDATION", "invalid_currency", sys.operationId)
173
+ const d = db as FinanceDb
174
+ const existing = await d.query("invoices").withIndex("by_number", (i) => i.eq("number", number)).unique()
175
+ if (existing) fail("CONFLICT", "invoice_number_taken", sys.operationId)
176
+ const invoiceId = await d.insert("invoices", { number, customer, amountCents: input.amountCents, currency, status: "draft", createdBy: actorId(actor) })
177
+ await recordAudit(sys, actor, { action: "finance.invoice.create", targetType: "invoice", targetId: invoiceId, changes: { number } })
178
+ // The new draft awaits approval. No-op unless the app installed notifications.
179
+ await notifyIfInstalled(sys, {
180
+ to: { permission: "finance:invoice:approve" },
181
+ type: "finance.invoice_awaiting_approval",
182
+ params: { number, customer, amountCents: input.amountCents, currency },
183
+ href: invoiceHref(number),
184
+ actor,
185
+ })
186
+ return { invoiceId, number }
187
+ }
188
+
189
+ /** Approves a draft (NOT_FOUND if missing, CONFLICT unless draft) and audits `finance.invoice.approve`. */
190
+ export async function approveInvoiceRecord(
191
+ db: unknown,
192
+ sys: SystemContext,
193
+ actor: Actor,
194
+ invoiceId: string,
195
+ changes?: Record<string, unknown>,
196
+ ): Promise<{ invoiceId: InvoiceId; number: string; status: InvoiceStatus }> {
197
+ const d = db as FinanceDb
198
+ const invoice = await d.get("invoices", invoiceId)
199
+ // A missing invoice and one the caller may not see look the same.
200
+ if (!invoice) fail("NOT_FOUND", "invoice_not_found", sys.operationId)
201
+ if (invoice.status !== "draft") fail("CONFLICT", "invoice_not_draft", sys.operationId)
202
+ await d.patch("invoices", invoiceId, { status: "approved", approvedBy: actorId(actor), approvedAt: sys.now() })
203
+ await recordAudit(sys, actor, { action: "finance.invoice.approve", targetType: "invoice", targetId: invoiceId, ...(changes ? { changes } : {}) })
204
+ return { invoiceId: invoice._id, number: invoice.number, status: "approved" }
110
205
  }
111
206
 
112
207
  /** The module's Convex functions, bound to the app's system. Re-export them from `convex/finance.ts`. */
113
208
  export function defineFinance(system: ConvexSystem) {
114
- // A loosely typed db handle: the app's schema contains `financeTables`.
115
- type Db = {
116
- query(t: "invoices"): {
117
- withIndex(i: string, f: (q: { eq(field: string, value: unknown): unknown }) => unknown): { order(o: "asc" | "desc"): { take(n: number): Promise<InvoiceDoc[]> }; unique(): Promise<InvoiceDoc | null> }
118
- order(o: "asc" | "desc"): { take(n: number): Promise<InvoiceDoc[]> }
209
+ /** Runs an invoice operation, translating SystemError to ConvexError. */
210
+ async function guarded<R>(operationId: string, fn: () => Promise<R>): Promise<R> {
211
+ try {
212
+ return await fn()
213
+ } catch (e) {
214
+ throw toConvexError(e, operationId)
119
215
  }
120
- get(t: "invoices", id: string): Promise<InvoiceDoc | null>
121
- insert(t: "invoices", doc: Record<string, unknown>): Promise<InvoiceId>
122
- patch(t: "invoices", id: string, doc: Record<string, unknown>): Promise<void>
123
- }
124
- const db = (ctx: { db: unknown }) => ctx.db as Db
125
-
126
- async function approveOne(ctx: { db: unknown }, operationId: string, invoiceId: string, by: string) {
127
- const invoice = await db(ctx).get("invoices", invoiceId)
128
- // A missing invoice and one the caller may not see look the same.
129
- if (!invoice) fail("NOT_FOUND", "invoice_not_found", operationId)
130
- if (invoice!.status !== "draft") fail("CONFLICT", "invoice_not_draft", operationId)
131
- await db(ctx).patch("invoices", invoiceId, { status: "approved", approvedBy: by, approvedAt: Date.now() })
132
216
  }
133
217
 
134
218
  return {
@@ -136,9 +220,7 @@ export function defineFinance(system: ConvexSystem) {
136
220
  args: { ...actorArgs, status: v.optional(v.union(v.literal("draft"), v.literal("approved"))) },
137
221
  handler: async (ctx, a): Promise<InvoiceView[]> => {
138
222
  await system.requirePermission(ctx, FinancePermissions.read, opts(a))
139
- const q = db(ctx).query("invoices")
140
- const rows = a.status ? await q.withIndex("by_status", (i) => i.eq("status", a.status)).order("desc").take(100) : await q.order("desc").take(100)
141
- return rows.map(view)
223
+ return listInvoiceViews(ctx.db, { status: a.status, limit: 100 })
142
224
  },
143
225
  }),
144
226
 
@@ -146,15 +228,9 @@ export function defineFinance(system: ConvexSystem) {
146
228
  args: { ...actorArgs, number: v.string(), customer: v.string(), amountCents: v.number(), currency: v.optional(v.string()) },
147
229
  handler: async (ctx, a): Promise<{ invoiceId: InvoiceId; operationId: string }> => {
148
230
  const { sys, actor } = await system.requirePermission(ctx, FinancePermissions.create, opts(a))
149
- const number = a.number.trim()
150
- const customer = a.customer.trim()
151
- if (!number || !customer || !Number.isInteger(a.amountCents) || a.amountCents <= 0) fail("VALIDATION", "invalid_invoice", sys.operationId)
152
- const currency = (a.currency ?? "EUR").toUpperCase()
153
- if (!/^[A-Z]{3}$/.test(currency)) fail("VALIDATION", "invalid_currency", sys.operationId)
154
- const existing = await db(ctx).query("invoices").withIndex("by_number", (i) => i.eq("number", number)).unique()
155
- if (existing) fail("CONFLICT", "invoice_number_taken", sys.operationId)
156
- const invoiceId = await db(ctx).insert("invoices", { number, customer, amountCents: a.amountCents, currency, status: "draft", createdBy: actorId(actor) })
157
- await recordAudit(sys, actor, { action: "finance.invoice.create", targetType: "invoice", targetId: invoiceId, changes: { number } })
231
+ const { invoiceId } = await guarded(sys.operationId, () =>
232
+ createInvoiceRecord(ctx.db, sys, actor, { number: a.number, customer: a.customer, amountCents: a.amountCents, currency: a.currency }),
233
+ )
158
234
  return { invoiceId, operationId: sys.operationId }
159
235
  },
160
236
  }),
@@ -163,8 +239,7 @@ export function defineFinance(system: ConvexSystem) {
163
239
  args: { ...actorArgs, invoiceId: v.id("invoices") },
164
240
  handler: async (ctx, a): Promise<{ operationId: string }> => {
165
241
  const { sys, actor } = await system.requirePermission(ctx, FinancePermissions.approve, opts(a))
166
- await approveOne(ctx, sys.operationId, a.invoiceId, actorId(actor))
167
- await recordAudit(sys, actor, { action: "finance.invoice.approve", targetType: "invoice", targetId: a.invoiceId })
242
+ await guarded(sys.operationId, () => approveInvoiceRecord(ctx.db, sys, actor, a.invoiceId))
168
243
  return { operationId: sys.operationId }
169
244
  },
170
245
  }),
@@ -174,16 +249,18 @@ export function defineFinance(system: ConvexSystem) {
174
249
  args: { ...actorArgs, invoiceIds: v.array(v.id("invoices")) },
175
250
  handler: async (ctx, a): Promise<{ approved: number; operationId: string }> => {
176
251
  const { sys, actor } = await system.requirePermission(ctx, FinancePermissions.approve, opts(a))
177
- if (!(await isFeatureEnabled(sys, FINANCE_BULK_APPROVE_FLAG))) fail("FORBIDDEN", "flag_off", sys.operationId)
178
- if (a.invoiceIds.length > FINANCE_BULK_LIMIT) fail("VALIDATION", "too_many", sys.operationId)
179
- for (const id of a.invoiceIds) {
180
- await approveOne(ctx, sys.operationId, id, actorId(actor))
181
- await recordAudit(sys, actor, { action: "finance.invoice.approve", targetType: "invoice", targetId: id, changes: { bulk: true } })
182
- }
183
- return { approved: a.invoiceIds.length, operationId: sys.operationId }
252
+ return guarded(sys.operationId, async () => {
253
+ if (!(await isFeatureEnabled(sys, FINANCE_BULK_APPROVE_FLAG))) fail("FORBIDDEN", "flag_off", sys.operationId)
254
+ if (a.invoiceIds.length > FINANCE_BULK_LIMIT) fail("VALIDATION", "too_many", sys.operationId)
255
+ for (const id of a.invoiceIds) await approveInvoiceRecord(ctx.db, sys, actor, id, { bulk: true })
256
+ return { approved: a.invoiceIds.length, operationId: sys.operationId }
257
+ })
184
258
  },
185
259
  }),
186
260
  }
187
261
  }
188
262
 
189
263
  export type FinanceFunctions = ReturnType<typeof defineFinance>
264
+
265
+ export { financeTools, financeListInvoicesTool, financeCreateInvoiceTool, financeApproveInvoiceTool } from "./ai-tools.js"
266
+ export { financeSearch, searchInvoices } from "./search.js"
@@ -0,0 +1,58 @@
1
+ // Global search provider of the finance module (DIG-72). Install it with
2
+ //
3
+ // export const { search } = defineSearch(system, { providers: [financeSearch] }).functions
4
+ //
5
+ // Finds invoices by number (prefix, via the `by_number` index) and by customer
6
+ // (full text, via the `search_customer` search index). Read-only and bounded.
7
+ //
8
+ // Note: `./index.ts` re-exports this file, so only function declarations from it
9
+ // may be used here at module top level (no consts: circular import).
10
+ import { defineSearchProvider, type SearchHit } from "../search/index.js"
11
+ import { invoiceHref, type InvoiceDoc } from "./index.js"
12
+
13
+ type IndexQ = { eq(f: string, v: unknown): IndexQ; gte(f: string, v: unknown): IndexQ; lt(f: string, v: unknown): IndexQ }
14
+ type SearchQ = { search(f: string, q: string): SearchQ }
15
+ interface InvoiceSearchDb {
16
+ query(t: "invoices"): {
17
+ withIndex(i: string, f: (q: IndexQ) => unknown): { take(n: number): Promise<InvoiceDoc[]> }
18
+ withSearchIndex(i: string, f: (q: SearchQ) => unknown): { take(n: number): Promise<InvoiceDoc[]> }
19
+ }
20
+ }
21
+
22
+ /** Invoices whose number starts with the query (as typed, then upper-cased), then customers matching it. */
23
+ export async function searchInvoices(db: unknown, query: string, limit: number): Promise<InvoiceDoc[]> {
24
+ const d = db as InvoiceSearchDb
25
+ const out = new Map<string, InvoiceDoc>()
26
+ for (const prefix of new Set([query, query.toUpperCase()])) {
27
+ if (out.size >= limit) break
28
+ const rows = await d
29
+ .query("invoices")
30
+ .withIndex("by_number", (q) => q.gte("number", prefix).lt("number", `${prefix}￿`))
31
+ .take(limit)
32
+ for (const r of rows) out.set(r._id, r)
33
+ }
34
+ if (out.size < limit) {
35
+ const rows = await d
36
+ .query("invoices")
37
+ .withSearchIndex("search_customer", (q) => q.search("customer", query))
38
+ .take(limit)
39
+ for (const r of rows) if (!out.has(r._id)) out.set(r._id, r)
40
+ }
41
+ return [...out.values()].slice(0, limit)
42
+ }
43
+
44
+ export const financeSearch = defineSearchProvider({
45
+ id: "finance.invoices",
46
+ moduleId: "finance",
47
+ label: "Sąskaitos",
48
+ permission: "finance:invoice:read",
49
+ search: async ({ db }, { query, limit }): Promise<SearchHit[]> =>
50
+ (await searchInvoices(db, query, limit)).map((i) => ({
51
+ providerId: "finance.invoices",
52
+ id: i._id,
53
+ title: i.number,
54
+ subtitle: i.customer,
55
+ href: invoiceHref(i.number),
56
+ kind: "invoice",
57
+ })),
58
+ })
package/src/index.ts CHANGED
@@ -17,3 +17,5 @@ export type { SessionSummary, ModuleState } from "./core/modules-service.js"
17
17
  export type { PublicApiKey } from "./core/api-keys.js"
18
18
  export type { PublicInvite } from "./core/invites.js"
19
19
  export type { PublicUser, Profile } from "./core/users.js"
20
+ export { auditToCsv, csvCell, AUDIT_CSV_COLUMNS, type AuditCsvEvent, type AuditCsvOptions } from "./audit/csv.js"
21
+ export { auditModuleOf } from "./core/audit-module.js"
@@ -11,6 +11,9 @@ export {
11
11
  type PermissionDefinition,
12
12
  type SubroleTemplate,
13
13
  type FeatureFlagDefinition,
14
+ defineNotificationType,
15
+ NOTIFICATION_TYPE_ID,
16
+ type NotificationTypeDefinition,
14
17
  type PermissionOf,
15
18
  type Catalog,
16
19
  } from "../core/catalog.js"