@digimuza/system-core 0.2.0 → 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.
- package/INTEGRATION.md +209 -3
- package/README.md +23 -0
- package/dist/ai/api.d.ts +120 -0
- package/dist/ai/api.d.ts.map +1 -0
- package/dist/ai/api.js +446 -0
- package/dist/ai/api.js.map +1 -0
- package/dist/ai/card.d.ts +27 -0
- package/dist/ai/card.d.ts.map +1 -0
- package/dist/ai/card.js +84 -0
- package/dist/ai/card.js.map +1 -0
- package/dist/ai/contract.d.ts +364 -0
- package/dist/ai/contract.d.ts.map +1 -0
- package/dist/ai/contract.js +101 -0
- package/dist/ai/contract.js.map +1 -0
- package/dist/ai/display.d.ts +20 -0
- package/dist/ai/display.d.ts.map +1 -0
- package/dist/ai/display.js +35 -0
- package/dist/ai/display.js.map +1 -0
- package/dist/ai/index.d.ts +7 -0
- package/dist/ai/index.d.ts.map +1 -0
- package/dist/ai/index.js +10 -0
- package/dist/ai/index.js.map +1 -0
- package/dist/ai/manifest.d.ts +25 -0
- package/dist/ai/manifest.d.ts.map +1 -0
- package/dist/ai/manifest.js +15 -0
- package/dist/ai/manifest.js.map +1 -0
- package/dist/ai/runner.d.ts +71 -0
- package/dist/ai/runner.d.ts.map +1 -0
- package/dist/ai/runner.js +246 -0
- package/dist/ai/runner.js.map +1 -0
- package/dist/ai/tools.d.ts +42 -0
- package/dist/ai/tools.d.ts.map +1 -0
- package/dist/ai/tools.js +117 -0
- package/dist/ai/tools.js.map +1 -0
- package/dist/audit/csv.d.ts +31 -0
- package/dist/audit/csv.d.ts.map +1 -0
- package/dist/audit/csv.js +39 -0
- package/dist/audit/csv.js.map +1 -0
- package/dist/audit/index.d.ts +5 -0
- package/dist/audit/index.d.ts.map +1 -0
- package/dist/audit/index.js +6 -0
- package/dist/audit/index.js.map +1 -0
- package/dist/convex/functions.d.ts +39 -0
- package/dist/convex/functions.d.ts.map +1 -1
- package/dist/convex/functions.js +38 -6
- package/dist/convex/functions.js.map +1 -1
- package/dist/convex/index.d.ts +1 -1
- package/dist/convex/index.d.ts.map +1 -1
- package/dist/convex/index.js.map +1 -1
- package/dist/core/access.d.ts +14 -0
- package/dist/core/access.d.ts.map +1 -1
- package/dist/core/access.js +62 -0
- package/dist/core/access.js.map +1 -1
- package/dist/core/audit-module.d.ts +5 -0
- package/dist/core/audit-module.d.ts.map +1 -0
- package/dist/core/audit-module.js +25 -0
- package/dist/core/audit-module.js.map +1 -0
- package/dist/core/audit.d.ts +31 -0
- package/dist/core/audit.d.ts.map +1 -1
- package/dist/core/audit.js +46 -2
- package/dist/core/audit.js.map +1 -1
- package/dist/core/catalog.d.ts +41 -0
- package/dist/core/catalog.d.ts.map +1 -1
- package/dist/core/catalog.js +45 -0
- package/dist/core/catalog.js.map +1 -1
- package/dist/core/context.d.ts +8 -2
- package/dist/core/context.d.ts.map +1 -1
- package/dist/core/context.js +4 -1
- package/dist/core/context.js.map +1 -1
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +2 -0
- package/dist/core/index.js.map +1 -1
- package/dist/core/notify.d.ts +37 -0
- package/dist/core/notify.d.ts.map +1 -0
- package/dist/core/notify.js +16 -0
- package/dist/core/notify.js.map +1 -0
- package/dist/core/roles.d.ts.map +1 -1
- package/dist/core/roles.js +5 -1
- package/dist/core/roles.js.map +1 -1
- package/dist/core/system-modules.d.ts +4 -0
- package/dist/core/system-modules.d.ts.map +1 -1
- package/dist/core/system-modules.js +11 -0
- package/dist/core/system-modules.js.map +1 -1
- package/dist/finance/ai-tools.d.ts +55 -0
- package/dist/finance/ai-tools.d.ts.map +1 -0
- package/dist/finance/ai-tools.js +65 -0
- package/dist/finance/ai-tools.js.map +1 -0
- package/dist/finance/index.d.ts +87 -1
- package/dist/finance/index.d.ts.map +1 -1
- package/dist/finance/index.js +92 -38
- package/dist/finance/index.js.map +1 -1
- package/dist/finance/search.d.ts +15 -0
- package/dist/finance/search.d.ts.map +1 -0
- package/dist/finance/search.js +51 -0
- package/dist/finance/search.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/modules/index.d.ts +1 -1
- package/dist/modules/index.d.ts.map +1 -1
- package/dist/modules/index.js +1 -1
- package/dist/modules/index.js.map +1 -1
- package/dist/notifications/api.d.ts +130 -0
- package/dist/notifications/api.d.ts.map +1 -0
- package/dist/notifications/api.js +384 -0
- package/dist/notifications/api.js.map +1 -0
- package/dist/notifications/contract.d.ts +132 -0
- package/dist/notifications/contract.d.ts.map +1 -0
- package/dist/notifications/contract.js +59 -0
- package/dist/notifications/contract.js.map +1 -0
- package/dist/notifications/index.d.ts +6 -0
- package/dist/notifications/index.d.ts.map +1 -0
- package/dist/notifications/index.js +9 -0
- package/dist/notifications/index.js.map +1 -0
- package/dist/search/index.d.ts +95 -0
- package/dist/search/index.d.ts.map +1 -0
- package/dist/search/index.js +151 -0
- package/dist/search/index.js.map +1 -0
- package/package.json +41 -2
- package/src/ai/api.ts +535 -0
- package/src/ai/card.ts +91 -0
- package/src/ai/contract.ts +245 -0
- package/src/ai/display.ts +38 -0
- package/src/ai/index.ts +9 -0
- package/src/ai/manifest.ts +16 -0
- package/src/ai/runner.ts +275 -0
- package/src/ai/tools.ts +128 -0
- package/src/audit/csv.ts +60 -0
- package/src/audit/index.ts +7 -0
- package/src/convex/functions.ts +54 -7
- package/src/convex/index.ts +1 -1
- package/src/core/access.ts +55 -1
- package/src/core/audit-module.ts +26 -0
- package/src/core/audit.ts +68 -2
- package/src/core/catalog.ts +63 -0
- package/src/core/context.ts +13 -2
- package/src/core/index.ts +2 -0
- package/src/core/notify.ts +52 -0
- package/src/core/roles.ts +5 -1
- package/src/core/system-modules.ts +12 -0
- package/src/finance/ai-tools.ts +68 -0
- package/src/finance/index.ts +119 -42
- package/src/finance/search.ts +58 -0
- package/src/index.ts +2 -0
- package/src/modules/index.ts +3 -0
- package/src/notifications/api.ts +477 -0
- package/src/notifications/contract.ts +142 -0
- package/src/notifications/index.ts +8 -0
- package/src/search/index.ts +198 -0
package/src/core/context.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
@@ -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
|
package/src/finance/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
await
|
|
181
|
-
|
|
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"
|
package/src/modules/index.ts
CHANGED