@mgsoftwarebv/mcp-server-bridge 3.5.6 → 3.5.7
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/dist/index.js +1324 -564
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -106677,7 +106677,7 @@ var TOOLS = [
|
|
|
106677
106677
|
},
|
|
106678
106678
|
{
|
|
106679
106679
|
name: "get-invoices",
|
|
106680
|
-
description: "List invoices with optional filtering by customer, status, or a search on invoice number / customer name. Use this to
|
|
106680
|
+
description: "List invoices with optional filtering by customer, status, or a search on invoice number / customer name. Use `get-invoice-by-id` for full detail including line items, product snapshots and linked documents. Use this listing to find a (draft) invoice before linking a deliverables document with `invoiceId` on create-document or link-document-to-invoice.",
|
|
106681
106681
|
inputSchema: {
|
|
106682
106682
|
type: "object",
|
|
106683
106683
|
properties: {
|
|
@@ -106722,6 +106722,136 @@ var TOOLS = [
|
|
|
106722
106722
|
required: ["documentId", "invoiceId"]
|
|
106723
106723
|
}
|
|
106724
106724
|
},
|
|
106725
|
+
{
|
|
106726
|
+
name: "get-invoice-by-id",
|
|
106727
|
+
description: "Get a single invoice by UUID or invoice number (e.g. 260060). Returns status, customer, dates, currency, totals, all line items (compact description + extended product/variant/clause context) and linked documents. Use before editing draft line descriptions with update-invoice-lines.",
|
|
106728
|
+
inputSchema: {
|
|
106729
|
+
type: "object",
|
|
106730
|
+
properties: {
|
|
106731
|
+
teamId: teamIdProp,
|
|
106732
|
+
invoiceId: {
|
|
106733
|
+
type: "string",
|
|
106734
|
+
description: "Invoice UUID or invoice number"
|
|
106735
|
+
}
|
|
106736
|
+
},
|
|
106737
|
+
required: ["invoiceId"]
|
|
106738
|
+
}
|
|
106739
|
+
},
|
|
106740
|
+
{
|
|
106741
|
+
name: "update-invoice",
|
|
106742
|
+
description: "Update a DRAFT invoice. Only invoices still in status `draft` can be changed \u2014 sent/paid/unpaid/overdue invoices are immutable here. Editable fields: title (template.title), note, internalNote, dueDate, issueDate. Provide `lineItems` to REPLACE all items (totals recomputed; productId items are re-snapshotted with clause + pricing variant).",
|
|
106743
|
+
inputSchema: {
|
|
106744
|
+
type: "object",
|
|
106745
|
+
properties: {
|
|
106746
|
+
teamId: teamIdProp,
|
|
106747
|
+
invoiceId: { type: "string", description: "Invoice UUID or number" },
|
|
106748
|
+
title: { type: "string", description: "Overrides template.title" },
|
|
106749
|
+
note: {
|
|
106750
|
+
type: ["string", "null"],
|
|
106751
|
+
description: "Customer-facing note; null clears it."
|
|
106752
|
+
},
|
|
106753
|
+
internalNote: { type: ["string", "null"] },
|
|
106754
|
+
dueDate: { type: ["string", "null"], description: "ISO date" },
|
|
106755
|
+
issueDate: { type: ["string", "null"], description: "ISO date" },
|
|
106756
|
+
lineItems: {
|
|
106757
|
+
type: "array",
|
|
106758
|
+
description: "Replaces ALL line items. Each: { name?, quantity?, unit?, price?, productId?, pricingOptionId?, prorate?, startDate?, periodEndDate? }.",
|
|
106759
|
+
items: {
|
|
106760
|
+
type: "object",
|
|
106761
|
+
properties: {
|
|
106762
|
+
name: { type: "string", description: "Compact line description" },
|
|
106763
|
+
quantity: { type: "number" },
|
|
106764
|
+
unit: { type: "string" },
|
|
106765
|
+
price: { type: "number", description: "Unit price excl. VAT" },
|
|
106766
|
+
productId: { type: "string" },
|
|
106767
|
+
pricingOptionId: { type: "string" },
|
|
106768
|
+
prorate: {
|
|
106769
|
+
type: "boolean",
|
|
106770
|
+
description: "Pro-rate yearly variant from startDate"
|
|
106771
|
+
},
|
|
106772
|
+
startDate: { type: "string", description: "ISO date for pro-rata" },
|
|
106773
|
+
periodEndDate: { type: "string" }
|
|
106774
|
+
}
|
|
106775
|
+
}
|
|
106776
|
+
}
|
|
106777
|
+
},
|
|
106778
|
+
required: ["invoiceId"]
|
|
106779
|
+
}
|
|
106780
|
+
},
|
|
106781
|
+
{
|
|
106782
|
+
name: "update-invoice-lines",
|
|
106783
|
+
description: "Update specific line items on a DRAFT invoice without replacing the whole list. Address lines by zero-based `index`. Only provided fields change; clause/pricingOption/productId snapshots are preserved. Ideal for compacting factuurregel descriptions. Recomputes invoice totals.",
|
|
106784
|
+
inputSchema: {
|
|
106785
|
+
type: "object",
|
|
106786
|
+
properties: {
|
|
106787
|
+
teamId: teamIdProp,
|
|
106788
|
+
invoiceId: { type: "string", description: "Invoice UUID or number" },
|
|
106789
|
+
lineItems: {
|
|
106790
|
+
type: "array",
|
|
106791
|
+
description: "Patches to apply. Each: { index, description?, quantity?, unit?, price? }.",
|
|
106792
|
+
items: {
|
|
106793
|
+
type: "object",
|
|
106794
|
+
properties: {
|
|
106795
|
+
index: {
|
|
106796
|
+
type: "number",
|
|
106797
|
+
description: "Zero-based line index"
|
|
106798
|
+
},
|
|
106799
|
+
description: {
|
|
106800
|
+
type: "string",
|
|
106801
|
+
description: "Compact factuurregel text (plain string)"
|
|
106802
|
+
},
|
|
106803
|
+
quantity: { type: "number" },
|
|
106804
|
+
unit: { type: "string" },
|
|
106805
|
+
price: { type: "number" }
|
|
106806
|
+
},
|
|
106807
|
+
required: ["index"]
|
|
106808
|
+
}
|
|
106809
|
+
}
|
|
106810
|
+
},
|
|
106811
|
+
required: ["invoiceId", "lineItems"]
|
|
106812
|
+
}
|
|
106813
|
+
},
|
|
106814
|
+
{
|
|
106815
|
+
name: "add-product-to-invoice",
|
|
106816
|
+
description: "Add a catalog product as a new line item on a DRAFT invoice. Snapshots clause + chosen pricing variant onto the line. Only works on `draft` invoices. Supports optional pro-rata billing for yearly variants (prorate + startDate). Recomputes totals.",
|
|
106817
|
+
inputSchema: {
|
|
106818
|
+
type: "object",
|
|
106819
|
+
properties: {
|
|
106820
|
+
teamId: teamIdProp,
|
|
106821
|
+
invoiceId: { type: "string", description: "Invoice UUID or number" },
|
|
106822
|
+
productId: {
|
|
106823
|
+
type: "string",
|
|
106824
|
+
description: "Catalog product ID (see get-products)"
|
|
106825
|
+
},
|
|
106826
|
+
quantity: { type: "number", default: 1 },
|
|
106827
|
+
customDescription: {
|
|
106828
|
+
type: "string",
|
|
106829
|
+
description: "Overrides the compact line description."
|
|
106830
|
+
},
|
|
106831
|
+
customPrice: {
|
|
106832
|
+
type: "number",
|
|
106833
|
+
description: "Overrides the unit price (excl. VAT)."
|
|
106834
|
+
},
|
|
106835
|
+
pricingOptionId: {
|
|
106836
|
+
type: "string",
|
|
106837
|
+
description: "Pricing variant id (monthly/yearly); defaults to product default."
|
|
106838
|
+
},
|
|
106839
|
+
prorate: {
|
|
106840
|
+
type: "boolean",
|
|
106841
|
+
description: "When true and a yearly variant is selected, price is pro-rated from startDate."
|
|
106842
|
+
},
|
|
106843
|
+
startDate: {
|
|
106844
|
+
type: "string",
|
|
106845
|
+
description: "ISO start date for pro-rata (required when prorate is true)."
|
|
106846
|
+
},
|
|
106847
|
+
periodEndDate: {
|
|
106848
|
+
type: "string",
|
|
106849
|
+
description: "Optional end of billing period for pro-rata (defaults to end of start year)."
|
|
106850
|
+
}
|
|
106851
|
+
},
|
|
106852
|
+
required: ["invoiceId", "productId"]
|
|
106853
|
+
}
|
|
106854
|
+
},
|
|
106725
106855
|
{
|
|
106726
106856
|
name: "get-products",
|
|
106727
106857
|
description: "List catalog products used on invoices AND quotes (the shared `invoice_products` catalog). Each entry includes its ID (UUID), name, unit price, currency, unit, active/archived flag, configurable flag, usage stats, and structured package metadata (category, billingType, tier, optional add-on flag and includedItems). Editing or archiving a catalog product never changes existing invoices/quotes \u2014 those keep an immutable line-item snapshot; catalog changes only affect documents created afterwards.",
|
|
@@ -113547,151 +113677,937 @@ async function handleLogHours(input) {
|
|
|
113547
113677
|
return { content: [{ type: "text", text: responseText }] };
|
|
113548
113678
|
}
|
|
113549
113679
|
|
|
113550
|
-
// src/
|
|
113551
|
-
|
|
113552
|
-
|
|
113553
|
-
|
|
113554
|
-
|
|
113555
|
-
|
|
113556
|
-
|
|
113557
|
-
|
|
113558
|
-
|
|
113559
|
-
|
|
113560
|
-
|
|
113561
|
-
|
|
113562
|
-
|
|
113563
|
-
|
|
113564
|
-
|
|
113565
|
-
|
|
113566
|
-
|
|
113567
|
-
|
|
113568
|
-
|
|
113569
|
-
|
|
113570
|
-
]
|
|
113571
|
-
};
|
|
113572
|
-
}
|
|
113573
|
-
const scope = await resolveTeamScope(input.teamId);
|
|
113574
|
-
if (!scope.ok) return scope.response;
|
|
113575
|
-
if (scope.teamIds.length === 0) {
|
|
113576
|
-
return { content: [{ type: "text", text: "No accessible teams found." }] };
|
|
113577
|
-
}
|
|
113578
|
-
const filters = [inArray(schema_exports.invoices.teamId, scope.teamIds)];
|
|
113579
|
-
if (customerId) filters.push(eq(schema_exports.invoices.customerId, customerId));
|
|
113580
|
-
if (status) filters.push(eq(schema_exports.invoices.status, status));
|
|
113581
|
-
if (q3) {
|
|
113582
|
-
filters.push(
|
|
113583
|
-
or(
|
|
113584
|
-
ilike(schema_exports.invoices.invoiceNumber, `%${q3}%`),
|
|
113585
|
-
ilike(schema_exports.invoices.customerName, `%${q3}%`)
|
|
113586
|
-
)
|
|
113587
|
-
);
|
|
113588
|
-
}
|
|
113589
|
-
const rows = await db.select({
|
|
113590
|
-
id: schema_exports.invoices.id,
|
|
113591
|
-
invoiceNumber: schema_exports.invoices.invoiceNumber,
|
|
113592
|
-
status: schema_exports.invoices.status,
|
|
113593
|
-
teamId: schema_exports.invoices.teamId,
|
|
113594
|
-
customerId: schema_exports.invoices.customerId,
|
|
113595
|
-
customerName: schema_exports.invoices.customerName,
|
|
113596
|
-
amount: schema_exports.invoices.amount,
|
|
113597
|
-
currency: schema_exports.invoices.currency,
|
|
113598
|
-
issueDate: schema_exports.invoices.issueDate,
|
|
113599
|
-
dueDate: schema_exports.invoices.dueDate,
|
|
113600
|
-
createdAt: schema_exports.invoices.createdAt
|
|
113601
|
-
}).from(schema_exports.invoices).where(and(...filters)).orderBy(desc(schema_exports.invoices.createdAt)).limit(Math.min(pageSize, 100));
|
|
113602
|
-
if (rows.length === 0) {
|
|
113603
|
-
return { content: [{ type: "text", text: "No invoices found." }] };
|
|
113680
|
+
// ../invoice/src/utils/included-items.ts
|
|
113681
|
+
function parseIncludedItems(value) {
|
|
113682
|
+
if (value == null) return null;
|
|
113683
|
+
if (!Array.isArray(value)) return null;
|
|
113684
|
+
const items = [];
|
|
113685
|
+
for (const entry of value) {
|
|
113686
|
+
if (typeof entry === "string") {
|
|
113687
|
+
const label = entry.trim();
|
|
113688
|
+
if (label) items.push({ label });
|
|
113689
|
+
continue;
|
|
113690
|
+
}
|
|
113691
|
+
if (entry && typeof entry === "object" && "label" in entry) {
|
|
113692
|
+
const raw = entry;
|
|
113693
|
+
const label = String(raw.label ?? "").trim();
|
|
113694
|
+
if (!label) continue;
|
|
113695
|
+
items.push({
|
|
113696
|
+
label,
|
|
113697
|
+
productId: raw.productId ?? null
|
|
113698
|
+
});
|
|
113699
|
+
}
|
|
113604
113700
|
}
|
|
113605
|
-
|
|
113606
|
-
|
|
113607
|
-
|
|
113608
|
-
|
|
113609
|
-
|
|
113610
|
-
|
|
113611
|
-
|
|
113612
|
-
).
|
|
113613
|
-
return
|
|
113614
|
-
|
|
113615
|
-
|
|
113616
|
-
|
|
113617
|
-
|
|
113701
|
+
return items.length > 0 ? items : null;
|
|
113702
|
+
}
|
|
113703
|
+
function serializeIncludedItems(items) {
|
|
113704
|
+
if (items == null) return null;
|
|
113705
|
+
const cleaned = items.map((item) => ({
|
|
113706
|
+
label: item.label.trim(),
|
|
113707
|
+
...item.productId ? { productId: item.productId } : {}
|
|
113708
|
+
})).filter((item) => item.label.length > 0);
|
|
113709
|
+
return cleaned.length > 0 ? cleaned : null;
|
|
113710
|
+
}
|
|
113711
|
+
function includedItemLabels(items) {
|
|
113712
|
+
const parsed = items ? serializeIncludedItems(items) : null;
|
|
113713
|
+
if (!parsed?.length) return null;
|
|
113714
|
+
return parsed.map((item) => item.label);
|
|
113715
|
+
}
|
|
113618
113716
|
|
|
113619
|
-
|
|
113620
|
-
|
|
113717
|
+
// ../invoice/src/utils/product-options.ts
|
|
113718
|
+
function findOption(group3, optionId) {
|
|
113719
|
+
if (!optionId) return void 0;
|
|
113720
|
+
return group3.options?.find((option) => option.id === optionId);
|
|
113721
|
+
}
|
|
113722
|
+
function buildDefaultConfiguration(product) {
|
|
113723
|
+
const groups = product.options ?? [];
|
|
113724
|
+
return {
|
|
113725
|
+
selections: groups.map((group3) => {
|
|
113726
|
+
if (group3.type === "quantity") {
|
|
113727
|
+
return {
|
|
113728
|
+
groupId: group3.id,
|
|
113729
|
+
type: "quantity",
|
|
113730
|
+
quantity: group3.defaultQuantity ?? group3.min ?? 0
|
|
113731
|
+
};
|
|
113621
113732
|
}
|
|
113622
|
-
|
|
113733
|
+
if (group3.type === "single_choice") {
|
|
113734
|
+
const defaultOption = group3.options?.find((option) => option.selectedByDefault) ?? group3.options?.[0];
|
|
113735
|
+
return {
|
|
113736
|
+
groupId: group3.id,
|
|
113737
|
+
type: "single_choice",
|
|
113738
|
+
optionId: defaultOption?.id ?? null
|
|
113739
|
+
};
|
|
113740
|
+
}
|
|
113741
|
+
return {
|
|
113742
|
+
groupId: group3.id,
|
|
113743
|
+
type: "addon",
|
|
113744
|
+
optionIds: group3.options?.filter((option) => option.selectedByDefault).map((option) => option.id) ?? []
|
|
113745
|
+
};
|
|
113746
|
+
})
|
|
113623
113747
|
};
|
|
113624
113748
|
}
|
|
113625
|
-
|
|
113626
|
-
const
|
|
113627
|
-
|
|
113628
|
-
|
|
113629
|
-
|
|
113630
|
-
};
|
|
113631
|
-
}
|
|
113632
|
-
const scope = await resolveTeamScope(input.teamId);
|
|
113633
|
-
if (!scope.ok) return scope.response;
|
|
113634
|
-
if (scope.teamIds.length === 0) {
|
|
113635
|
-
return { content: [{ type: "text", text: "No accessible teams found." }] };
|
|
113749
|
+
function computeConfiguredPrice(product, configuration) {
|
|
113750
|
+
const base = product.price ?? 0;
|
|
113751
|
+
const groups = product.options ?? [];
|
|
113752
|
+
if (!configuration || groups.length === 0) {
|
|
113753
|
+
return base;
|
|
113636
113754
|
}
|
|
113637
|
-
|
|
113638
|
-
|
|
113639
|
-
|
|
113640
|
-
|
|
113641
|
-
|
|
113642
|
-
|
|
113643
|
-
|
|
113644
|
-
|
|
113645
|
-
|
|
113646
|
-
|
|
113647
|
-
|
|
113648
|
-
|
|
113649
|
-
|
|
113650
|
-
|
|
113651
|
-
|
|
113652
|
-
|
|
113653
|
-
|
|
113654
|
-
|
|
113655
|
-
|
|
113656
|
-
|
|
113657
|
-
|
|
113755
|
+
let extra = 0;
|
|
113756
|
+
for (const group3 of groups) {
|
|
113757
|
+
const selection = configuration.selections.find(
|
|
113758
|
+
(item) => item.groupId === group3.id
|
|
113759
|
+
);
|
|
113760
|
+
if (!selection) continue;
|
|
113761
|
+
if (group3.type === "quantity" && selection.type === "quantity") {
|
|
113762
|
+
const clamped = clampQuantity(selection.quantity, group3);
|
|
113763
|
+
extra += clamped * (group3.pricePerUnit ?? 0);
|
|
113764
|
+
continue;
|
|
113765
|
+
}
|
|
113766
|
+
if (group3.type === "single_choice" && selection.type === "single_choice") {
|
|
113767
|
+
const option = findOption(group3, selection.optionId);
|
|
113768
|
+
extra += option?.price ?? 0;
|
|
113769
|
+
continue;
|
|
113770
|
+
}
|
|
113771
|
+
if (group3.type === "addon" && selection.type === "addon") {
|
|
113772
|
+
for (const optionId of selection.optionIds) {
|
|
113773
|
+
const option = findOption(group3, optionId);
|
|
113774
|
+
extra += option?.price ?? 0;
|
|
113775
|
+
}
|
|
113776
|
+
}
|
|
113658
113777
|
}
|
|
113659
|
-
|
|
113660
|
-
|
|
113661
|
-
|
|
113662
|
-
|
|
113663
|
-
|
|
113664
|
-
|
|
113665
|
-
|
|
113666
|
-
|
|
113667
|
-
|
|
113668
|
-
|
|
113778
|
+
return base + extra;
|
|
113779
|
+
}
|
|
113780
|
+
function clampQuantity(quantity, group3) {
|
|
113781
|
+
const value = Number.isFinite(quantity) ? quantity : 0;
|
|
113782
|
+
const min = group3.min ?? 0;
|
|
113783
|
+
const max = group3.max;
|
|
113784
|
+
const lower = Math.max(value, min);
|
|
113785
|
+
return max != null ? Math.min(lower, max) : lower;
|
|
113786
|
+
}
|
|
113787
|
+
|
|
113788
|
+
// ../invoice/src/utils/product-clause.ts
|
|
113789
|
+
function trimToNull(value) {
|
|
113790
|
+
if (typeof value !== "string") return null;
|
|
113791
|
+
const trimmed = value.trim();
|
|
113792
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
113793
|
+
}
|
|
113794
|
+
function parseStringList(value) {
|
|
113795
|
+
if (!Array.isArray(value)) return [];
|
|
113796
|
+
const out = [];
|
|
113797
|
+
for (const entry of value) {
|
|
113798
|
+
const label = trimToNull(entry);
|
|
113799
|
+
if (label) out.push(label);
|
|
113800
|
+
}
|
|
113801
|
+
return out;
|
|
113802
|
+
}
|
|
113803
|
+
function parseLimits(value) {
|
|
113804
|
+
if (!value || typeof value !== "object") return null;
|
|
113805
|
+
const raw = value;
|
|
113806
|
+
const hoursRaw = raw.includedHoursPerMonth;
|
|
113807
|
+
let includedHoursPerMonth = null;
|
|
113808
|
+
if (typeof hoursRaw === "number" && Number.isFinite(hoursRaw)) {
|
|
113809
|
+
includedHoursPerMonth = hoursRaw;
|
|
113810
|
+
} else if (typeof hoursRaw === "string" && hoursRaw.trim() !== "") {
|
|
113811
|
+
const parsed = Number(hoursRaw);
|
|
113812
|
+
if (Number.isFinite(parsed)) includedHoursPerMonth = parsed;
|
|
113813
|
+
}
|
|
113814
|
+
const rollover = typeof raw.rollover === "boolean" ? raw.rollover : null;
|
|
113815
|
+
const limits = {
|
|
113816
|
+
includedHoursPerMonth,
|
|
113817
|
+
rollover,
|
|
113818
|
+
minimumBillingUnitExtraWork: trimToNull(raw.minimumBillingUnitExtraWork),
|
|
113819
|
+
responseTime: trimToNull(raw.responseTime),
|
|
113820
|
+
supportLevel: trimToNull(raw.supportLevel)
|
|
113821
|
+
};
|
|
113822
|
+
return isLimitsEmpty(limits) ? null : limits;
|
|
113823
|
+
}
|
|
113824
|
+
function isLimitsEmpty(limits) {
|
|
113825
|
+
if (!limits) return true;
|
|
113826
|
+
return limits.includedHoursPerMonth == null && limits.rollover == null && !limits.minimumBillingUnitExtraWork && !limits.responseTime && !limits.supportLevel;
|
|
113827
|
+
}
|
|
113828
|
+
function parseProductClause(value) {
|
|
113829
|
+
if (value == null) return null;
|
|
113830
|
+
if (typeof value !== "object" || Array.isArray(value)) return null;
|
|
113831
|
+
const raw = value;
|
|
113832
|
+
const clause = {
|
|
113833
|
+
commercialDescription: trimToNull(raw.commercialDescription),
|
|
113834
|
+
includedScope: parseStringList(raw.includedScope),
|
|
113835
|
+
excludedScope: parseStringList(raw.excludedScope),
|
|
113836
|
+
limits: parseLimits(raw.limits),
|
|
113837
|
+
customerValue: trimToNull(raw.customerValue),
|
|
113838
|
+
extraWorkConditions: trimToNull(raw.extraWorkConditions)
|
|
113839
|
+
};
|
|
113840
|
+
return isClauseEmpty(clause) ? null : clause;
|
|
113841
|
+
}
|
|
113842
|
+
function isClauseEmpty(clause) {
|
|
113843
|
+
if (!clause) return true;
|
|
113844
|
+
return !clause.commercialDescription && (clause.includedScope?.length ?? 0) === 0 && (clause.excludedScope?.length ?? 0) === 0 && isLimitsEmpty(clause.limits) && !clause.customerValue && !clause.extraWorkConditions;
|
|
113845
|
+
}
|
|
113846
|
+
function serializeProductClause(clause) {
|
|
113847
|
+
if (clause == null) return null;
|
|
113848
|
+
const parsed = parseProductClause(clause);
|
|
113849
|
+
if (!parsed) return null;
|
|
113850
|
+
const out = {};
|
|
113851
|
+
if (parsed.commercialDescription) {
|
|
113852
|
+
out.commercialDescription = parsed.commercialDescription;
|
|
113853
|
+
}
|
|
113854
|
+
if (parsed.includedScope && parsed.includedScope.length > 0) {
|
|
113855
|
+
out.includedScope = parsed.includedScope;
|
|
113856
|
+
}
|
|
113857
|
+
if (parsed.excludedScope && parsed.excludedScope.length > 0) {
|
|
113858
|
+
out.excludedScope = parsed.excludedScope;
|
|
113859
|
+
}
|
|
113860
|
+
if (!isLimitsEmpty(parsed.limits)) {
|
|
113861
|
+
const limits = parsed.limits;
|
|
113862
|
+
const cleanedLimits = {};
|
|
113863
|
+
if (limits.includedHoursPerMonth != null) {
|
|
113864
|
+
cleanedLimits.includedHoursPerMonth = limits.includedHoursPerMonth;
|
|
113865
|
+
}
|
|
113866
|
+
if (limits.rollover != null) cleanedLimits.rollover = limits.rollover;
|
|
113867
|
+
if (limits.minimumBillingUnitExtraWork) {
|
|
113868
|
+
cleanedLimits.minimumBillingUnitExtraWork = limits.minimumBillingUnitExtraWork;
|
|
113869
|
+
}
|
|
113870
|
+
if (limits.responseTime) cleanedLimits.responseTime = limits.responseTime;
|
|
113871
|
+
if (limits.supportLevel) cleanedLimits.supportLevel = limits.supportLevel;
|
|
113872
|
+
out.limits = cleanedLimits;
|
|
113873
|
+
}
|
|
113874
|
+
if (parsed.customerValue) out.customerValue = parsed.customerValue;
|
|
113875
|
+
if (parsed.extraWorkConditions) {
|
|
113876
|
+
out.extraWorkConditions = parsed.extraWorkConditions;
|
|
113877
|
+
}
|
|
113878
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
113879
|
+
}
|
|
113880
|
+
function clauseLimitLines(limits) {
|
|
113881
|
+
if (isLimitsEmpty(limits) || !limits) return [];
|
|
113882
|
+
const lines = [];
|
|
113883
|
+
if (limits.includedHoursPerMonth != null) {
|
|
113884
|
+
lines.push(`Inbegrepen uren per maand: ${limits.includedHoursPerMonth}`);
|
|
113885
|
+
}
|
|
113886
|
+
if (limits.rollover != null) {
|
|
113887
|
+
lines.push(
|
|
113888
|
+
limits.rollover ? "Niet-gebruikte uren schuiven door naar de volgende maand" : "Niet-gebruikte uren vervallen aan het einde van de maand"
|
|
113889
|
+
);
|
|
113890
|
+
}
|
|
113891
|
+
if (limits.minimumBillingUnitExtraWork) {
|
|
113892
|
+
lines.push(
|
|
113893
|
+
`Minimale facturatie-eenheid meerwerk: ${limits.minimumBillingUnitExtraWork}`
|
|
113894
|
+
);
|
|
113895
|
+
}
|
|
113896
|
+
if (limits.supportLevel) lines.push(`Supportniveau: ${limits.supportLevel}`);
|
|
113897
|
+
if (limits.responseTime) lines.push(`Responstijd: ${limits.responseTime}`);
|
|
113898
|
+
return lines;
|
|
113899
|
+
}
|
|
113900
|
+
function clauseSummaryLines(clause) {
|
|
113901
|
+
const parsed = parseProductClause(clause);
|
|
113902
|
+
if (!parsed) return [];
|
|
113903
|
+
const lines = [];
|
|
113904
|
+
if (parsed.commercialDescription) {
|
|
113905
|
+
lines.push(parsed.commercialDescription);
|
|
113906
|
+
}
|
|
113907
|
+
if (parsed.includedScope?.length) {
|
|
113908
|
+
lines.push(`Inbegrepen: ${parsed.includedScope.join("; ")}`);
|
|
113909
|
+
}
|
|
113910
|
+
if (parsed.excludedScope?.length) {
|
|
113911
|
+
lines.push(`Niet inbegrepen: ${parsed.excludedScope.join("; ")}`);
|
|
113912
|
+
}
|
|
113913
|
+
lines.push(...clauseLimitLines(parsed.limits));
|
|
113914
|
+
if (parsed.customerValue) {
|
|
113915
|
+
lines.push(`Klantwaarde: ${parsed.customerValue}`);
|
|
113916
|
+
}
|
|
113917
|
+
if (parsed.extraWorkConditions) {
|
|
113918
|
+
lines.push(`Meerwerk: ${parsed.extraWorkConditions}`);
|
|
113919
|
+
}
|
|
113920
|
+
return lines;
|
|
113921
|
+
}
|
|
113922
|
+
|
|
113923
|
+
// ../invoice/src/utils/pricing-options.ts
|
|
113924
|
+
function defaultMonthsCovered(interval2) {
|
|
113925
|
+
return interval2 === "year" ? 12 : 1;
|
|
113926
|
+
}
|
|
113927
|
+
function formatRecurringNote(option) {
|
|
113928
|
+
const period = option.billingInterval === "year" ? "per jaar" : "per maand";
|
|
113929
|
+
const bits = [`${option.label} (${period})`];
|
|
113930
|
+
if (option.discountDescription) bits.push(option.discountDescription);
|
|
113931
|
+
const contract = option.contract;
|
|
113932
|
+
const terms = [];
|
|
113933
|
+
if (contract?.minimumTermMonths) {
|
|
113934
|
+
terms.push(`min. looptijd ${contract.minimumTermMonths} mnd`);
|
|
113935
|
+
}
|
|
113936
|
+
if (contract?.noticePeriodDays) {
|
|
113937
|
+
terms.push(`opzegtermijn ${contract.noticePeriodDays} dagen`);
|
|
113938
|
+
}
|
|
113939
|
+
if (contract?.renewalPolicy === "auto_renew") {
|
|
113940
|
+
terms.push("stilzwijgend verlengd");
|
|
113941
|
+
}
|
|
113942
|
+
if (terms.length) bits.push(terms.join(", "));
|
|
113943
|
+
return bits.join(" \xB7 ");
|
|
113944
|
+
}
|
|
113945
|
+
function resolveDefaultPricingOption(options, defaultId) {
|
|
113946
|
+
if (!options || options.length === 0) return null;
|
|
113947
|
+
if (defaultId) {
|
|
113948
|
+
const found = options.find((o3) => o3.id === defaultId);
|
|
113949
|
+
if (found) return found;
|
|
113950
|
+
}
|
|
113951
|
+
return options[0] ?? null;
|
|
113952
|
+
}
|
|
113953
|
+
function effectivePricingOptions(product) {
|
|
113954
|
+
if (product.pricingOptions && product.pricingOptions.length > 0) {
|
|
113955
|
+
return product.pricingOptions;
|
|
113956
|
+
}
|
|
113957
|
+
const interval2 = product.billingType === "yearly" ? "year" : product.billingType === "monthly" ? "month" : null;
|
|
113958
|
+
if (interval2 && product.price != null) {
|
|
113959
|
+
return [
|
|
113960
|
+
{
|
|
113961
|
+
id: interval2,
|
|
113962
|
+
label: interval2 === "year" ? "Jaarlijks" : "Maandelijks",
|
|
113963
|
+
billingInterval: interval2,
|
|
113964
|
+
price: product.price,
|
|
113965
|
+
monthsCovered: defaultMonthsCovered(interval2)
|
|
113966
|
+
}
|
|
113967
|
+
];
|
|
113968
|
+
}
|
|
113969
|
+
return [];
|
|
113970
|
+
}
|
|
113971
|
+
function endOfUtcYear(iso) {
|
|
113972
|
+
const date10 = new Date(iso);
|
|
113973
|
+
if (Number.isNaN(date10.getTime())) return iso;
|
|
113974
|
+
return new Date(
|
|
113975
|
+
Date.UTC(date10.getUTCFullYear(), 11, 31, 23, 59, 59, 999)
|
|
113976
|
+
).toISOString();
|
|
113977
|
+
}
|
|
113978
|
+
function countInclusiveMonths(startIso, endIso) {
|
|
113979
|
+
const start = new Date(startIso);
|
|
113980
|
+
const end = new Date(endIso);
|
|
113981
|
+
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return 0;
|
|
113982
|
+
if (end.getTime() < start.getTime()) return 0;
|
|
113983
|
+
return (end.getUTCFullYear() - start.getUTCFullYear()) * 12 + (end.getUTCMonth() - start.getUTCMonth()) + 1;
|
|
113984
|
+
}
|
|
113985
|
+
function computeProRata(input) {
|
|
113986
|
+
if (!input.startDate) return null;
|
|
113987
|
+
const start = new Date(input.startDate);
|
|
113988
|
+
if (Number.isNaN(start.getTime())) return null;
|
|
113989
|
+
const monthsInPeriod = input.monthsInPeriod != null && input.monthsInPeriod > 0 ? Math.round(input.monthsInPeriod) : 12;
|
|
113990
|
+
const periodStart = input.startDate;
|
|
113991
|
+
const periodEnd = input.periodEndDate ?? endOfUtcYear(input.startDate);
|
|
113992
|
+
const end = new Date(periodEnd);
|
|
113993
|
+
if (Number.isNaN(end.getTime())) return null;
|
|
113994
|
+
let months2 = countInclusiveMonths(periodStart, periodEnd);
|
|
113995
|
+
months2 = Math.min(Math.max(months2, 1), monthsInPeriod);
|
|
113996
|
+
const fraction = months2 / monthsInPeriod;
|
|
113997
|
+
const amount = Math.round(input.pricePerPeriod * fraction * 100) / 100;
|
|
113998
|
+
return { months: months2, fraction, amount, periodStart, periodEnd };
|
|
113999
|
+
}
|
|
114000
|
+
|
|
114001
|
+
// src/tools/invoice-line-util.ts
|
|
114002
|
+
function round2(n3) {
|
|
114003
|
+
return Math.round(n3 * 100) / 100;
|
|
114004
|
+
}
|
|
114005
|
+
function lineFinancials(quantity, price, defaults) {
|
|
114006
|
+
const lineTotal = quantity * price;
|
|
114007
|
+
return {
|
|
114008
|
+
vat: defaults.includeVat ? round2(lineTotal * (defaults.vatRate / 100)) : void 0,
|
|
114009
|
+
tax: defaults.includeTax ? round2(lineTotal * (defaults.taxRate / 100)) : void 0
|
|
114010
|
+
};
|
|
114011
|
+
}
|
|
114012
|
+
function computeInvoiceTotals(items, defaults, discount = 0) {
|
|
114013
|
+
const subtotal = items.reduce(
|
|
114014
|
+
(sum, i6) => sum + (i6.quantity || 0) * (i6.price || 0),
|
|
114015
|
+
0
|
|
114016
|
+
);
|
|
114017
|
+
const vat = defaults.includeVat ? subtotal * (defaults.vatRate / 100) : 0;
|
|
114018
|
+
const tax = defaults.includeTax ? subtotal * (defaults.taxRate / 100) : 0;
|
|
114019
|
+
const safeDiscount = defaults.includeDiscount ? discount ?? 0 : 0;
|
|
114020
|
+
const amount = subtotal + vat + tax - safeDiscount;
|
|
114021
|
+
return {
|
|
114022
|
+
subtotal: round2(subtotal),
|
|
114023
|
+
vat: round2(vat),
|
|
114024
|
+
tax: round2(tax),
|
|
114025
|
+
amount: round2(amount)
|
|
114026
|
+
};
|
|
114027
|
+
}
|
|
114028
|
+
function templateDefaultsFromInvoice(template, currency) {
|
|
114029
|
+
const t8 = template ?? {};
|
|
114030
|
+
return {
|
|
114031
|
+
currency: t8.currency || currency || "EUR",
|
|
114032
|
+
vatRate: t8.vatRate ?? 21,
|
|
114033
|
+
taxRate: t8.taxRate ?? 0,
|
|
114034
|
+
includeVat: t8.includeVat ?? true,
|
|
114035
|
+
includeTax: t8.includeTax ?? false,
|
|
114036
|
+
includeDiscount: t8.includeDiscount ?? false,
|
|
114037
|
+
includeDecimals: t8.includeDecimals ?? true,
|
|
114038
|
+
includeUnits: t8.includeUnits ?? true,
|
|
114039
|
+
raw: t8
|
|
114040
|
+
};
|
|
114041
|
+
}
|
|
114042
|
+
function plainTextFromLineItemName(name21) {
|
|
114043
|
+
if (typeof name21 === "string") return name21.trim();
|
|
114044
|
+
if (!name21 || typeof name21 !== "object") return "";
|
|
114045
|
+
const parts = [];
|
|
114046
|
+
const walk = (node) => {
|
|
114047
|
+
if (!node || typeof node !== "object") return;
|
|
114048
|
+
const n3 = node;
|
|
114049
|
+
if (typeof n3.text === "string") parts.push(n3.text);
|
|
114050
|
+
if (Array.isArray(n3.content)) n3.content.forEach(walk);
|
|
114051
|
+
};
|
|
114052
|
+
walk(name21);
|
|
114053
|
+
return parts.join(" ").replace(/\s+/g, " ").trim();
|
|
114054
|
+
}
|
|
114055
|
+
function resolvePricingOption(product, pricingOptionId) {
|
|
114056
|
+
const options = effectivePricingOptions(product);
|
|
114057
|
+
if (options.length === 0) return null;
|
|
114058
|
+
if (pricingOptionId) {
|
|
114059
|
+
const found = options.find((o3) => o3.id === pricingOptionId);
|
|
114060
|
+
if (found) return found;
|
|
114061
|
+
}
|
|
114062
|
+
return resolveDefaultPricingOption(options, product.defaultPricingOptionId);
|
|
114063
|
+
}
|
|
114064
|
+
function compactLineDescription(product, override) {
|
|
114065
|
+
if (override?.trim()) return override.trim();
|
|
114066
|
+
const clause = parseProductClause(product.clause);
|
|
114067
|
+
if (clause?.commercialDescription) return clause.commercialDescription;
|
|
114068
|
+
return product.name;
|
|
114069
|
+
}
|
|
114070
|
+
function buildInvoiceLineFromProduct(product, opts, defaults) {
|
|
114071
|
+
const quantity = opts.quantity ?? 1;
|
|
114072
|
+
const pricingOption = resolvePricingOption(product, opts.pricingOptionId);
|
|
114073
|
+
const clause = serializeProductClause(product.clause);
|
|
114074
|
+
let configuration;
|
|
114075
|
+
let price = opts.customPrice ?? product.price ?? 0;
|
|
114076
|
+
if (product.isConfigurable && product.options) {
|
|
114077
|
+
const groups = product.options;
|
|
114078
|
+
if (groups?.length) {
|
|
114079
|
+
const defaultConfig = buildDefaultConfiguration({
|
|
114080
|
+
...product,
|
|
114081
|
+
options: groups
|
|
114082
|
+
});
|
|
114083
|
+
configuration = defaultConfig;
|
|
114084
|
+
price = computeConfiguredPrice(
|
|
114085
|
+
product,
|
|
114086
|
+
defaultConfig
|
|
114087
|
+
);
|
|
114088
|
+
}
|
|
114089
|
+
}
|
|
114090
|
+
if (pricingOption && !(product.isConfigurable && product.options)) {
|
|
114091
|
+
price = pricingOption.price;
|
|
114092
|
+
}
|
|
114093
|
+
if (opts.prorate && pricingOption?.billingInterval === "year" && opts.startDate) {
|
|
114094
|
+
const proRata = computeProRata({
|
|
114095
|
+
pricePerPeriod: price,
|
|
114096
|
+
startDate: opts.startDate,
|
|
114097
|
+
periodEndDate: opts.periodEndDate,
|
|
114098
|
+
monthsInPeriod: pricingOption.monthsCovered ?? 12
|
|
114099
|
+
});
|
|
114100
|
+
if (proRata) price = proRata.amount;
|
|
114101
|
+
}
|
|
114102
|
+
if (opts.customPrice != null) price = opts.customPrice;
|
|
114103
|
+
const { vat, tax } = lineFinancials(quantity, price, defaults);
|
|
114104
|
+
return {
|
|
114105
|
+
name: compactLineDescription(product, opts.customDescription),
|
|
114106
|
+
quantity,
|
|
114107
|
+
unit: product.unit || void 0,
|
|
114108
|
+
price,
|
|
114109
|
+
vat,
|
|
114110
|
+
tax,
|
|
114111
|
+
productId: product.id,
|
|
114112
|
+
configuration,
|
|
114113
|
+
clause,
|
|
114114
|
+
pricingOption: pricingOption ?? null
|
|
114115
|
+
};
|
|
114116
|
+
}
|
|
114117
|
+
function formatLineItemDetail(line2, index2) {
|
|
114118
|
+
const description = plainTextFromLineItemName(line2.name) || "(no description)";
|
|
114119
|
+
const lineTotal = round2((line2.quantity ?? 0) * (line2.price ?? 0));
|
|
114120
|
+
const parts = [
|
|
114121
|
+
`[${index2}] **${description}**`,
|
|
114122
|
+
` qty=${line2.quantity ?? 0}${line2.unit ? ` ${line2.unit}` : ""} \xD7 ${line2.price ?? 0} = ${lineTotal}`
|
|
114123
|
+
];
|
|
114124
|
+
if (line2.productId) parts.push(` productId: ${line2.productId}`);
|
|
114125
|
+
if (line2.pricingOption) {
|
|
114126
|
+
parts.push(` variant: ${formatRecurringNote(line2.pricingOption)}`);
|
|
114127
|
+
}
|
|
114128
|
+
const clauseLines = clauseSummaryLines(parseProductClause(line2.clause));
|
|
114129
|
+
if (clauseLines.length > 0) {
|
|
114130
|
+
parts.push(" scope:");
|
|
114131
|
+
clauseLines.forEach((l4) => parts.push(` - ${l4}`));
|
|
114132
|
+
}
|
|
114133
|
+
return parts.join("\n");
|
|
114134
|
+
}
|
|
114135
|
+
|
|
114136
|
+
// src/tools/invoices.ts
|
|
114137
|
+
var INVOICE_STATUSES = [
|
|
114138
|
+
"draft",
|
|
114139
|
+
"overdue",
|
|
114140
|
+
"paid",
|
|
114141
|
+
"partially_paid",
|
|
114142
|
+
"unpaid",
|
|
114143
|
+
"canceled",
|
|
114144
|
+
"scheduled",
|
|
114145
|
+
"refunded"
|
|
114146
|
+
];
|
|
114147
|
+
var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
114148
|
+
function textResponse2(text3) {
|
|
114149
|
+
return { content: [{ type: "text", text: text3 }] };
|
|
114150
|
+
}
|
|
114151
|
+
function tiptapNote(text3) {
|
|
114152
|
+
return {
|
|
114153
|
+
type: "doc",
|
|
114154
|
+
content: [{ type: "paragraph", content: [{ type: "text", text: text3 }] }]
|
|
114155
|
+
};
|
|
114156
|
+
}
|
|
114157
|
+
var INVOICE_DETAIL_COLUMNS = {
|
|
114158
|
+
id: schema_exports.invoices.id,
|
|
114159
|
+
teamId: schema_exports.invoices.teamId,
|
|
114160
|
+
invoiceNumber: schema_exports.invoices.invoiceNumber,
|
|
114161
|
+
status: schema_exports.invoices.status,
|
|
114162
|
+
customerId: schema_exports.invoices.customerId,
|
|
114163
|
+
customerName: schema_exports.invoices.customerName,
|
|
114164
|
+
amount: schema_exports.invoices.amount,
|
|
114165
|
+
subtotal: schema_exports.invoices.subtotal,
|
|
114166
|
+
vat: schema_exports.invoices.vat,
|
|
114167
|
+
tax: schema_exports.invoices.tax,
|
|
114168
|
+
discount: schema_exports.invoices.discount,
|
|
114169
|
+
currency: schema_exports.invoices.currency,
|
|
114170
|
+
issueDate: schema_exports.invoices.issueDate,
|
|
114171
|
+
dueDate: schema_exports.invoices.dueDate,
|
|
114172
|
+
createdAt: schema_exports.invoices.createdAt,
|
|
114173
|
+
updatedAt: schema_exports.invoices.updatedAt,
|
|
114174
|
+
note: schema_exports.invoices.note,
|
|
114175
|
+
internalNote: schema_exports.invoices.internalNote,
|
|
114176
|
+
lineItems: schema_exports.invoices.lineItems,
|
|
114177
|
+
template: schema_exports.invoices.template,
|
|
114178
|
+
noteDetails: schema_exports.invoices.noteDetails
|
|
114179
|
+
};
|
|
114180
|
+
var PRODUCT_COLUMNS = {
|
|
114181
|
+
id: schema_exports.invoiceProducts.id,
|
|
114182
|
+
name: schema_exports.invoiceProducts.name,
|
|
114183
|
+
description: schema_exports.invoiceProducts.description,
|
|
114184
|
+
price: schema_exports.invoiceProducts.price,
|
|
114185
|
+
currency: schema_exports.invoiceProducts.currency,
|
|
114186
|
+
unit: schema_exports.invoiceProducts.unit,
|
|
114187
|
+
isConfigurable: schema_exports.invoiceProducts.isConfigurable,
|
|
114188
|
+
options: schema_exports.invoiceProducts.options,
|
|
114189
|
+
billingType: schema_exports.invoiceProducts.billingType,
|
|
114190
|
+
category: schema_exports.invoiceProducts.category,
|
|
114191
|
+
includedItems: schema_exports.invoiceProducts.includedItems,
|
|
114192
|
+
optional: schema_exports.invoiceProducts.optional,
|
|
114193
|
+
tier: schema_exports.invoiceProducts.tier,
|
|
114194
|
+
clause: schema_exports.invoiceProducts.clause,
|
|
114195
|
+
serviceCadence: schema_exports.invoiceProducts.serviceCadence,
|
|
114196
|
+
pricingOptions: schema_exports.invoiceProducts.pricingOptions,
|
|
114197
|
+
defaultPricingOptionId: schema_exports.invoiceProducts.defaultPricingOptionId
|
|
114198
|
+
};
|
|
114199
|
+
function parseStoredLineItems(value) {
|
|
114200
|
+
return Array.isArray(value) ? value : [];
|
|
114201
|
+
}
|
|
114202
|
+
async function loadInvoiceByIdentifier(identifier, teamIds) {
|
|
114203
|
+
if (teamIds.length === 0) return null;
|
|
114204
|
+
const byId = UUID_RE.test(identifier);
|
|
114205
|
+
const filters = [inArray(schema_exports.invoices.teamId, teamIds)];
|
|
114206
|
+
filters.push(
|
|
114207
|
+
byId ? eq(schema_exports.invoices.id, identifier) : eq(schema_exports.invoices.invoiceNumber, identifier)
|
|
114208
|
+
);
|
|
114209
|
+
const [row] = await db.select(INVOICE_DETAIL_COLUMNS).from(schema_exports.invoices).where(and(...filters)).limit(1);
|
|
114210
|
+
return row ?? null;
|
|
114211
|
+
}
|
|
114212
|
+
async function loadInvoiceInTeam(identifier, teamId) {
|
|
114213
|
+
const accessibleTeamIds = await getAccessibleTeamIds(teamId);
|
|
114214
|
+
return loadInvoiceByIdentifier(identifier, accessibleTeamIds);
|
|
114215
|
+
}
|
|
114216
|
+
async function loadProductsInTeam(productIds, teamId) {
|
|
114217
|
+
if (productIds.length === 0) return /* @__PURE__ */ new Map();
|
|
114218
|
+
const accessibleTeamIds = await getAccessibleTeamIds(teamId);
|
|
114219
|
+
const rows = await db.select(PRODUCT_COLUMNS).from(schema_exports.invoiceProducts).where(
|
|
114220
|
+
and(
|
|
114221
|
+
inArray(schema_exports.invoiceProducts.id, productIds),
|
|
114222
|
+
inArray(schema_exports.invoiceProducts.teamId, accessibleTeamIds)
|
|
114223
|
+
)
|
|
114224
|
+
);
|
|
114225
|
+
return new Map(rows.map((r6) => [r6.id, r6]));
|
|
114226
|
+
}
|
|
114227
|
+
async function resolveInvoiceLineItems(inputs, defaults, teamId) {
|
|
114228
|
+
const productIds = inputs.map((i6) => i6.productId).filter((id) => Boolean(id));
|
|
114229
|
+
const products = await loadProductsInTeam([...new Set(productIds)], teamId);
|
|
114230
|
+
const items = [];
|
|
114231
|
+
for (const input of inputs) {
|
|
114232
|
+
if (input.productId) {
|
|
114233
|
+
const product = products.get(input.productId);
|
|
114234
|
+
if (!product) {
|
|
114235
|
+
return {
|
|
114236
|
+
items: [],
|
|
114237
|
+
error: `Product ${input.productId} not found or not owned by this team.`
|
|
114238
|
+
};
|
|
114239
|
+
}
|
|
114240
|
+
items.push(
|
|
114241
|
+
buildInvoiceLineFromProduct(
|
|
114242
|
+
product,
|
|
114243
|
+
{
|
|
114244
|
+
quantity: input.quantity,
|
|
114245
|
+
customDescription: input.name ?? input.description,
|
|
114246
|
+
customPrice: input.price,
|
|
114247
|
+
pricingOptionId: input.pricingOptionId,
|
|
114248
|
+
prorate: input.prorate,
|
|
114249
|
+
startDate: input.startDate,
|
|
114250
|
+
periodEndDate: input.periodEndDate
|
|
114251
|
+
},
|
|
114252
|
+
defaults
|
|
114253
|
+
)
|
|
114254
|
+
);
|
|
114255
|
+
continue;
|
|
114256
|
+
}
|
|
114257
|
+
const quantity = input.quantity ?? 1;
|
|
114258
|
+
const price = input.price ?? 0;
|
|
114259
|
+
const name21 = input.name?.trim() || input.description?.trim() || "(no description)";
|
|
114260
|
+
const { vat, tax } = lineFinancials(quantity, price, defaults);
|
|
114261
|
+
items.push({ name: name21, quantity, unit: input.unit, price, vat, tax });
|
|
114262
|
+
}
|
|
114263
|
+
return { items };
|
|
114264
|
+
}
|
|
114265
|
+
function notDraftResponse(invoice) {
|
|
114266
|
+
return textResponse2(
|
|
114267
|
+
`Invoice ${invoice.invoiceNumber ?? invoice.id} has status "${invoice.status}", not "draft". These tools only modify draft invoices \u2014 sent/paid/unpaid/overdue invoices are immutable here.`
|
|
114268
|
+
);
|
|
114269
|
+
}
|
|
114270
|
+
function formatInvoiceSummary(invoice) {
|
|
114271
|
+
const items = parseStoredLineItems(invoice.lineItems);
|
|
114272
|
+
return `**${invoice.invoiceNumber ?? "(draft, no number)"}** (${invoice.status})
|
|
114273
|
+
ID: ${invoice.id}
|
|
114274
|
+
Customer: ${invoice.customerName ?? invoice.customerId ?? "(none)"}
|
|
114275
|
+
Total: ${invoice.amount ?? "?"} ${invoice.currency ?? ""} (subtotal ${invoice.subtotal ?? "?"}, VAT ${invoice.vat ?? 0})
|
|
114276
|
+
Line items: ${items.length}
|
|
114277
|
+
Issue: ${invoice.issueDate ? new Date(invoice.issueDate).toLocaleDateString() : "-"} | Due: ${invoice.dueDate ? new Date(invoice.dueDate).toLocaleDateString() : "-"}
|
|
114278
|
+
`;
|
|
114279
|
+
}
|
|
114280
|
+
async function handleGetInvoices(input) {
|
|
114281
|
+
const { customerId, status, q: q3, pageSize = 20 } = input;
|
|
114282
|
+
if (status && !INVOICE_STATUSES.includes(status)) {
|
|
114283
|
+
return textResponse2(
|
|
114284
|
+
`Error: invalid status "${status}". Allowed: ${INVOICE_STATUSES.join(", ")}.`
|
|
114285
|
+
);
|
|
114286
|
+
}
|
|
114287
|
+
const scope = await resolveTeamScope(input.teamId);
|
|
114288
|
+
if (!scope.ok) return scope.response;
|
|
114289
|
+
if (scope.teamIds.length === 0) {
|
|
114290
|
+
return textResponse2("No accessible teams found.");
|
|
114291
|
+
}
|
|
114292
|
+
const filters = [inArray(schema_exports.invoices.teamId, scope.teamIds)];
|
|
114293
|
+
if (customerId) filters.push(eq(schema_exports.invoices.customerId, customerId));
|
|
114294
|
+
if (status) filters.push(eq(schema_exports.invoices.status, status));
|
|
114295
|
+
if (q3) {
|
|
114296
|
+
filters.push(
|
|
114297
|
+
or(
|
|
114298
|
+
ilike(schema_exports.invoices.invoiceNumber, `%${q3}%`),
|
|
114299
|
+
ilike(schema_exports.invoices.customerName, `%${q3}%`)
|
|
114300
|
+
)
|
|
114301
|
+
);
|
|
114302
|
+
}
|
|
114303
|
+
const rows = await db.select({
|
|
114304
|
+
id: schema_exports.invoices.id,
|
|
114305
|
+
invoiceNumber: schema_exports.invoices.invoiceNumber,
|
|
114306
|
+
status: schema_exports.invoices.status,
|
|
114307
|
+
teamId: schema_exports.invoices.teamId,
|
|
114308
|
+
customerId: schema_exports.invoices.customerId,
|
|
114309
|
+
customerName: schema_exports.invoices.customerName,
|
|
114310
|
+
amount: schema_exports.invoices.amount,
|
|
114311
|
+
currency: schema_exports.invoices.currency,
|
|
114312
|
+
issueDate: schema_exports.invoices.issueDate,
|
|
114313
|
+
dueDate: schema_exports.invoices.dueDate,
|
|
114314
|
+
createdAt: schema_exports.invoices.createdAt
|
|
114315
|
+
}).from(schema_exports.invoices).where(and(...filters)).orderBy(desc(schema_exports.invoices.createdAt)).limit(Math.min(pageSize, 100));
|
|
114316
|
+
if (rows.length === 0) {
|
|
114317
|
+
return textResponse2("No invoices found.");
|
|
114318
|
+
}
|
|
114319
|
+
const list = rows.map(
|
|
114320
|
+
(inv) => `**${inv.invoiceNumber ?? "(draft, no number)"}**
|
|
114321
|
+
ID: ${inv.id}
|
|
114322
|
+
Status: ${inv.status} | Amount: ${inv.amount ?? "?"} ${inv.currency ?? ""}
|
|
114323
|
+
Customer: ${inv.customerName ?? inv.customerId ?? "(none)"}
|
|
114324
|
+
Issue date: ${inv.issueDate ? new Date(inv.issueDate).toLocaleDateString() : "-"} | Due: ${inv.dueDate ? new Date(inv.dueDate).toLocaleDateString() : "-"}
|
|
114325
|
+
`
|
|
114326
|
+
).join("\n");
|
|
114327
|
+
return textResponse2(
|
|
114328
|
+
`Found ${rows.length} invoices:
|
|
114329
|
+
|
|
114330
|
+
${list}
|
|
114331
|
+
Use \`get-invoice-by-id\` for line items and linked documents. Use \`link-document-to-invoice\` (or \`invoiceId\` on create-document) to attach deliverables.`
|
|
114332
|
+
);
|
|
114333
|
+
}
|
|
114334
|
+
async function handleGetInvoiceById(input) {
|
|
114335
|
+
const { invoiceId } = input;
|
|
114336
|
+
if (!invoiceId) return textResponse2("Error: `invoiceId` is required.");
|
|
114337
|
+
const scope = await resolveTeamScope(input.teamId);
|
|
114338
|
+
if (!scope.ok) return scope.response;
|
|
114339
|
+
if (scope.teamIds.length === 0) {
|
|
114340
|
+
return textResponse2("No accessible teams found.");
|
|
114341
|
+
}
|
|
114342
|
+
const invoice = await loadInvoiceByIdentifier(invoiceId, scope.teamIds);
|
|
114343
|
+
if (!invoice) {
|
|
114344
|
+
return textResponse2(
|
|
114345
|
+
`Invoice ${invoiceId} not found or you don't have access to it.`
|
|
114346
|
+
);
|
|
114347
|
+
}
|
|
114348
|
+
const lineItems = parseStoredLineItems(invoice.lineItems);
|
|
114349
|
+
const linkedDocs = await db.select({
|
|
114350
|
+
id: schema_exports.documents.id,
|
|
114351
|
+
title: schema_exports.documents.title,
|
|
114352
|
+
type: schema_exports.documents.type
|
|
114353
|
+
}).from(schema_exports.documents).where(
|
|
114354
|
+
and(
|
|
114355
|
+
eq(schema_exports.documents.invoiceId, invoice.id),
|
|
114356
|
+
isNull(schema_exports.documents.deletedAt)
|
|
114357
|
+
)
|
|
114358
|
+
);
|
|
114359
|
+
const linesText = lineItems.length > 0 ? lineItems.map((line2, i6) => formatLineItemDetail(line2, i6)).join("\n\n") : "(no line items)";
|
|
114360
|
+
const docsText = linkedDocs.length > 0 ? linkedDocs.map((d6) => `- ${d6.title} (${d6.type ?? "document"}) \u2014 ${d6.id}`).join("\n") : "(none)";
|
|
114361
|
+
return textResponse2(
|
|
114362
|
+
`**Invoice ${invoice.invoiceNumber ?? invoice.id}**
|
|
114363
|
+
|
|
114364
|
+
ID: ${invoice.id}
|
|
114365
|
+
Status: ${invoice.status}
|
|
114366
|
+
Customer: ${invoice.customerName ?? invoice.customerId ?? "(none)"}
|
|
114367
|
+
Currency: ${invoice.currency ?? "EUR"}
|
|
114368
|
+
Issue date: ${invoice.issueDate ? new Date(invoice.issueDate).toLocaleDateString() : "-"}
|
|
114369
|
+
Due date: ${invoice.dueDate ? new Date(invoice.dueDate).toLocaleDateString() : "-"}
|
|
114370
|
+
Subtotal: ${invoice.subtotal ?? "?"} | VAT: ${invoice.vat ?? 0} | Tax: ${invoice.tax ?? 0} | Total: ${invoice.amount ?? "?"}
|
|
114371
|
+
|
|
114372
|
+
**Line items (${lineItems.length})**
|
|
114373
|
+
${linesText}
|
|
114374
|
+
|
|
114375
|
+
**Linked documents (${linkedDocs.length})**
|
|
114376
|
+
${docsText}
|
|
114377
|
+
|
|
114378
|
+
` + (invoice.status === "draft" ? "This invoice is a draft \u2014 use `update-invoice-lines` to compact descriptions or `add-product-to-invoice` to add catalog products." : "This invoice is not a draft \u2014 line items are read-only via MCP.")
|
|
114379
|
+
);
|
|
114380
|
+
}
|
|
114381
|
+
async function handleUpdateInvoice(input) {
|
|
114382
|
+
const { invoiceId } = input;
|
|
114383
|
+
if (!invoiceId) return textResponse2("Error: `invoiceId` is required.");
|
|
114384
|
+
const resolved = await resolveTeamId(input.teamId);
|
|
114385
|
+
if (!resolved.ok) return resolved.response;
|
|
114386
|
+
const invoice = await loadInvoiceInTeam(invoiceId, resolved.teamId);
|
|
114387
|
+
if (!invoice) {
|
|
114388
|
+
return textResponse2(
|
|
114389
|
+
`Invoice ${invoiceId} not found or not owned by this team.`
|
|
114390
|
+
);
|
|
114391
|
+
}
|
|
114392
|
+
if (invoice.status !== "draft") return notDraftResponse(invoice);
|
|
114393
|
+
const defaults = templateDefaultsFromInvoice(
|
|
114394
|
+
invoice.template,
|
|
114395
|
+
invoice.currency
|
|
114396
|
+
);
|
|
114397
|
+
const updates = {};
|
|
114398
|
+
if (input.title !== void 0) {
|
|
114399
|
+
updates.template = { ...defaults.raw, title: input.title };
|
|
114400
|
+
}
|
|
114401
|
+
if (input.note !== void 0) {
|
|
114402
|
+
updates.noteDetails = input.note ? tiptapNote(input.note) : null;
|
|
114403
|
+
}
|
|
114404
|
+
if (input.internalNote !== void 0) {
|
|
114405
|
+
updates.internalNote = input.internalNote;
|
|
114406
|
+
}
|
|
114407
|
+
if (input.dueDate !== void 0) updates.dueDate = input.dueDate;
|
|
114408
|
+
if (input.issueDate !== void 0) updates.issueDate = input.issueDate;
|
|
114409
|
+
if (input.lineItems !== void 0) {
|
|
114410
|
+
const { items, error: error49 } = await resolveInvoiceLineItems(
|
|
114411
|
+
input.lineItems,
|
|
114412
|
+
defaults,
|
|
114413
|
+
invoice.teamId
|
|
114414
|
+
);
|
|
114415
|
+
if (error49) return textResponse2(`Error: ${error49}`);
|
|
114416
|
+
const totals = computeInvoiceTotals(
|
|
114417
|
+
items,
|
|
114418
|
+
defaults,
|
|
114419
|
+
invoice.discount ?? 0
|
|
114420
|
+
);
|
|
114421
|
+
updates.lineItems = items;
|
|
114422
|
+
updates.subtotal = totals.subtotal;
|
|
114423
|
+
updates.vat = totals.vat;
|
|
114424
|
+
updates.tax = totals.tax;
|
|
114425
|
+
updates.amount = totals.amount;
|
|
114426
|
+
}
|
|
114427
|
+
if (Object.keys(updates).length === 0) {
|
|
114428
|
+
return textResponse2(
|
|
114429
|
+
"No fields to update. Provide at least one of: title, note, internalNote, dueDate, issueDate, lineItems."
|
|
114430
|
+
);
|
|
114431
|
+
}
|
|
114432
|
+
updates.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
114433
|
+
const [updated] = await db.update(schema_exports.invoices).set(updates).where(eq(schema_exports.invoices.id, invoice.id)).returning(INVOICE_DETAIL_COLUMNS);
|
|
114434
|
+
if (!updated) return textResponse2(`Failed to update invoice ${invoiceId}.`);
|
|
114435
|
+
return textResponse2(`\u2705 **Draft invoice updated**
|
|
114436
|
+
|
|
114437
|
+
${formatInvoiceSummary(updated)}`);
|
|
114438
|
+
}
|
|
114439
|
+
async function handleUpdateInvoiceLines(input) {
|
|
114440
|
+
const { invoiceId, lineItems: patches } = input;
|
|
114441
|
+
if (!invoiceId) return textResponse2("Error: `invoiceId` is required.");
|
|
114442
|
+
if (!patches || patches.length === 0) {
|
|
114443
|
+
return textResponse2("Error: `lineItems` must be a non-empty array.");
|
|
114444
|
+
}
|
|
114445
|
+
const resolved = await resolveTeamId(input.teamId);
|
|
114446
|
+
if (!resolved.ok) return resolved.response;
|
|
114447
|
+
const invoice = await loadInvoiceInTeam(invoiceId, resolved.teamId);
|
|
114448
|
+
if (!invoice) {
|
|
114449
|
+
return textResponse2(
|
|
114450
|
+
`Invoice ${invoiceId} not found or not owned by this team.`
|
|
114451
|
+
);
|
|
114452
|
+
}
|
|
114453
|
+
if (invoice.status !== "draft") return notDraftResponse(invoice);
|
|
114454
|
+
const defaults = templateDefaultsFromInvoice(
|
|
114455
|
+
invoice.template,
|
|
114456
|
+
invoice.currency
|
|
114457
|
+
);
|
|
114458
|
+
const items = [...parseStoredLineItems(invoice.lineItems)];
|
|
114459
|
+
let updatedCount = 0;
|
|
114460
|
+
for (const patch of patches) {
|
|
114461
|
+
const index2 = patch.index;
|
|
114462
|
+
if (index2 < 0 || index2 >= items.length) {
|
|
114463
|
+
return textResponse2(
|
|
114464
|
+
`Error: line index ${index2} is out of range (invoice has ${items.length} line(s)).`
|
|
114465
|
+
);
|
|
114466
|
+
}
|
|
114467
|
+
const line2 = { ...items[index2] };
|
|
114468
|
+
if (patch.description !== void 0) line2.name = patch.description;
|
|
114469
|
+
if (patch.quantity !== void 0) line2.quantity = patch.quantity;
|
|
114470
|
+
if (patch.unit !== void 0) line2.unit = patch.unit ?? void 0;
|
|
114471
|
+
if (patch.price !== void 0) line2.price = patch.price;
|
|
114472
|
+
const qty = line2.quantity ?? 1;
|
|
114473
|
+
const price = line2.price ?? 0;
|
|
114474
|
+
const { vat, tax } = lineFinancials(qty, price, defaults);
|
|
114475
|
+
line2.vat = vat;
|
|
114476
|
+
line2.tax = tax;
|
|
114477
|
+
items[index2] = line2;
|
|
114478
|
+
updatedCount++;
|
|
114479
|
+
}
|
|
114480
|
+
const totals = computeInvoiceTotals(items, defaults, invoice.discount ?? 0);
|
|
114481
|
+
const [updated] = await db.update(schema_exports.invoices).set({
|
|
114482
|
+
lineItems: items,
|
|
114483
|
+
subtotal: totals.subtotal,
|
|
114484
|
+
vat: totals.vat,
|
|
114485
|
+
tax: totals.tax,
|
|
114486
|
+
amount: totals.amount,
|
|
114487
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
114488
|
+
}).where(eq(schema_exports.invoices.id, invoice.id)).returning(INVOICE_DETAIL_COLUMNS);
|
|
114489
|
+
if (!updated) {
|
|
114490
|
+
return textResponse2(`Failed to update invoice lines for ${invoiceId}.`);
|
|
114491
|
+
}
|
|
114492
|
+
const changedLines = patches.map((p3) => {
|
|
114493
|
+
const line2 = items[p3.index];
|
|
114494
|
+
return `[${p3.index}] ${plainTextFromLineItemName(line2.name)}`;
|
|
114495
|
+
}).join("\n");
|
|
114496
|
+
return textResponse2(
|
|
114497
|
+
`\u2705 **Updated ${updatedCount} line item(s) on draft invoice ${updated.invoiceNumber ?? updated.id}**
|
|
114498
|
+
|
|
114499
|
+
${changedLines}
|
|
114500
|
+
|
|
114501
|
+
New total: ${updated.amount} ${updated.currency} (subtotal ${updated.subtotal}, VAT ${updated.vat})`
|
|
114502
|
+
);
|
|
114503
|
+
}
|
|
114504
|
+
async function handleAddProductToInvoice(input) {
|
|
114505
|
+
const { invoiceId, productId } = input;
|
|
114506
|
+
if (!invoiceId) return textResponse2("Error: `invoiceId` is required.");
|
|
114507
|
+
if (!productId) return textResponse2("Error: `productId` is required.");
|
|
114508
|
+
const resolved = await resolveTeamId(input.teamId);
|
|
114509
|
+
if (!resolved.ok) return resolved.response;
|
|
114510
|
+
const invoice = await loadInvoiceInTeam(invoiceId, resolved.teamId);
|
|
114511
|
+
if (!invoice) {
|
|
114512
|
+
return textResponse2(
|
|
114513
|
+
`Invoice ${invoiceId} not found or not owned by this team.`
|
|
114514
|
+
);
|
|
114515
|
+
}
|
|
114516
|
+
if (invoice.status !== "draft") return notDraftResponse(invoice);
|
|
114517
|
+
const products = await loadProductsInTeam([productId], invoice.teamId);
|
|
114518
|
+
const product = products.get(productId);
|
|
114519
|
+
if (!product) {
|
|
114520
|
+
return textResponse2(
|
|
114521
|
+
`Product ${productId} not found or not owned by this team.`
|
|
114522
|
+
);
|
|
114523
|
+
}
|
|
114524
|
+
const defaults = templateDefaultsFromInvoice(
|
|
114525
|
+
invoice.template,
|
|
114526
|
+
invoice.currency
|
|
114527
|
+
);
|
|
114528
|
+
const newItem = buildInvoiceLineFromProduct(
|
|
114529
|
+
product,
|
|
114530
|
+
{
|
|
114531
|
+
quantity: input.quantity,
|
|
114532
|
+
customDescription: input.customDescription,
|
|
114533
|
+
customPrice: input.customPrice,
|
|
114534
|
+
pricingOptionId: input.pricingOptionId,
|
|
114535
|
+
prorate: input.prorate,
|
|
114536
|
+
startDate: input.startDate,
|
|
114537
|
+
periodEndDate: input.periodEndDate
|
|
114538
|
+
},
|
|
114539
|
+
defaults
|
|
114540
|
+
);
|
|
114541
|
+
const items = [...parseStoredLineItems(invoice.lineItems), newItem];
|
|
114542
|
+
const totals = computeInvoiceTotals(items, defaults, invoice.discount ?? 0);
|
|
114543
|
+
const [updated] = await db.update(schema_exports.invoices).set({
|
|
114544
|
+
lineItems: items,
|
|
114545
|
+
subtotal: totals.subtotal,
|
|
114546
|
+
vat: totals.vat,
|
|
114547
|
+
tax: totals.tax,
|
|
114548
|
+
amount: totals.amount,
|
|
114549
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
114550
|
+
}).where(eq(schema_exports.invoices.id, invoice.id)).returning(INVOICE_DETAIL_COLUMNS);
|
|
114551
|
+
if (!updated) {
|
|
114552
|
+
return textResponse2(`Failed to add product to invoice ${invoiceId}.`);
|
|
114553
|
+
}
|
|
114554
|
+
return textResponse2(
|
|
114555
|
+
`\u2705 **Product added to draft invoice ${updated.invoiceNumber ?? updated.id}**
|
|
114556
|
+
|
|
114557
|
+
` + formatLineItemDetail(newItem, items.length - 1) + `
|
|
114558
|
+
|
|
114559
|
+
New invoice total: ${updated.amount} ${updated.currency} (subtotal ${updated.subtotal}, VAT ${updated.vat})
|
|
114560
|
+
Clause and pricing variant are snapshotted on the line \u2014 later catalog edits won't change this invoice.`
|
|
114561
|
+
);
|
|
114562
|
+
}
|
|
114563
|
+
async function handleLinkDocumentToInvoice(input) {
|
|
114564
|
+
const { documentId, invoiceId } = input;
|
|
114565
|
+
if (!documentId) {
|
|
114566
|
+
return textResponse2("Error: `documentId` is required.");
|
|
114567
|
+
}
|
|
114568
|
+
const scope = await resolveTeamScope(input.teamId);
|
|
114569
|
+
if (!scope.ok) return scope.response;
|
|
114570
|
+
if (scope.teamIds.length === 0) {
|
|
114571
|
+
return textResponse2("No accessible teams found.");
|
|
114572
|
+
}
|
|
114573
|
+
const [doc] = await db.select({
|
|
114574
|
+
id: schema_exports.documents.id,
|
|
114575
|
+
title: schema_exports.documents.title,
|
|
114576
|
+
teamId: schema_exports.documents.teamId,
|
|
114577
|
+
invoiceId: schema_exports.documents.invoiceId
|
|
114578
|
+
}).from(schema_exports.documents).where(
|
|
114579
|
+
and(
|
|
114580
|
+
eq(schema_exports.documents.id, documentId),
|
|
114581
|
+
inArray(schema_exports.documents.teamId, scope.teamIds),
|
|
114582
|
+
isNull(schema_exports.documents.deletedAt)
|
|
114583
|
+
)
|
|
114584
|
+
).limit(1);
|
|
114585
|
+
if (!doc) {
|
|
114586
|
+
return textResponse2(
|
|
114587
|
+
`Document ${documentId} not found or you don't have access to it.`
|
|
114588
|
+
);
|
|
114589
|
+
}
|
|
114590
|
+
if (!invoiceId) {
|
|
114591
|
+
await db.update(schema_exports.documents).set({ invoiceId: null, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq(schema_exports.documents.id, doc.id));
|
|
114592
|
+
return textResponse2(
|
|
114593
|
+
`\u2705 Document "${doc.title}" (${doc.id}) is unlinked from its invoice.`
|
|
114594
|
+
);
|
|
113669
114595
|
}
|
|
113670
114596
|
const invoice = await findAccessibleInvoice(invoiceId, [doc.teamId]);
|
|
113671
114597
|
if (!invoice) {
|
|
113672
|
-
return
|
|
113673
|
-
|
|
113674
|
-
|
|
113675
|
-
type: "text",
|
|
113676
|
-
text: `Error: invoice ${invoiceId} not found in team ${doc.teamId}. Use get-invoices to find a valid invoice id.`
|
|
113677
|
-
}
|
|
113678
|
-
]
|
|
113679
|
-
};
|
|
114598
|
+
return textResponse2(
|
|
114599
|
+
`Error: invoice ${invoiceId} not found in team ${doc.teamId}. Use get-invoices to find a valid invoice id.`
|
|
114600
|
+
);
|
|
113680
114601
|
}
|
|
113681
114602
|
await db.update(schema_exports.documents).set({ invoiceId, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq(schema_exports.documents.id, doc.id));
|
|
113682
|
-
return
|
|
113683
|
-
|
|
113684
|
-
{
|
|
113685
|
-
type: "text",
|
|
113686
|
-
text: `\u2705 **Document linked to invoice!**
|
|
114603
|
+
return textResponse2(
|
|
114604
|
+
`\u2705 **Document linked to invoice!**
|
|
113687
114605
|
|
|
113688
114606
|
Document: ${doc.title} (${doc.id})
|
|
113689
114607
|
Invoice: ${invoice.invoiceNumber ?? invoice.id} (${invoice.status})
|
|
113690
114608
|
|
|
113691
114609
|
The document can now be selected as a PDF attachment when sending this invoice from the dashboard.`
|
|
113692
|
-
|
|
113693
|
-
]
|
|
113694
|
-
};
|
|
114610
|
+
);
|
|
113695
114611
|
}
|
|
113696
114612
|
|
|
113697
114613
|
// src/tools/project-cleanup-util.ts
|
|
@@ -113817,7 +114733,7 @@ ${description ? `Description: ${description}
|
|
|
113817
114733
|
]
|
|
113818
114734
|
};
|
|
113819
114735
|
}
|
|
113820
|
-
function
|
|
114736
|
+
function textResponse3(text3) {
|
|
113821
114737
|
return { content: [{ type: "text", text: text3 }] };
|
|
113822
114738
|
}
|
|
113823
114739
|
function memberLabel(m4) {
|
|
@@ -113831,7 +114747,7 @@ async function requireTeamOwner2(teamId, userId) {
|
|
|
113831
114747
|
eq(schema_exports.usersOnTeam.teamId, teamId)
|
|
113832
114748
|
)
|
|
113833
114749
|
).limit(1);
|
|
113834
|
-
return membership?.role === "owner" ? null :
|
|
114750
|
+
return membership?.role === "owner" ? null : textResponse3(OWNER_REQUIRED);
|
|
113835
114751
|
}
|
|
113836
114752
|
async function setProjectMemberAccess(params) {
|
|
113837
114753
|
const { projectId, teamId, memberIds, createdBy } = params;
|
|
@@ -113935,7 +114851,7 @@ async function resolveTeamMember(teamId, opts) {
|
|
|
113935
114851
|
if (!match) {
|
|
113936
114852
|
return {
|
|
113937
114853
|
ok: false,
|
|
113938
|
-
response:
|
|
114854
|
+
response: textResponse3(
|
|
113939
114855
|
`User ${opts.userId} is not a member of this team. Call get-project-members to see the team roster.`
|
|
113940
114856
|
)
|
|
113941
114857
|
};
|
|
@@ -113948,7 +114864,7 @@ async function resolveTeamMember(teamId, opts) {
|
|
|
113948
114864
|
if (matches.length === 0) {
|
|
113949
114865
|
return {
|
|
113950
114866
|
ok: false,
|
|
113951
|
-
response:
|
|
114867
|
+
response: textResponse3(
|
|
113952
114868
|
`No team member found with email "${opts.email}". Call get-project-members to see the team roster.`
|
|
113953
114869
|
)
|
|
113954
114870
|
};
|
|
@@ -113956,7 +114872,7 @@ async function resolveTeamMember(teamId, opts) {
|
|
|
113956
114872
|
if (matches.length > 1) {
|
|
113957
114873
|
return {
|
|
113958
114874
|
ok: false,
|
|
113959
|
-
response:
|
|
114875
|
+
response: textResponse3(
|
|
113960
114876
|
`Multiple team members match email "${opts.email}". Pass an explicit userId instead.`
|
|
113961
114877
|
)
|
|
113962
114878
|
};
|
|
@@ -113965,7 +114881,7 @@ async function resolveTeamMember(teamId, opts) {
|
|
|
113965
114881
|
}
|
|
113966
114882
|
return {
|
|
113967
114883
|
ok: false,
|
|
113968
|
-
response:
|
|
114884
|
+
response: textResponse3(
|
|
113969
114885
|
"Provide either a userId or an email to identify the member."
|
|
113970
114886
|
)
|
|
113971
114887
|
};
|
|
@@ -114014,7 +114930,7 @@ async function handleUpdateProject(input) {
|
|
|
114014
114930
|
if (!resolved.ok) return resolved.response;
|
|
114015
114931
|
const existing = await loadProjectInTeam(id, resolved.teamId);
|
|
114016
114932
|
if (!existing) {
|
|
114017
|
-
return
|
|
114933
|
+
return textResponse3(
|
|
114018
114934
|
`Project ${id} not found, or it is not owned by this team.`
|
|
114019
114935
|
);
|
|
114020
114936
|
}
|
|
@@ -114029,7 +114945,7 @@ async function handleUpdateProject(input) {
|
|
|
114029
114945
|
)
|
|
114030
114946
|
).limit(1);
|
|
114031
114947
|
if (dupe) {
|
|
114032
|
-
return
|
|
114948
|
+
return textResponse3(
|
|
114033
114949
|
`A project named "${input.name}" already exists in this team. Choose a different name.`
|
|
114034
114950
|
);
|
|
114035
114951
|
}
|
|
@@ -114094,7 +115010,7 @@ async function handleUpdateProject(input) {
|
|
|
114094
115010
|
customerName: schema_exports.customers.name
|
|
114095
115011
|
}).from(schema_exports.projects).leftJoin(schema_exports.customers, eq(schema_exports.projects.customerId, schema_exports.customers.id)).where(eq(schema_exports.projects.id, id)).limit(1);
|
|
114096
115012
|
if (!updated) {
|
|
114097
|
-
return
|
|
115013
|
+
return textResponse3(`Failed to update project ${id}.`);
|
|
114098
115014
|
}
|
|
114099
115015
|
const lines = [
|
|
114100
115016
|
"\u2705 **Project Updated**",
|
|
@@ -114112,7 +115028,7 @@ async function handleUpdateProject(input) {
|
|
|
114112
115028
|
if (willRename) {
|
|
114113
115029
|
lines.push("", "Note: tickets for this project were renumbered.");
|
|
114114
115030
|
}
|
|
114115
|
-
return
|
|
115031
|
+
return textResponse3(lines.join("\n"));
|
|
114116
115032
|
}
|
|
114117
115033
|
async function handleGetProjectMembers(input) {
|
|
114118
115034
|
const { projectId } = input;
|
|
@@ -114120,7 +115036,7 @@ async function handleGetProjectMembers(input) {
|
|
|
114120
115036
|
if (!resolved.ok) return resolved.response;
|
|
114121
115037
|
const project = await loadProjectInTeam(projectId, resolved.teamId);
|
|
114122
115038
|
if (!project) {
|
|
114123
|
-
return
|
|
115039
|
+
return textResponse3(
|
|
114124
115040
|
`Project ${projectId} not found, or it is not owned by this team.`
|
|
114125
115041
|
);
|
|
114126
115042
|
}
|
|
@@ -114149,7 +115065,7 @@ async function handleGetProjectMembers(input) {
|
|
|
114149
115065
|
return `- ${memberLabel(m4)} (userId: ${m4.userId}, role: ${m4.role ?? "member"}) \u2014 ${access}`;
|
|
114150
115066
|
}).join("\n");
|
|
114151
115067
|
const note = state2.projectMemberIds.size === 0 ? "No members are explicitly assigned to this project, so every owner and every unrestricted member can see it." : `${state2.projectMemberIds.size} member(s) are explicitly assigned to this project.`;
|
|
114152
|
-
return
|
|
115068
|
+
return textResponse3(
|
|
114153
115069
|
`**Project members for "${project.name}"** (ID: ${project.id})
|
|
114154
115070
|
|
|
114155
115071
|
${note}
|
|
@@ -114170,7 +115086,7 @@ async function handleSetProjectMembers(input) {
|
|
|
114170
115086
|
if (ownerError) return ownerError;
|
|
114171
115087
|
const project = await loadProjectInTeam(projectId, resolved.teamId);
|
|
114172
115088
|
if (!project) {
|
|
114173
|
-
return
|
|
115089
|
+
return textResponse3(
|
|
114174
115090
|
`Project ${projectId} not found, or it is not owned by this team.`
|
|
114175
115091
|
);
|
|
114176
115092
|
}
|
|
@@ -114208,7 +115124,7 @@ async function handleSetProjectMembers(input) {
|
|
|
114208
115124
|
|
|
114209
115125
|
\u26A0\uFE0F ${names} previously had no restrictions (could see all projects). They are now restricted to only the projects explicitly assigned to them.`;
|
|
114210
115126
|
}
|
|
114211
|
-
return
|
|
115127
|
+
return textResponse3(
|
|
114212
115128
|
`\u2705 **Project members updated**
|
|
114213
115129
|
|
|
114214
115130
|
Members with explicit access to this project:
|
|
@@ -114224,7 +115140,7 @@ async function handleAddProjectMember(input) {
|
|
|
114224
115140
|
if (ownerError) return ownerError;
|
|
114225
115141
|
const project = await loadProjectInTeam(projectId, resolved.teamId);
|
|
114226
115142
|
if (!project) {
|
|
114227
|
-
return
|
|
115143
|
+
return textResponse3(
|
|
114228
115144
|
`Project ${projectId} not found, or it is not owned by this team.`
|
|
114229
115145
|
);
|
|
114230
115146
|
}
|
|
@@ -114235,7 +115151,7 @@ async function handleAddProjectMember(input) {
|
|
|
114235
115151
|
if (!member2.ok) return member2.response;
|
|
114236
115152
|
const state2 = await getProjectAccessState(resolved.teamId, projectId);
|
|
114237
115153
|
if (state2.projectMemberIds.has(member2.member.userId)) {
|
|
114238
|
-
return
|
|
115154
|
+
return textResponse3(
|
|
114239
115155
|
`${memberLabel(member2.member)} already has explicit access to this project.`
|
|
114240
115156
|
);
|
|
114241
115157
|
}
|
|
@@ -114250,7 +115166,7 @@ async function handleAddProjectMember(input) {
|
|
|
114250
115166
|
if (wasUnrestricted) {
|
|
114251
115167
|
text3 += "\n\n\u26A0\uFE0F This member previously had no access restrictions (they could see all projects). They are now restricted to ONLY the projects explicitly assigned to them. Grant any other projects they still need with add-project-member, or remove all their assignments to restore full visibility.";
|
|
114252
115168
|
}
|
|
114253
|
-
return
|
|
115169
|
+
return textResponse3(text3);
|
|
114254
115170
|
}
|
|
114255
115171
|
async function handleRemoveProjectMember(input) {
|
|
114256
115172
|
const ctx = getAuthContext();
|
|
@@ -114261,7 +115177,7 @@ async function handleRemoveProjectMember(input) {
|
|
|
114261
115177
|
if (ownerError) return ownerError;
|
|
114262
115178
|
const project = await loadProjectInTeam(projectId, resolved.teamId);
|
|
114263
115179
|
if (!project) {
|
|
114264
|
-
return
|
|
115180
|
+
return textResponse3(
|
|
114265
115181
|
`Project ${projectId} not found, or it is not owned by this team.`
|
|
114266
115182
|
);
|
|
114267
115183
|
}
|
|
@@ -114272,297 +115188,125 @@ async function handleRemoveProjectMember(input) {
|
|
|
114272
115188
|
if (!member2.ok) return member2.response;
|
|
114273
115189
|
const state2 = await getProjectAccessState(resolved.teamId, projectId);
|
|
114274
115190
|
if (!state2.projectMemberIds.has(member2.member.userId)) {
|
|
114275
|
-
return
|
|
115191
|
+
return textResponse3(
|
|
114276
115192
|
`${memberLabel(member2.member)} has no explicit assignment to this project; nothing to remove.`
|
|
114277
|
-
);
|
|
114278
|
-
}
|
|
114279
|
-
await setProjectMemberAccess({
|
|
114280
|
-
projectId,
|
|
114281
|
-
teamId: resolved.teamId,
|
|
114282
|
-
memberIds: [...state2.projectMemberIds].filter(
|
|
114283
|
-
(uid2) => uid2 !== member2.member.userId
|
|
114284
|
-
),
|
|
114285
|
-
createdBy: ctx.userId
|
|
114286
|
-
});
|
|
114287
|
-
let text3 = `\u2705 Removed ${memberLabel(member2.member)} (userId: ${member2.member.userId}) from the project.`;
|
|
114288
|
-
if ((state2.rowCountByUser.get(member2.member.userId) ?? 0) <= 1) {
|
|
114289
|
-
text3 += "\n\nThis was the member's last project assignment, so their access restrictions were cleared \u2014 they can see all projects in the team again (default behavior).";
|
|
114290
|
-
}
|
|
114291
|
-
return textResponse2(text3);
|
|
114292
|
-
}
|
|
114293
|
-
async function loadProjectForCleanup(projectId, teamId) {
|
|
114294
|
-
const accessibleTeamIds = await getAccessibleTeamIds(teamId);
|
|
114295
|
-
const [row] = await db.select({
|
|
114296
|
-
id: schema_exports.projects.id,
|
|
114297
|
-
name: schema_exports.projects.name,
|
|
114298
|
-
teamId: schema_exports.projects.teamId,
|
|
114299
|
-
settings: schema_exports.projects.settings
|
|
114300
|
-
}).from(schema_exports.projects).where(eq(schema_exports.projects.id, projectId)).limit(1);
|
|
114301
|
-
if (!row || !row.teamId || !accessibleTeamIds.includes(row.teamId)) {
|
|
114302
|
-
return null;
|
|
114303
|
-
}
|
|
114304
|
-
return { id: row.id, name: row.name, teamId: row.teamId, settings: row.settings };
|
|
114305
|
-
}
|
|
114306
|
-
async function countProjectDependencies(projectId) {
|
|
114307
|
-
const countRows = (table) => db.select({ c: sql`count(*)::int` }).from(table).where(eq(table.projectId, projectId)).then((r6) => r6[0]?.c ?? 0);
|
|
114308
|
-
const [tickets3, timesheetEvents2, timesheetTemplates2, trips2, tripTemplates2] = await Promise.all([
|
|
114309
|
-
countRows(schema_exports.tickets),
|
|
114310
|
-
countRows(schema_exports.timesheetEvents),
|
|
114311
|
-
countRows(schema_exports.timesheetTemplates),
|
|
114312
|
-
countRows(schema_exports.trips),
|
|
114313
|
-
countRows(schema_exports.tripTemplates)
|
|
114314
|
-
]);
|
|
114315
|
-
return { tickets: tickets3, timesheetEvents: timesheetEvents2, timesheetTemplates: timesheetTemplates2, trips: trips2, tripTemplates: tripTemplates2 };
|
|
114316
|
-
}
|
|
114317
|
-
async function handleArchiveProject(input) {
|
|
114318
|
-
const { projectId, reason } = input;
|
|
114319
|
-
if (!projectId) return textResponse2("Error: `projectId` is required.");
|
|
114320
|
-
const resolved = await resolveTeamId(input.teamId);
|
|
114321
|
-
if (!resolved.ok) return resolved.response;
|
|
114322
|
-
const project = await loadProjectForCleanup(projectId, resolved.teamId);
|
|
114323
|
-
if (!project) {
|
|
114324
|
-
return textResponse2(
|
|
114325
|
-
`Project ${projectId} not found, or it is not owned by this team.`
|
|
114326
|
-
);
|
|
114327
|
-
}
|
|
114328
|
-
const state2 = getProjectArchiveState(project.settings);
|
|
114329
|
-
if (state2.archived) {
|
|
114330
|
-
return textResponse2(
|
|
114331
|
-
`Project "${project.name}" (${project.id}) is already archived${state2.archivedAt ? ` (since ${state2.archivedAt})` : ""}.`
|
|
114332
|
-
);
|
|
114333
|
-
}
|
|
114334
|
-
const archivedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
114335
|
-
const nextSettings = withArchiveSettings(project.settings, archivedAt, reason);
|
|
114336
|
-
await db.update(schema_exports.projects).set({ settings: nextSettings, updatedAt: sql`now()` }).where(eq(schema_exports.projects.id, project.id));
|
|
114337
|
-
return textResponse2(
|
|
114338
|
-
`\u2705 **Project archived**
|
|
114339
|
-
|
|
114340
|
-
Project: ${project.name}
|
|
114341
|
-
ID: ${project.id}
|
|
114342
|
-
Action: archived (soft, reversible)
|
|
114343
|
-
Status: archived
|
|
114344
|
-
Timestamp: ${archivedAt}
|
|
114345
|
-
${reason ? `Reason: ${reason}
|
|
114346
|
-
` : ""}
|
|
114347
|
-
Archived projects are hidden from get-projects by default (pass status: 'archived' or 'all' to see them). No tickets, hours, or other data were touched.
|
|
114348
|
-
|
|
114349
|
-
Note: the archive flag is stored in \`projects.settings.archivedAt\`; the dashboard UI does not yet read it, so the project still appears there.`
|
|
114350
|
-
);
|
|
114351
|
-
}
|
|
114352
|
-
async function handleDeleteProject(input) {
|
|
114353
|
-
const ctx = getAuthContext();
|
|
114354
|
-
const { projectId, confirmEmptyOnly } = input;
|
|
114355
|
-
if (!projectId) return textResponse2("Error: `projectId` is required.");
|
|
114356
|
-
const resolved = await resolveTeamId(input.teamId);
|
|
114357
|
-
if (!resolved.ok) return resolved.response;
|
|
114358
|
-
const ownerError = await requireTeamOwner2(resolved.teamId, ctx.userId);
|
|
114359
|
-
if (ownerError) return ownerError;
|
|
114360
|
-
const project = await loadProjectForCleanup(projectId, resolved.teamId);
|
|
114361
|
-
if (!project) {
|
|
114362
|
-
return textResponse2(
|
|
114363
|
-
`Project ${projectId} not found, or it is not owned by this team.`
|
|
114364
|
-
);
|
|
114365
|
-
}
|
|
114366
|
-
const deps = await countProjectDependencies(project.id);
|
|
114367
|
-
const summary = formatProjectDependencies(deps);
|
|
114368
|
-
if (!isProjectEmpty(deps)) {
|
|
114369
|
-
return textResponse2(
|
|
114370
|
-
`\u{1F6AB} **Delete blocked** \u2014 project "${project.name}" (${project.id}) is not empty.
|
|
114371
|
-
|
|
114372
|
-
Dependencies: ${summary}.
|
|
114373
|
-
|
|
114374
|
-
A hard delete would orphan these records, so it is not allowed. Use archive-project instead to safely retire this project (reversible, keeps all data).`
|
|
114375
|
-
);
|
|
114376
|
-
}
|
|
114377
|
-
if (confirmEmptyOnly !== true) {
|
|
114378
|
-
return textResponse2(
|
|
114379
|
-
`Project "${project.name}" (${project.id}) has no dependencies and can be safely deleted. This is a permanent hard delete. Re-run delete-project with confirmEmptyOnly: true to proceed (or use archive-project to keep the record).`
|
|
114380
|
-
);
|
|
114381
|
-
}
|
|
114382
|
-
await db.delete(schema_exports.projects).where(eq(schema_exports.projects.id, project.id));
|
|
114383
|
-
return textResponse2(
|
|
114384
|
-
`\u2705 **Project deleted**
|
|
114385
|
-
|
|
114386
|
-
Project: ${project.name}
|
|
114387
|
-
ID: ${project.id}
|
|
114388
|
-
Action: hard delete (empty project)
|
|
114389
|
-
Status: deleted
|
|
114390
|
-
Timestamp: ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
114391
|
-
|
|
114392
|
-
The project had no tickets, hours, trips, or templates. Any project-scoped config (member access, tags, slack/github links, team rates) was removed with it.`
|
|
114393
|
-
);
|
|
114394
|
-
}
|
|
114395
|
-
|
|
114396
|
-
// ../invoice/src/utils/included-items.ts
|
|
114397
|
-
function parseIncludedItems(value) {
|
|
114398
|
-
if (value == null) return null;
|
|
114399
|
-
if (!Array.isArray(value)) return null;
|
|
114400
|
-
const items = [];
|
|
114401
|
-
for (const entry of value) {
|
|
114402
|
-
if (typeof entry === "string") {
|
|
114403
|
-
const label = entry.trim();
|
|
114404
|
-
if (label) items.push({ label });
|
|
114405
|
-
continue;
|
|
114406
|
-
}
|
|
114407
|
-
if (entry && typeof entry === "object" && "label" in entry) {
|
|
114408
|
-
const raw = entry;
|
|
114409
|
-
const label = String(raw.label ?? "").trim();
|
|
114410
|
-
if (!label) continue;
|
|
114411
|
-
items.push({
|
|
114412
|
-
label,
|
|
114413
|
-
productId: raw.productId ?? null
|
|
114414
|
-
});
|
|
114415
|
-
}
|
|
114416
|
-
}
|
|
114417
|
-
return items.length > 0 ? items : null;
|
|
114418
|
-
}
|
|
114419
|
-
function serializeIncludedItems(items) {
|
|
114420
|
-
if (items == null) return null;
|
|
114421
|
-
const cleaned = items.map((item) => ({
|
|
114422
|
-
label: item.label.trim(),
|
|
114423
|
-
...item.productId ? { productId: item.productId } : {}
|
|
114424
|
-
})).filter((item) => item.label.length > 0);
|
|
114425
|
-
return cleaned.length > 0 ? cleaned : null;
|
|
114426
|
-
}
|
|
114427
|
-
function includedItemLabels(items) {
|
|
114428
|
-
const parsed = items ? serializeIncludedItems(items) : null;
|
|
114429
|
-
if (!parsed?.length) return null;
|
|
114430
|
-
return parsed.map((item) => item.label);
|
|
114431
|
-
}
|
|
114432
|
-
|
|
114433
|
-
// ../invoice/src/utils/product-clause.ts
|
|
114434
|
-
function trimToNull(value) {
|
|
114435
|
-
if (typeof value !== "string") return null;
|
|
114436
|
-
const trimmed = value.trim();
|
|
114437
|
-
return trimmed.length > 0 ? trimmed : null;
|
|
114438
|
-
}
|
|
114439
|
-
function parseStringList(value) {
|
|
114440
|
-
if (!Array.isArray(value)) return [];
|
|
114441
|
-
const out = [];
|
|
114442
|
-
for (const entry of value) {
|
|
114443
|
-
const label = trimToNull(entry);
|
|
114444
|
-
if (label) out.push(label);
|
|
114445
|
-
}
|
|
114446
|
-
return out;
|
|
114447
|
-
}
|
|
114448
|
-
function parseLimits(value) {
|
|
114449
|
-
if (!value || typeof value !== "object") return null;
|
|
114450
|
-
const raw = value;
|
|
114451
|
-
const hoursRaw = raw.includedHoursPerMonth;
|
|
114452
|
-
let includedHoursPerMonth = null;
|
|
114453
|
-
if (typeof hoursRaw === "number" && Number.isFinite(hoursRaw)) {
|
|
114454
|
-
includedHoursPerMonth = hoursRaw;
|
|
114455
|
-
} else if (typeof hoursRaw === "string" && hoursRaw.trim() !== "") {
|
|
114456
|
-
const parsed = Number(hoursRaw);
|
|
114457
|
-
if (Number.isFinite(parsed)) includedHoursPerMonth = parsed;
|
|
114458
|
-
}
|
|
114459
|
-
const rollover = typeof raw.rollover === "boolean" ? raw.rollover : null;
|
|
114460
|
-
const limits = {
|
|
114461
|
-
includedHoursPerMonth,
|
|
114462
|
-
rollover,
|
|
114463
|
-
minimumBillingUnitExtraWork: trimToNull(raw.minimumBillingUnitExtraWork),
|
|
114464
|
-
responseTime: trimToNull(raw.responseTime),
|
|
114465
|
-
supportLevel: trimToNull(raw.supportLevel)
|
|
114466
|
-
};
|
|
114467
|
-
return isLimitsEmpty(limits) ? null : limits;
|
|
114468
|
-
}
|
|
114469
|
-
function isLimitsEmpty(limits) {
|
|
114470
|
-
if (!limits) return true;
|
|
114471
|
-
return limits.includedHoursPerMonth == null && limits.rollover == null && !limits.minimumBillingUnitExtraWork && !limits.responseTime && !limits.supportLevel;
|
|
114472
|
-
}
|
|
114473
|
-
function parseProductClause(value) {
|
|
114474
|
-
if (value == null) return null;
|
|
114475
|
-
if (typeof value !== "object" || Array.isArray(value)) return null;
|
|
114476
|
-
const raw = value;
|
|
114477
|
-
const clause = {
|
|
114478
|
-
commercialDescription: trimToNull(raw.commercialDescription),
|
|
114479
|
-
includedScope: parseStringList(raw.includedScope),
|
|
114480
|
-
excludedScope: parseStringList(raw.excludedScope),
|
|
114481
|
-
limits: parseLimits(raw.limits),
|
|
114482
|
-
customerValue: trimToNull(raw.customerValue),
|
|
114483
|
-
extraWorkConditions: trimToNull(raw.extraWorkConditions)
|
|
114484
|
-
};
|
|
114485
|
-
return isClauseEmpty(clause) ? null : clause;
|
|
114486
|
-
}
|
|
114487
|
-
function isClauseEmpty(clause) {
|
|
114488
|
-
if (!clause) return true;
|
|
114489
|
-
return !clause.commercialDescription && (clause.includedScope?.length ?? 0) === 0 && (clause.excludedScope?.length ?? 0) === 0 && isLimitsEmpty(clause.limits) && !clause.customerValue && !clause.extraWorkConditions;
|
|
114490
|
-
}
|
|
114491
|
-
function serializeProductClause(clause) {
|
|
114492
|
-
if (clause == null) return null;
|
|
114493
|
-
const parsed = parseProductClause(clause);
|
|
114494
|
-
if (!parsed) return null;
|
|
114495
|
-
const out = {};
|
|
114496
|
-
if (parsed.commercialDescription) {
|
|
114497
|
-
out.commercialDescription = parsed.commercialDescription;
|
|
114498
|
-
}
|
|
114499
|
-
if (parsed.includedScope && parsed.includedScope.length > 0) {
|
|
114500
|
-
out.includedScope = parsed.includedScope;
|
|
114501
|
-
}
|
|
114502
|
-
if (parsed.excludedScope && parsed.excludedScope.length > 0) {
|
|
114503
|
-
out.excludedScope = parsed.excludedScope;
|
|
114504
|
-
}
|
|
114505
|
-
if (!isLimitsEmpty(parsed.limits)) {
|
|
114506
|
-
const limits = parsed.limits;
|
|
114507
|
-
const cleanedLimits = {};
|
|
114508
|
-
if (limits.includedHoursPerMonth != null) {
|
|
114509
|
-
cleanedLimits.includedHoursPerMonth = limits.includedHoursPerMonth;
|
|
114510
|
-
}
|
|
114511
|
-
if (limits.rollover != null) cleanedLimits.rollover = limits.rollover;
|
|
114512
|
-
if (limits.minimumBillingUnitExtraWork) {
|
|
114513
|
-
cleanedLimits.minimumBillingUnitExtraWork = limits.minimumBillingUnitExtraWork;
|
|
114514
|
-
}
|
|
114515
|
-
if (limits.responseTime) cleanedLimits.responseTime = limits.responseTime;
|
|
114516
|
-
if (limits.supportLevel) cleanedLimits.supportLevel = limits.supportLevel;
|
|
114517
|
-
out.limits = cleanedLimits;
|
|
115193
|
+
);
|
|
114518
115194
|
}
|
|
114519
|
-
|
|
114520
|
-
|
|
114521
|
-
|
|
115195
|
+
await setProjectMemberAccess({
|
|
115196
|
+
projectId,
|
|
115197
|
+
teamId: resolved.teamId,
|
|
115198
|
+
memberIds: [...state2.projectMemberIds].filter(
|
|
115199
|
+
(uid2) => uid2 !== member2.member.userId
|
|
115200
|
+
),
|
|
115201
|
+
createdBy: ctx.userId
|
|
115202
|
+
});
|
|
115203
|
+
let text3 = `\u2705 Removed ${memberLabel(member2.member)} (userId: ${member2.member.userId}) from the project.`;
|
|
115204
|
+
if ((state2.rowCountByUser.get(member2.member.userId) ?? 0) <= 1) {
|
|
115205
|
+
text3 += "\n\nThis was the member's last project assignment, so their access restrictions were cleared \u2014 they can see all projects in the team again (default behavior).";
|
|
114522
115206
|
}
|
|
114523
|
-
return
|
|
115207
|
+
return textResponse3(text3);
|
|
114524
115208
|
}
|
|
114525
|
-
function
|
|
114526
|
-
|
|
114527
|
-
const
|
|
114528
|
-
|
|
114529
|
-
|
|
115209
|
+
async function loadProjectForCleanup(projectId, teamId) {
|
|
115210
|
+
const accessibleTeamIds = await getAccessibleTeamIds(teamId);
|
|
115211
|
+
const [row] = await db.select({
|
|
115212
|
+
id: schema_exports.projects.id,
|
|
115213
|
+
name: schema_exports.projects.name,
|
|
115214
|
+
teamId: schema_exports.projects.teamId,
|
|
115215
|
+
settings: schema_exports.projects.settings
|
|
115216
|
+
}).from(schema_exports.projects).where(eq(schema_exports.projects.id, projectId)).limit(1);
|
|
115217
|
+
if (!row || !row.teamId || !accessibleTeamIds.includes(row.teamId)) {
|
|
115218
|
+
return null;
|
|
114530
115219
|
}
|
|
114531
|
-
|
|
114532
|
-
|
|
114533
|
-
|
|
115220
|
+
return { id: row.id, name: row.name, teamId: row.teamId, settings: row.settings };
|
|
115221
|
+
}
|
|
115222
|
+
async function countProjectDependencies(projectId) {
|
|
115223
|
+
const countRows = (table) => db.select({ c: sql`count(*)::int` }).from(table).where(eq(table.projectId, projectId)).then((r6) => r6[0]?.c ?? 0);
|
|
115224
|
+
const [tickets3, timesheetEvents2, timesheetTemplates2, trips2, tripTemplates2] = await Promise.all([
|
|
115225
|
+
countRows(schema_exports.tickets),
|
|
115226
|
+
countRows(schema_exports.timesheetEvents),
|
|
115227
|
+
countRows(schema_exports.timesheetTemplates),
|
|
115228
|
+
countRows(schema_exports.trips),
|
|
115229
|
+
countRows(schema_exports.tripTemplates)
|
|
115230
|
+
]);
|
|
115231
|
+
return { tickets: tickets3, timesheetEvents: timesheetEvents2, timesheetTemplates: timesheetTemplates2, trips: trips2, tripTemplates: tripTemplates2 };
|
|
115232
|
+
}
|
|
115233
|
+
async function handleArchiveProject(input) {
|
|
115234
|
+
const { projectId, reason } = input;
|
|
115235
|
+
if (!projectId) return textResponse3("Error: `projectId` is required.");
|
|
115236
|
+
const resolved = await resolveTeamId(input.teamId);
|
|
115237
|
+
if (!resolved.ok) return resolved.response;
|
|
115238
|
+
const project = await loadProjectForCleanup(projectId, resolved.teamId);
|
|
115239
|
+
if (!project) {
|
|
115240
|
+
return textResponse3(
|
|
115241
|
+
`Project ${projectId} not found, or it is not owned by this team.`
|
|
114534
115242
|
);
|
|
114535
115243
|
}
|
|
114536
|
-
|
|
114537
|
-
|
|
114538
|
-
|
|
115244
|
+
const state2 = getProjectArchiveState(project.settings);
|
|
115245
|
+
if (state2.archived) {
|
|
115246
|
+
return textResponse3(
|
|
115247
|
+
`Project "${project.name}" (${project.id}) is already archived${state2.archivedAt ? ` (since ${state2.archivedAt})` : ""}.`
|
|
114539
115248
|
);
|
|
114540
115249
|
}
|
|
114541
|
-
|
|
114542
|
-
|
|
114543
|
-
|
|
115250
|
+
const archivedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
115251
|
+
const nextSettings = withArchiveSettings(project.settings, archivedAt, reason);
|
|
115252
|
+
await db.update(schema_exports.projects).set({ settings: nextSettings, updatedAt: sql`now()` }).where(eq(schema_exports.projects.id, project.id));
|
|
115253
|
+
return textResponse3(
|
|
115254
|
+
`\u2705 **Project archived**
|
|
115255
|
+
|
|
115256
|
+
Project: ${project.name}
|
|
115257
|
+
ID: ${project.id}
|
|
115258
|
+
Action: archived (soft, reversible)
|
|
115259
|
+
Status: archived
|
|
115260
|
+
Timestamp: ${archivedAt}
|
|
115261
|
+
${reason ? `Reason: ${reason}
|
|
115262
|
+
` : ""}
|
|
115263
|
+
Archived projects are hidden from get-projects by default (pass status: 'archived' or 'all' to see them). No tickets, hours, or other data were touched.
|
|
115264
|
+
|
|
115265
|
+
Note: the archive flag is stored in \`projects.settings.archivedAt\`; the dashboard UI does not yet read it, so the project still appears there.`
|
|
115266
|
+
);
|
|
114544
115267
|
}
|
|
114545
|
-
function
|
|
114546
|
-
const
|
|
114547
|
-
|
|
114548
|
-
|
|
114549
|
-
|
|
114550
|
-
|
|
114551
|
-
|
|
114552
|
-
if (
|
|
114553
|
-
|
|
114554
|
-
|
|
114555
|
-
|
|
114556
|
-
|
|
115268
|
+
async function handleDeleteProject(input) {
|
|
115269
|
+
const ctx = getAuthContext();
|
|
115270
|
+
const { projectId, confirmEmptyOnly } = input;
|
|
115271
|
+
if (!projectId) return textResponse3("Error: `projectId` is required.");
|
|
115272
|
+
const resolved = await resolveTeamId(input.teamId);
|
|
115273
|
+
if (!resolved.ok) return resolved.response;
|
|
115274
|
+
const ownerError = await requireTeamOwner2(resolved.teamId, ctx.userId);
|
|
115275
|
+
if (ownerError) return ownerError;
|
|
115276
|
+
const project = await loadProjectForCleanup(projectId, resolved.teamId);
|
|
115277
|
+
if (!project) {
|
|
115278
|
+
return textResponse3(
|
|
115279
|
+
`Project ${projectId} not found, or it is not owned by this team.`
|
|
115280
|
+
);
|
|
114557
115281
|
}
|
|
114558
|
-
|
|
114559
|
-
|
|
114560
|
-
|
|
115282
|
+
const deps = await countProjectDependencies(project.id);
|
|
115283
|
+
const summary = formatProjectDependencies(deps);
|
|
115284
|
+
if (!isProjectEmpty(deps)) {
|
|
115285
|
+
return textResponse3(
|
|
115286
|
+
`\u{1F6AB} **Delete blocked** \u2014 project "${project.name}" (${project.id}) is not empty.
|
|
115287
|
+
|
|
115288
|
+
Dependencies: ${summary}.
|
|
115289
|
+
|
|
115290
|
+
A hard delete would orphan these records, so it is not allowed. Use archive-project instead to safely retire this project (reversible, keeps all data).`
|
|
115291
|
+
);
|
|
114561
115292
|
}
|
|
114562
|
-
if (
|
|
114563
|
-
|
|
115293
|
+
if (confirmEmptyOnly !== true) {
|
|
115294
|
+
return textResponse3(
|
|
115295
|
+
`Project "${project.name}" (${project.id}) has no dependencies and can be safely deleted. This is a permanent hard delete. Re-run delete-project with confirmEmptyOnly: true to proceed (or use archive-project to keep the record).`
|
|
115296
|
+
);
|
|
114564
115297
|
}
|
|
114565
|
-
|
|
115298
|
+
await db.delete(schema_exports.projects).where(eq(schema_exports.projects.id, project.id));
|
|
115299
|
+
return textResponse3(
|
|
115300
|
+
`\u2705 **Project deleted**
|
|
115301
|
+
|
|
115302
|
+
Project: ${project.name}
|
|
115303
|
+
ID: ${project.id}
|
|
115304
|
+
Action: hard delete (empty project)
|
|
115305
|
+
Status: deleted
|
|
115306
|
+
Timestamp: ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
115307
|
+
|
|
115308
|
+
The project had no tickets, hours, trips, or templates. Any project-scoped config (member access, tags, slack/github links, team rates) was removed with it.`
|
|
115309
|
+
);
|
|
114566
115310
|
}
|
|
114567
115311
|
|
|
114568
115312
|
// src/tools/products.ts
|
|
@@ -114583,7 +115327,7 @@ var TIERS = [
|
|
|
114583
115327
|
"silver",
|
|
114584
115328
|
"gold"
|
|
114585
115329
|
];
|
|
114586
|
-
var
|
|
115330
|
+
var PRODUCT_COLUMNS2 = {
|
|
114587
115331
|
id: schema_exports.invoiceProducts.id,
|
|
114588
115332
|
teamId: schema_exports.invoiceProducts.teamId,
|
|
114589
115333
|
name: schema_exports.invoiceProducts.name,
|
|
@@ -114605,7 +115349,7 @@ var PRODUCT_COLUMNS = {
|
|
|
114605
115349
|
createdAt: schema_exports.invoiceProducts.createdAt,
|
|
114606
115350
|
updatedAt: schema_exports.invoiceProducts.updatedAt
|
|
114607
115351
|
};
|
|
114608
|
-
function
|
|
115352
|
+
function textResponse4(text3) {
|
|
114609
115353
|
return { content: [{ type: "text", text: text3 }] };
|
|
114610
115354
|
}
|
|
114611
115355
|
function formatPrice(p3) {
|
|
@@ -114651,14 +115395,14 @@ async function handleGetProducts(input) {
|
|
|
114651
115395
|
const { q: q3, currency, pageSize = 20 } = input;
|
|
114652
115396
|
const status = input.status ?? "active";
|
|
114653
115397
|
if (!PRODUCT_STATUSES.includes(status)) {
|
|
114654
|
-
return
|
|
115398
|
+
return textResponse4(
|
|
114655
115399
|
`Error: invalid status "${status}". Allowed: ${PRODUCT_STATUSES.join(", ")}.`
|
|
114656
115400
|
);
|
|
114657
115401
|
}
|
|
114658
115402
|
const scope = await resolveTeamScope(input.teamId);
|
|
114659
115403
|
if (!scope.ok) return scope.response;
|
|
114660
115404
|
if (scope.teamIds.length === 0) {
|
|
114661
|
-
return
|
|
115405
|
+
return textResponse4("No accessible teams found.");
|
|
114662
115406
|
}
|
|
114663
115407
|
const filters = [inArray(schema_exports.invoiceProducts.teamId, scope.teamIds)];
|
|
114664
115408
|
if (status === "active") {
|
|
@@ -114675,17 +115419,17 @@ async function handleGetProducts(input) {
|
|
|
114675
115419
|
)
|
|
114676
115420
|
);
|
|
114677
115421
|
}
|
|
114678
|
-
const rows = await db.select(
|
|
115422
|
+
const rows = await db.select(PRODUCT_COLUMNS2).from(schema_exports.invoiceProducts).where(and(...filters)).orderBy(
|
|
114679
115423
|
desc(schema_exports.invoiceProducts.usageCount),
|
|
114680
115424
|
desc(schema_exports.invoiceProducts.lastUsedAt),
|
|
114681
115425
|
asc(schema_exports.invoiceProducts.name)
|
|
114682
115426
|
).limit(Math.min(pageSize, 100));
|
|
114683
115427
|
if (rows.length === 0) {
|
|
114684
|
-
return
|
|
115428
|
+
return textResponse4(
|
|
114685
115429
|
`No products found${status !== "all" ? ` (status: ${status})` : ""}.`
|
|
114686
115430
|
);
|
|
114687
115431
|
}
|
|
114688
|
-
return
|
|
115432
|
+
return textResponse4(
|
|
114689
115433
|
`Found ${rows.length} product(s):
|
|
114690
115434
|
|
|
114691
115435
|
${rows.map(formatProduct).join("\n")}`
|
|
@@ -114693,28 +115437,28 @@ ${rows.map(formatProduct).join("\n")}`
|
|
|
114693
115437
|
}
|
|
114694
115438
|
async function handleGetProductById(input) {
|
|
114695
115439
|
const { productId } = input;
|
|
114696
|
-
if (!productId) return
|
|
115440
|
+
if (!productId) return textResponse4("Error: `productId` is required.");
|
|
114697
115441
|
const scope = await resolveTeamScope(input.teamId);
|
|
114698
115442
|
if (!scope.ok) return scope.response;
|
|
114699
115443
|
if (scope.teamIds.length === 0) {
|
|
114700
|
-
return
|
|
115444
|
+
return textResponse4("No accessible teams found.");
|
|
114701
115445
|
}
|
|
114702
|
-
const [row] = await db.select(
|
|
115446
|
+
const [row] = await db.select(PRODUCT_COLUMNS2).from(schema_exports.invoiceProducts).where(
|
|
114703
115447
|
and(
|
|
114704
115448
|
eq(schema_exports.invoiceProducts.id, productId),
|
|
114705
115449
|
inArray(schema_exports.invoiceProducts.teamId, scope.teamIds)
|
|
114706
115450
|
)
|
|
114707
115451
|
).limit(1);
|
|
114708
115452
|
if (!row) {
|
|
114709
|
-
return
|
|
115453
|
+
return textResponse4(
|
|
114710
115454
|
`Product ${productId} not found or you don't have access to it.`
|
|
114711
115455
|
);
|
|
114712
115456
|
}
|
|
114713
|
-
return
|
|
115457
|
+
return textResponse4(formatProduct(row));
|
|
114714
115458
|
}
|
|
114715
115459
|
async function loadProductInTeam(productId, teamId) {
|
|
114716
115460
|
const accessibleTeamIds = await getAccessibleTeamIds(teamId);
|
|
114717
|
-
const [row] = await db.select(
|
|
115461
|
+
const [row] = await db.select(PRODUCT_COLUMNS2).from(schema_exports.invoiceProducts).where(
|
|
114718
115462
|
and(
|
|
114719
115463
|
eq(schema_exports.invoiceProducts.id, productId),
|
|
114720
115464
|
inArray(schema_exports.invoiceProducts.teamId, accessibleTeamIds)
|
|
@@ -114725,10 +115469,10 @@ async function loadProductInTeam(productId, teamId) {
|
|
|
114725
115469
|
async function handleCreateProduct(input) {
|
|
114726
115470
|
const { name: name21, description, price, currency, unit } = input;
|
|
114727
115471
|
if (!name21 || name21.trim().length === 0) {
|
|
114728
|
-
return
|
|
115472
|
+
return textResponse4("Error: `name` is required.");
|
|
114729
115473
|
}
|
|
114730
115474
|
const enumError = validateEnum("billingType", input.billingType, BILLING_TYPES) ?? validateEnum("category", input.category, CATEGORIES) ?? validateEnum("tier", input.tier, TIERS);
|
|
114731
|
-
if (enumError) return
|
|
115475
|
+
if (enumError) return textResponse4(enumError);
|
|
114732
115476
|
const resolved = await resolveTeamId(input.teamId);
|
|
114733
115477
|
if (!resolved.ok) return resolved.response;
|
|
114734
115478
|
const [created] = await db.insert(schema_exports.invoiceProducts).values({
|
|
@@ -114747,9 +115491,9 @@ async function handleCreateProduct(input) {
|
|
|
114747
115491
|
clause: serializeProductClause(input.clause) ?? null,
|
|
114748
115492
|
isActive: true,
|
|
114749
115493
|
lastUsedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
114750
|
-
}).returning(
|
|
114751
|
-
if (!created) return
|
|
114752
|
-
return
|
|
115494
|
+
}).returning(PRODUCT_COLUMNS2);
|
|
115495
|
+
if (!created) return textResponse4("Failed to create product.");
|
|
115496
|
+
return textResponse4(
|
|
114753
115497
|
`\u2705 **Product created**
|
|
114754
115498
|
|
|
114755
115499
|
${formatProduct(created)}`
|
|
@@ -114757,21 +115501,21 @@ ${formatProduct(created)}`
|
|
|
114757
115501
|
}
|
|
114758
115502
|
async function handleUpdateProduct(input) {
|
|
114759
115503
|
const { productId } = input;
|
|
114760
|
-
if (!productId) return
|
|
115504
|
+
if (!productId) return textResponse4("Error: `productId` is required.");
|
|
114761
115505
|
const resolved = await resolveTeamId(input.teamId);
|
|
114762
115506
|
if (!resolved.ok) return resolved.response;
|
|
114763
115507
|
const existing = await loadProductInTeam(productId, resolved.teamId);
|
|
114764
115508
|
if (!existing) {
|
|
114765
|
-
return
|
|
115509
|
+
return textResponse4(
|
|
114766
115510
|
`Product ${productId} not found, or it is not owned by this team.`
|
|
114767
115511
|
);
|
|
114768
115512
|
}
|
|
114769
115513
|
const enumError = validateEnum("billingType", input.billingType, BILLING_TYPES) ?? validateEnum("category", input.category, CATEGORIES) ?? validateEnum("tier", input.tier, TIERS);
|
|
114770
|
-
if (enumError) return
|
|
115514
|
+
if (enumError) return textResponse4(enumError);
|
|
114771
115515
|
const updates = {};
|
|
114772
115516
|
if (input.name !== void 0) {
|
|
114773
115517
|
if (!input.name || input.name.trim().length === 0) {
|
|
114774
|
-
return
|
|
115518
|
+
return textResponse4("Error: `name` cannot be empty.");
|
|
114775
115519
|
}
|
|
114776
115520
|
updates.name = input.name.trim();
|
|
114777
115521
|
}
|
|
@@ -114792,14 +115536,14 @@ async function handleUpdateProduct(input) {
|
|
|
114792
115536
|
updates.clause = serializeProductClause(input.clause);
|
|
114793
115537
|
}
|
|
114794
115538
|
if (Object.keys(updates).length === 0) {
|
|
114795
|
-
return
|
|
115539
|
+
return textResponse4(
|
|
114796
115540
|
"No fields to update. Provide at least one of: name, description, price, currency, unit, isActive, billingType, category, includedItems, optional, tier, sortOrder, clause."
|
|
114797
115541
|
);
|
|
114798
115542
|
}
|
|
114799
115543
|
updates.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
114800
|
-
const [updated] = await db.update(schema_exports.invoiceProducts).set(updates).where(eq(schema_exports.invoiceProducts.id, existing.id)).returning(
|
|
114801
|
-
if (!updated) return
|
|
114802
|
-
return
|
|
115544
|
+
const [updated] = await db.update(schema_exports.invoiceProducts).set(updates).where(eq(schema_exports.invoiceProducts.id, existing.id)).returning(PRODUCT_COLUMNS2);
|
|
115545
|
+
if (!updated) return textResponse4(`Failed to update product ${productId}.`);
|
|
115546
|
+
return textResponse4(
|
|
114803
115547
|
`\u2705 **Product updated**
|
|
114804
115548
|
|
|
114805
115549
|
${formatProduct(updated)}
|
|
@@ -114808,23 +115552,23 @@ Note: this only affects future invoices/quotes. Existing documents keep their li
|
|
|
114808
115552
|
}
|
|
114809
115553
|
async function handleArchiveProduct(input) {
|
|
114810
115554
|
const { productId, reason } = input;
|
|
114811
|
-
if (!productId) return
|
|
115555
|
+
if (!productId) return textResponse4("Error: `productId` is required.");
|
|
114812
115556
|
const resolved = await resolveTeamId(input.teamId);
|
|
114813
115557
|
if (!resolved.ok) return resolved.response;
|
|
114814
115558
|
const existing = await loadProductInTeam(productId, resolved.teamId);
|
|
114815
115559
|
if (!existing) {
|
|
114816
|
-
return
|
|
115560
|
+
return textResponse4(
|
|
114817
115561
|
`Product ${productId} not found, or it is not owned by this team.`
|
|
114818
115562
|
);
|
|
114819
115563
|
}
|
|
114820
115564
|
if (!existing.isActive) {
|
|
114821
|
-
return
|
|
115565
|
+
return textResponse4(
|
|
114822
115566
|
`Product "${existing.name}" (${existing.id}) is already archived.`
|
|
114823
115567
|
);
|
|
114824
115568
|
}
|
|
114825
|
-
const [archived] = await db.update(schema_exports.invoiceProducts).set({ isActive: false, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq(schema_exports.invoiceProducts.id, existing.id)).returning(
|
|
114826
|
-
if (!archived) return
|
|
114827
|
-
return
|
|
115569
|
+
const [archived] = await db.update(schema_exports.invoiceProducts).set({ isActive: false, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq(schema_exports.invoiceProducts.id, existing.id)).returning(PRODUCT_COLUMNS2);
|
|
115570
|
+
if (!archived) return textResponse4(`Failed to archive product ${productId}.`);
|
|
115571
|
+
return textResponse4(
|
|
114828
115572
|
`\u2705 **Product archived** (hidden from new invoices/quotes; existing documents are untouched).
|
|
114829
115573
|
|
|
114830
115574
|
${formatProduct(archived)}${reason ? `Reason: ${reason}
|
|
@@ -114833,14 +115577,14 @@ ${formatProduct(archived)}${reason ? `Reason: ${reason}
|
|
|
114833
115577
|
}
|
|
114834
115578
|
|
|
114835
115579
|
// src/tools/quote-line-util.ts
|
|
114836
|
-
function
|
|
115580
|
+
function round22(n3) {
|
|
114837
115581
|
return Math.round(n3 * 100) / 100;
|
|
114838
115582
|
}
|
|
114839
|
-
function
|
|
115583
|
+
function lineFinancials2(quantity, price, defaults) {
|
|
114840
115584
|
const lineTotal = quantity * price;
|
|
114841
115585
|
return {
|
|
114842
|
-
vat: defaults.includeVat ?
|
|
114843
|
-
tax: defaults.includeTax ?
|
|
115586
|
+
vat: defaults.includeVat ? round22(lineTotal * (defaults.vatRate / 100)) : void 0,
|
|
115587
|
+
tax: defaults.includeTax ? round22(lineTotal * (defaults.taxRate / 100)) : void 0
|
|
114844
115588
|
};
|
|
114845
115589
|
}
|
|
114846
115590
|
function computeTotals(items, defaults) {
|
|
@@ -114851,10 +115595,10 @@ function computeTotals(items, defaults) {
|
|
|
114851
115595
|
const vat = defaults.includeVat ? subtotal * (defaults.vatRate / 100) : 0;
|
|
114852
115596
|
const tax = defaults.includeTax ? subtotal * (defaults.taxRate / 100) : 0;
|
|
114853
115597
|
return {
|
|
114854
|
-
subtotal:
|
|
114855
|
-
vat:
|
|
114856
|
-
tax:
|
|
114857
|
-
amount:
|
|
115598
|
+
subtotal: round22(subtotal),
|
|
115599
|
+
vat: round22(vat),
|
|
115600
|
+
tax: round22(tax),
|
|
115601
|
+
amount: round22(subtotal + vat + tax)
|
|
114858
115602
|
};
|
|
114859
115603
|
}
|
|
114860
115604
|
function snapshotFromProduct(product, defaults, now2 = () => (/* @__PURE__ */ new Date()).toISOString()) {
|
|
@@ -114884,7 +115628,7 @@ function snapshotFromProduct(product, defaults, now2 = () => (/* @__PURE__ */ ne
|
|
|
114884
115628
|
function lineItemFromProduct(product, opts, defaults, now2 = () => (/* @__PURE__ */ new Date()).toISOString()) {
|
|
114885
115629
|
const quantity = opts.quantity ?? 1;
|
|
114886
115630
|
const price = opts.customPrice ?? product.price ?? 0;
|
|
114887
|
-
const { vat, tax } =
|
|
115631
|
+
const { vat, tax } = lineFinancials2(quantity, price, defaults);
|
|
114888
115632
|
return {
|
|
114889
115633
|
name: opts.customDescription || product.name,
|
|
114890
115634
|
quantity,
|
|
@@ -114924,7 +115668,7 @@ var QUOTE_STATUSES = [
|
|
|
114924
115668
|
"expired"
|
|
114925
115669
|
];
|
|
114926
115670
|
var SAFE_DRAFT_STATUSES = /* @__PURE__ */ new Set(["draft"]);
|
|
114927
|
-
function
|
|
115671
|
+
function textResponse5(text3) {
|
|
114928
115672
|
return { content: [{ type: "text", text: text3 }] };
|
|
114929
115673
|
}
|
|
114930
115674
|
async function loadTemplateDefaults(teamId) {
|
|
@@ -114990,7 +115734,7 @@ async function nextQuotationNumber(teamId) {
|
|
|
114990
115734
|
if (!value) throw new Error("Failed to fetch next quotation number");
|
|
114991
115735
|
return value;
|
|
114992
115736
|
}
|
|
114993
|
-
async function
|
|
115737
|
+
async function loadProductsInTeam2(productIds, teamId) {
|
|
114994
115738
|
if (productIds.length === 0) return /* @__PURE__ */ new Map();
|
|
114995
115739
|
const accessibleTeamIds = await getAccessibleTeamIds(teamId);
|
|
114996
115740
|
const rows = await db.select({
|
|
@@ -115018,7 +115762,7 @@ async function loadProductsInTeam(productIds, teamId) {
|
|
|
115018
115762
|
}
|
|
115019
115763
|
async function resolveLineItems(inputs, defaults, teamId) {
|
|
115020
115764
|
const productIds = inputs.map((i6) => i6.productId).filter((id) => Boolean(id));
|
|
115021
|
-
const products = await
|
|
115765
|
+
const products = await loadProductsInTeam2([...new Set(productIds)], teamId);
|
|
115022
115766
|
const items = [];
|
|
115023
115767
|
for (const input of inputs) {
|
|
115024
115768
|
if (input.productId) {
|
|
@@ -115044,7 +115788,7 @@ async function resolveLineItems(inputs, defaults, teamId) {
|
|
|
115044
115788
|
}
|
|
115045
115789
|
const quantity = input.quantity ?? 1;
|
|
115046
115790
|
const price = input.price ?? 0;
|
|
115047
|
-
const { vat, tax } =
|
|
115791
|
+
const { vat, tax } = lineFinancials2(quantity, price, defaults);
|
|
115048
115792
|
items.push({
|
|
115049
115793
|
name: input.name?.trim() || "(no description)",
|
|
115050
115794
|
quantity,
|
|
@@ -115083,7 +115827,7 @@ ${q3.validUntil ? `Valid until: ${new Date(q3.validUntil).toLocaleDateString()}
|
|
|
115083
115827
|
` : ""}Created: ${new Date(q3.createdAt).toLocaleDateString()}
|
|
115084
115828
|
`;
|
|
115085
115829
|
}
|
|
115086
|
-
function
|
|
115830
|
+
function tiptapNote2(text3) {
|
|
115087
115831
|
return {
|
|
115088
115832
|
type: "doc",
|
|
115089
115833
|
content: [{ type: "paragraph", content: [{ type: "text", text: text3 }] }]
|
|
@@ -115092,14 +115836,14 @@ function tiptapNote(text3) {
|
|
|
115092
115836
|
async function handleGetQuotes(input) {
|
|
115093
115837
|
const { customerId, status, q: q3, pageSize = 20 } = input;
|
|
115094
115838
|
if (status && !QUOTE_STATUSES.includes(status)) {
|
|
115095
|
-
return
|
|
115839
|
+
return textResponse5(
|
|
115096
115840
|
`Error: invalid status "${status}". Allowed: ${QUOTE_STATUSES.join(", ")}.`
|
|
115097
115841
|
);
|
|
115098
115842
|
}
|
|
115099
115843
|
const scope = await resolveTeamScope(input.teamId);
|
|
115100
115844
|
if (!scope.ok) return scope.response;
|
|
115101
115845
|
if (scope.teamIds.length === 0) {
|
|
115102
|
-
return
|
|
115846
|
+
return textResponse5("No accessible teams found.");
|
|
115103
115847
|
}
|
|
115104
115848
|
const filters = [inArray(schema_exports.quotations.teamId, scope.teamIds)];
|
|
115105
115849
|
if (customerId) filters.push(eq(schema_exports.quotations.customerId, customerId));
|
|
@@ -115114,10 +115858,10 @@ async function handleGetQuotes(input) {
|
|
|
115114
115858
|
}
|
|
115115
115859
|
const rows = await db.select(QUOTE_COLUMNS).from(schema_exports.quotations).where(and(...filters)).orderBy(desc(schema_exports.quotations.createdAt)).limit(Math.min(pageSize, 100));
|
|
115116
115860
|
if (rows.length === 0) {
|
|
115117
|
-
return
|
|
115861
|
+
return textResponse5("No quotes found.");
|
|
115118
115862
|
}
|
|
115119
115863
|
const note = input.projectId ? "\nNote: `projectId` was ignored \u2014 quotations are not linked to projects." : "";
|
|
115120
|
-
return
|
|
115864
|
+
return textResponse5(
|
|
115121
115865
|
`Found ${rows.length} quote(s):
|
|
115122
115866
|
|
|
115123
115867
|
${rows.map(formatQuote).join("\n")}${note}`
|
|
@@ -115125,10 +115869,10 @@ ${rows.map(formatQuote).join("\n")}${note}`
|
|
|
115125
115869
|
}
|
|
115126
115870
|
async function handleCreateQuote(input) {
|
|
115127
115871
|
const { customerId } = input;
|
|
115128
|
-
if (!customerId) return
|
|
115872
|
+
if (!customerId) return textResponse5("Error: `customerId` is required.");
|
|
115129
115873
|
const status = input.status ?? "draft";
|
|
115130
115874
|
if (!SAFE_DRAFT_STATUSES.has(status)) {
|
|
115131
|
-
return
|
|
115875
|
+
return textResponse5(
|
|
115132
115876
|
`Error: this tool only creates draft quotes. Requested status "${status}" is not allowed. Sending/accepting a quote is a manual dashboard action.`
|
|
115133
115877
|
);
|
|
115134
115878
|
}
|
|
@@ -115151,7 +115895,7 @@ async function handleCreateQuote(input) {
|
|
|
115151
115895
|
)
|
|
115152
115896
|
).limit(1);
|
|
115153
115897
|
if (!customer) {
|
|
115154
|
-
return
|
|
115898
|
+
return textResponse5(
|
|
115155
115899
|
`Customer ${customerId} not found or not owned by this team.`
|
|
115156
115900
|
);
|
|
115157
115901
|
}
|
|
@@ -115161,7 +115905,7 @@ async function handleCreateQuote(input) {
|
|
|
115161
115905
|
defaults,
|
|
115162
115906
|
teamId
|
|
115163
115907
|
);
|
|
115164
|
-
if (error49) return
|
|
115908
|
+
if (error49) return textResponse5(`Error: ${error49}`);
|
|
115165
115909
|
const totals = computeTotals(items, defaults);
|
|
115166
115910
|
const quotationNumber = await nextQuotationNumber(teamId);
|
|
115167
115911
|
const template = buildQuoteTemplate(defaults, input.title);
|
|
@@ -115212,7 +115956,7 @@ async function handleCreateQuote(input) {
|
|
|
115212
115956
|
customerDetails,
|
|
115213
115957
|
fromDetails: defaults.fromDetails ?? null,
|
|
115214
115958
|
paymentDetails: defaults.paymentDetails ?? null,
|
|
115215
|
-
noteDetails: input.description ?
|
|
115959
|
+
noteDetails: input.description ? tiptapNote2(input.description) : null,
|
|
115216
115960
|
issueDate: (/* @__PURE__ */ new Date()).toISOString(),
|
|
115217
115961
|
validUntil: input.validUntil ?? null,
|
|
115218
115962
|
lineItems: items,
|
|
@@ -115221,8 +115965,8 @@ async function handleCreateQuote(input) {
|
|
|
115221
115965
|
tax: totals.tax,
|
|
115222
115966
|
amount: totals.amount
|
|
115223
115967
|
}).returning(QUOTE_COLUMNS);
|
|
115224
|
-
if (!created) return
|
|
115225
|
-
return
|
|
115968
|
+
if (!created) return textResponse5("Failed to create quote.");
|
|
115969
|
+
return textResponse5(
|
|
115226
115970
|
`\u2705 **Draft quote created**
|
|
115227
115971
|
|
|
115228
115972
|
${formatQuote(created)}
|
|
@@ -115239,16 +115983,16 @@ async function loadQuoteInTeam(id, teamId) {
|
|
|
115239
115983
|
).limit(1);
|
|
115240
115984
|
return row ?? null;
|
|
115241
115985
|
}
|
|
115242
|
-
function
|
|
115243
|
-
return
|
|
115986
|
+
function notDraftResponse2(quote) {
|
|
115987
|
+
return textResponse5(
|
|
115244
115988
|
`Quote ${quote.quotationNumber ?? quote.id} has status "${quote.status}", not "draft". These tools only modify draft quotes \u2014 sent/accepted/rejected/expired quotes are immutable here so their product snapshots stay reproducible.`
|
|
115245
115989
|
);
|
|
115246
115990
|
}
|
|
115247
115991
|
async function handleUpdateQuote(input) {
|
|
115248
115992
|
const { id } = input;
|
|
115249
|
-
if (!id) return
|
|
115993
|
+
if (!id) return textResponse5("Error: `id` is required.");
|
|
115250
115994
|
if (input.status !== void 0 && !SAFE_DRAFT_STATUSES.has(input.status)) {
|
|
115251
|
-
return
|
|
115995
|
+
return textResponse5(
|
|
115252
115996
|
`Error: status can only stay within {${[...SAFE_DRAFT_STATUSES].join(", ")}}. "${input.status}" (send/accept/reject/expire) must be done manually from the dashboard.`
|
|
115253
115997
|
);
|
|
115254
115998
|
}
|
|
@@ -115256,16 +116000,16 @@ async function handleUpdateQuote(input) {
|
|
|
115256
116000
|
if (!resolved.ok) return resolved.response;
|
|
115257
116001
|
const quote = await loadQuoteInTeam(id, resolved.teamId);
|
|
115258
116002
|
if (!quote) {
|
|
115259
|
-
return
|
|
116003
|
+
return textResponse5(`Quote ${id} not found or not owned by this team.`);
|
|
115260
116004
|
}
|
|
115261
|
-
if (quote.status !== "draft") return
|
|
116005
|
+
if (quote.status !== "draft") return notDraftResponse2(quote);
|
|
115262
116006
|
const defaults = templateDefaultsFromStored(quote.template, quote.currency);
|
|
115263
116007
|
const updates = {};
|
|
115264
116008
|
if (input.title !== void 0) {
|
|
115265
116009
|
updates.template = buildQuoteTemplate(defaults, input.title);
|
|
115266
116010
|
}
|
|
115267
116011
|
if (input.description !== void 0) {
|
|
115268
|
-
updates.noteDetails = input.description ?
|
|
116012
|
+
updates.noteDetails = input.description ? tiptapNote2(input.description) : null;
|
|
115269
116013
|
}
|
|
115270
116014
|
if (input.validUntil !== void 0) {
|
|
115271
116015
|
updates.validUntil = input.validUntil;
|
|
@@ -115276,7 +116020,7 @@ async function handleUpdateQuote(input) {
|
|
|
115276
116020
|
defaults,
|
|
115277
116021
|
quote.teamId
|
|
115278
116022
|
);
|
|
115279
|
-
if (error49) return
|
|
116023
|
+
if (error49) return textResponse5(`Error: ${error49}`);
|
|
115280
116024
|
const totals = computeTotals(items, defaults);
|
|
115281
116025
|
updates.lineItems = items;
|
|
115282
116026
|
updates.subtotal = totals.subtotal;
|
|
@@ -115285,32 +116029,32 @@ async function handleUpdateQuote(input) {
|
|
|
115285
116029
|
updates.amount = totals.amount;
|
|
115286
116030
|
}
|
|
115287
116031
|
if (Object.keys(updates).length === 0) {
|
|
115288
|
-
return
|
|
116032
|
+
return textResponse5(
|
|
115289
116033
|
"No fields to update. Provide at least one of: title, description, validUntil, lineItems."
|
|
115290
116034
|
);
|
|
115291
116035
|
}
|
|
115292
116036
|
updates.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
115293
116037
|
const [updated] = await db.update(schema_exports.quotations).set(updates).where(eq(schema_exports.quotations.id, quote.id)).returning(QUOTE_COLUMNS);
|
|
115294
|
-
if (!updated) return
|
|
115295
|
-
return
|
|
116038
|
+
if (!updated) return textResponse5(`Failed to update quote ${id}.`);
|
|
116039
|
+
return textResponse5(`\u2705 **Draft quote updated**
|
|
115296
116040
|
|
|
115297
116041
|
${formatQuote(updated)}`);
|
|
115298
116042
|
}
|
|
115299
116043
|
async function handleAddProductToQuote(input) {
|
|
115300
116044
|
const { quoteId, productId } = input;
|
|
115301
|
-
if (!quoteId) return
|
|
115302
|
-
if (!productId) return
|
|
116045
|
+
if (!quoteId) return textResponse5("Error: `quoteId` is required.");
|
|
116046
|
+
if (!productId) return textResponse5("Error: `productId` is required.");
|
|
115303
116047
|
const resolved = await resolveTeamId(input.teamId);
|
|
115304
116048
|
if (!resolved.ok) return resolved.response;
|
|
115305
116049
|
const quote = await loadQuoteInTeam(quoteId, resolved.teamId);
|
|
115306
116050
|
if (!quote) {
|
|
115307
|
-
return
|
|
116051
|
+
return textResponse5(`Quote ${quoteId} not found or not owned by this team.`);
|
|
115308
116052
|
}
|
|
115309
|
-
if (quote.status !== "draft") return
|
|
115310
|
-
const products = await
|
|
116053
|
+
if (quote.status !== "draft") return notDraftResponse2(quote);
|
|
116054
|
+
const products = await loadProductsInTeam2([productId], quote.teamId);
|
|
115311
116055
|
const product = products.get(productId);
|
|
115312
116056
|
if (!product) {
|
|
115313
|
-
return
|
|
116057
|
+
return textResponse5(
|
|
115314
116058
|
`Product ${productId} not found or not owned by this team.`
|
|
115315
116059
|
);
|
|
115316
116060
|
}
|
|
@@ -115336,7 +116080,7 @@ async function handleAddProductToQuote(input) {
|
|
|
115336
116080
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
115337
116081
|
}).where(eq(schema_exports.quotations.id, quote.id)).returning(QUOTE_COLUMNS);
|
|
115338
116082
|
if (!updated) {
|
|
115339
|
-
return
|
|
116083
|
+
return textResponse5(`Failed to add product to quote ${quoteId}.`);
|
|
115340
116084
|
}
|
|
115341
116085
|
await db.update(schema_exports.invoiceProducts).set({
|
|
115342
116086
|
usageCount: sql`${schema_exports.invoiceProducts.usageCount} + 1`,
|
|
@@ -115352,7 +116096,7 @@ async function handleAddProductToQuote(input) {
|
|
|
115352
116096
|
if (meta5.includedItems && meta5.includedItems.length > 0) {
|
|
115353
116097
|
metaParts.push(`included=[${meta5.includedItems.join(", ")}]`);
|
|
115354
116098
|
}
|
|
115355
|
-
return
|
|
116099
|
+
return textResponse5(
|
|
115356
116100
|
`\u2705 **Product added to draft quote ${updated.quotationNumber ?? updated.id}**
|
|
115357
116101
|
|
|
115358
116102
|
Line item: ${newItem.name} \xD7 ${newItem.quantity}${newItem.unit ? ` ${newItem.unit}` : ""} @ ${newItem.price} ${snap.currency}
|
|
@@ -121087,7 +121831,7 @@ function formatDeleteAttachmentRefusal(reason, context2) {
|
|
|
121087
121831
|
}
|
|
121088
121832
|
|
|
121089
121833
|
// src/tools/ticket-attachments.ts
|
|
121090
|
-
function
|
|
121834
|
+
function textResponse6(text3) {
|
|
121091
121835
|
return { content: [{ type: "text", text: text3 }] };
|
|
121092
121836
|
}
|
|
121093
121837
|
async function findAttachment(attachmentId) {
|
|
@@ -121160,7 +121904,7 @@ ${url3}`
|
|
|
121160
121904
|
async function handleUploadTicketAttachment(input) {
|
|
121161
121905
|
const ctx = getAuthContext() ?? authContext;
|
|
121162
121906
|
if (!ctx) {
|
|
121163
|
-
return
|
|
121907
|
+
return textResponse6("Error: Not authenticated.");
|
|
121164
121908
|
}
|
|
121165
121909
|
const access = await loadAccessibleTicket(input.teamId, input.ticketId);
|
|
121166
121910
|
if (!access.ok) return access.response;
|
|
@@ -121176,12 +121920,12 @@ async function handleUploadTicketAttachment(input) {
|
|
|
121176
121920
|
userId: ctx.userId
|
|
121177
121921
|
});
|
|
121178
121922
|
if (!resolved.ok) {
|
|
121179
|
-
return
|
|
121923
|
+
return textResponse6(resolved.message);
|
|
121180
121924
|
}
|
|
121181
121925
|
const { buffer: buffer2, fileName, mimeType, stagingStorageKey } = resolved;
|
|
121182
121926
|
const validationError = validateAttachmentBuffer(buffer2, mimeType);
|
|
121183
121927
|
if (validationError) {
|
|
121184
|
-
return
|
|
121928
|
+
return textResponse6(validationError.message);
|
|
121185
121929
|
}
|
|
121186
121930
|
const storageKey = `${ticket.teamId}/tickets/${ticket.id}/${Date.now()}_${fileName}`;
|
|
121187
121931
|
try {
|
|
@@ -121192,7 +121936,7 @@ async function handleUploadTicketAttachment(input) {
|
|
|
121192
121936
|
options: { contentType: mimeType, upsert: true }
|
|
121193
121937
|
});
|
|
121194
121938
|
} catch (error49) {
|
|
121195
|
-
return
|
|
121939
|
+
return textResponse6(
|
|
121196
121940
|
`Upload failed: ${error49 instanceof Error ? error49.message : String(error49)}`
|
|
121197
121941
|
);
|
|
121198
121942
|
}
|
|
@@ -121221,7 +121965,7 @@ async function handleUploadTicketAttachment(input) {
|
|
|
121221
121965
|
url3 = signed.url;
|
|
121222
121966
|
} catch {
|
|
121223
121967
|
}
|
|
121224
|
-
return
|
|
121968
|
+
return textResponse6(
|
|
121225
121969
|
`\u{1F4CE} **Attached to ${ticket.ticketNumber}**
|
|
121226
121970
|
File: ${fileName}
|
|
121227
121971
|
Type: ${mimeType}
|
|
@@ -121235,18 +121979,18 @@ ${url3}` : "")
|
|
|
121235
121979
|
async function handleDeleteTicketAttachment(input) {
|
|
121236
121980
|
const ctx = getAuthContext() ?? authContext;
|
|
121237
121981
|
if (!ctx) {
|
|
121238
|
-
return
|
|
121982
|
+
return textResponse6("Error: Not authenticated.");
|
|
121239
121983
|
}
|
|
121240
121984
|
const inputError = validateDeleteAttachmentInput(input.attachmentId);
|
|
121241
121985
|
if (inputError) {
|
|
121242
|
-
return
|
|
121986
|
+
return textResponse6(formatDeleteAttachmentRefusal(inputError, { ticketNumber: input.ticketId }));
|
|
121243
121987
|
}
|
|
121244
121988
|
const access = await loadAccessibleTicket(input.teamId, input.ticketId);
|
|
121245
121989
|
if (!access.ok) return access.response;
|
|
121246
121990
|
const ticket = access.ticket;
|
|
121247
121991
|
const attachment = await findAttachment(input.attachmentId);
|
|
121248
121992
|
if (!attachment) {
|
|
121249
|
-
return
|
|
121993
|
+
return textResponse6(
|
|
121250
121994
|
formatDeleteAttachmentRefusal("attachment_not_found", {
|
|
121251
121995
|
attachmentId: input.attachmentId,
|
|
121252
121996
|
ticketNumber: ticket.ticketNumber
|
|
@@ -121254,7 +121998,7 @@ async function handleDeleteTicketAttachment(input) {
|
|
|
121254
121998
|
);
|
|
121255
121999
|
}
|
|
121256
122000
|
if (!validateAttachmentBelongsToTicket(attachment.ticketId, ticket.id)) {
|
|
121257
|
-
return
|
|
122001
|
+
return textResponse6(
|
|
121258
122002
|
formatDeleteAttachmentRefusal("wrong_ticket", {
|
|
121259
122003
|
attachmentId: input.attachmentId,
|
|
121260
122004
|
ticketNumber: ticket.ticketNumber,
|
|
@@ -121266,7 +122010,7 @@ async function handleDeleteTicketAttachment(input) {
|
|
|
121266
122010
|
const table = attachment.source === "ticket" ? schema_exports.ticketAttachments : schema_exports.ticketCommentAttachments;
|
|
121267
122011
|
const [deletedRow] = await db.delete(table).where(eq(table.id, input.attachmentId)).returning({ id: table.id });
|
|
121268
122012
|
if (!deletedRow) {
|
|
121269
|
-
return
|
|
122013
|
+
return textResponse6(
|
|
121270
122014
|
`Failed to delete attachment ${input.attachmentId}. It may have been removed already.`
|
|
121271
122015
|
);
|
|
121272
122016
|
}
|
|
@@ -121296,7 +122040,7 @@ async function handleDeleteTicketAttachment(input) {
|
|
|
121296
122040
|
fileName: attachment.fileName,
|
|
121297
122041
|
source: attachment.source
|
|
121298
122042
|
});
|
|
121299
|
-
return
|
|
122043
|
+
return textResponse6(JSON.stringify(result, null, 2));
|
|
121300
122044
|
}
|
|
121301
122045
|
|
|
121302
122046
|
// src/tools/tiptap-text.ts
|
|
@@ -121713,7 +122457,7 @@ function formatTagUsage(usage) {
|
|
|
121713
122457
|
}
|
|
121714
122458
|
|
|
121715
122459
|
// src/tools/tag-management.ts
|
|
121716
|
-
function
|
|
122460
|
+
function textResponse7(text3) {
|
|
121717
122461
|
return { content: [{ type: "text", text: text3 }] };
|
|
121718
122462
|
}
|
|
121719
122463
|
var TAG_COLUMNS = {
|
|
@@ -121754,24 +122498,24 @@ function scopeFilter(projectId) {
|
|
|
121754
122498
|
return projectId === null ? isNull(schema_exports.tags.projectId) : eq(schema_exports.tags.projectId, projectId);
|
|
121755
122499
|
}
|
|
121756
122500
|
async function handleUpdateTag(input) {
|
|
121757
|
-
if (!input.tagId) return
|
|
122501
|
+
if (!input.tagId) return textResponse7("Error: `tagId` is required.");
|
|
121758
122502
|
const resolved = await resolveTeamId(input.teamId);
|
|
121759
122503
|
if (!resolved.ok) return resolved.response;
|
|
121760
122504
|
const existing = await loadTagInTeam(input.tagId, resolved.teamId);
|
|
121761
122505
|
if (!existing) {
|
|
121762
|
-
return
|
|
122506
|
+
return textResponse7(
|
|
121763
122507
|
`Tag ${input.tagId} not found, or it is not owned by this team.`
|
|
121764
122508
|
);
|
|
121765
122509
|
}
|
|
121766
122510
|
const renaming = input.name !== void 0;
|
|
121767
122511
|
const rescoping = input.projectId !== void 0;
|
|
121768
122512
|
if (!renaming && !rescoping) {
|
|
121769
|
-
return
|
|
122513
|
+
return textResponse7(
|
|
121770
122514
|
"No changes requested. Provide `name` to rename and/or `projectId` (string, or null for a general tag) to change scope."
|
|
121771
122515
|
);
|
|
121772
122516
|
}
|
|
121773
122517
|
if (renaming && !isValidTagName(input.name)) {
|
|
121774
|
-
return
|
|
122518
|
+
return textResponse7("Error: `name` cannot be empty.");
|
|
121775
122519
|
}
|
|
121776
122520
|
const nextName = renaming ? input.name.trim() : existing.name;
|
|
121777
122521
|
const nextProjectId = rescoping ? input.projectId ?? null : existing.projectId;
|
|
@@ -121784,13 +122528,13 @@ async function handleUpdateTag(input) {
|
|
|
121784
122528
|
)
|
|
121785
122529
|
).limit(1);
|
|
121786
122530
|
if (collision) {
|
|
121787
|
-
return
|
|
122531
|
+
return textResponse7(
|
|
121788
122532
|
`\u274C Cannot update: another tag already uses the name "${collision.name}" (id: ${collision.id}) in this scope. Use merge-tags to combine them instead of renaming.`
|
|
121789
122533
|
);
|
|
121790
122534
|
}
|
|
121791
122535
|
const [updated] = await db.update(schema_exports.tags).set({ name: nextName, projectId: nextProjectId }).where(eq(schema_exports.tags.id, existing.id)).returning(TAG_COLUMNS);
|
|
121792
|
-
if (!updated) return
|
|
121793
|
-
return
|
|
122536
|
+
if (!updated) return textResponse7(`Failed to update tag ${input.tagId}.`);
|
|
122537
|
+
return textResponse7(
|
|
121794
122538
|
`\u2705 **Tag updated**
|
|
121795
122539
|
|
|
121796
122540
|
${describeTag(updated)}
|
|
@@ -121799,34 +122543,34 @@ Existing ticket/customer/project/transaction tag relations are preserved.`
|
|
|
121799
122543
|
);
|
|
121800
122544
|
}
|
|
121801
122545
|
async function handleDeleteTag(input) {
|
|
121802
|
-
if (!input.tagId) return
|
|
122546
|
+
if (!input.tagId) return textResponse7("Error: `tagId` is required.");
|
|
121803
122547
|
const mode = input.mode ?? "delete_if_unused";
|
|
121804
122548
|
const resolved = await resolveTeamId(input.teamId);
|
|
121805
122549
|
if (!resolved.ok) return resolved.response;
|
|
121806
122550
|
const existing = await loadTagInTeam(input.tagId, resolved.teamId);
|
|
121807
122551
|
if (!existing) {
|
|
121808
|
-
return
|
|
122552
|
+
return textResponse7(
|
|
121809
122553
|
`Tag ${input.tagId} not found, or it is not owned by this team.`
|
|
121810
122554
|
);
|
|
121811
122555
|
}
|
|
121812
122556
|
const usage = await getTagUsage(existing.id);
|
|
121813
122557
|
const total = totalTagUsage(usage);
|
|
121814
122558
|
if (mode === "archive") {
|
|
121815
|
-
return
|
|
122559
|
+
return textResponse7(
|
|
121816
122560
|
`\u2139\uFE0F Archiving is not supported for team tags: the \`tags\` table has no archived column. ${describeTag(existing)} is used by ${formatTagUsage(usage)}.
|
|
121817
122561
|
|
|
121818
122562
|
Options: use merge-tags to fold it into another tag, or delete it once it is unused (mode: delete_if_unused).`
|
|
121819
122563
|
);
|
|
121820
122564
|
}
|
|
121821
122565
|
if (total > 0) {
|
|
121822
|
-
return
|
|
122566
|
+
return textResponse7(
|
|
121823
122567
|
`\u274C Refusing to delete ${describeTag(existing)}: it is still used by ${formatTagUsage(usage)}. Deleting would strip the tag off those entities.
|
|
121824
122568
|
|
|
121825
122569
|
Use merge-tags to move usage onto another tag first, then delete the (now-empty) tag.`
|
|
121826
122570
|
);
|
|
121827
122571
|
}
|
|
121828
122572
|
await db.delete(schema_exports.tags).where(eq(schema_exports.tags.id, existing.id));
|
|
121829
|
-
return
|
|
122573
|
+
return textResponse7(
|
|
121830
122574
|
`\u2705 **Tag deleted** (was unused): ${describeTag(existing)}`
|
|
121831
122575
|
);
|
|
121832
122576
|
}
|
|
@@ -121836,7 +122580,7 @@ async function resolveMergeTarget(teamId, input) {
|
|
|
121836
122580
|
if (!tag) {
|
|
121837
122581
|
return {
|
|
121838
122582
|
ok: false,
|
|
121839
|
-
response:
|
|
122583
|
+
response: textResponse7(
|
|
121840
122584
|
`Target tag ${input.targetTagId} not found, or it is not owned by this team.`
|
|
121841
122585
|
)
|
|
121842
122586
|
};
|
|
@@ -121846,7 +122590,7 @@ async function resolveMergeTarget(teamId, input) {
|
|
|
121846
122590
|
if (!isValidTagName(input.targetName)) {
|
|
121847
122591
|
return {
|
|
121848
122592
|
ok: false,
|
|
121849
|
-
response:
|
|
122593
|
+
response: textResponse7(
|
|
121850
122594
|
"Error: provide either `targetTagId` or a non-empty `targetName`."
|
|
121851
122595
|
)
|
|
121852
122596
|
};
|
|
@@ -121864,14 +122608,14 @@ async function resolveMergeTarget(teamId, input) {
|
|
|
121864
122608
|
}
|
|
121865
122609
|
const [created] = await db.insert(schema_exports.tags).values({ teamId, name: input.targetName.trim(), projectId: null }).returning(TAG_COLUMNS);
|
|
121866
122610
|
if (!created) {
|
|
121867
|
-
return { ok: false, response:
|
|
122611
|
+
return { ok: false, response: textResponse7("Failed to create target tag.") };
|
|
121868
122612
|
}
|
|
121869
122613
|
return { ok: true, tag: created, created: true };
|
|
121870
122614
|
}
|
|
121871
122615
|
async function handleMergeTags(input) {
|
|
121872
122616
|
const rawSourceIds = [...new Set(input.sourceTagIds ?? [])].filter(Boolean);
|
|
121873
122617
|
if (rawSourceIds.length === 0) {
|
|
121874
|
-
return
|
|
122618
|
+
return textResponse7("Error: `sourceTagIds` must contain at least one tag id.");
|
|
121875
122619
|
}
|
|
121876
122620
|
const resolved = await resolveTeamId(input.teamId);
|
|
121877
122621
|
if (!resolved.ok) return resolved.response;
|
|
@@ -121885,7 +122629,7 @@ async function handleMergeTags(input) {
|
|
|
121885
122629
|
const foundIds = new Set(sourceTags.map((t8) => t8.id));
|
|
121886
122630
|
const missing = rawSourceIds.filter((id) => !foundIds.has(id));
|
|
121887
122631
|
if (missing.length > 0) {
|
|
121888
|
-
return
|
|
122632
|
+
return textResponse7(
|
|
121889
122633
|
`Error: source tag(s) not found or not owned by this team: ${missing.join(", ")}.`
|
|
121890
122634
|
);
|
|
121891
122635
|
}
|
|
@@ -121893,7 +122637,7 @@ async function handleMergeTags(input) {
|
|
|
121893
122637
|
if (!target.ok) return target.response;
|
|
121894
122638
|
const sourcesToMerge = sourceTags.filter((t8) => t8.id !== target.tag.id);
|
|
121895
122639
|
if (sourcesToMerge.length === 0) {
|
|
121896
|
-
return
|
|
122640
|
+
return textResponse7(
|
|
121897
122641
|
"Error: nothing to merge \u2014 the only source tag is the same as the target tag."
|
|
121898
122642
|
);
|
|
121899
122643
|
}
|
|
@@ -121990,7 +122734,7 @@ async function handleMergeTags(input) {
|
|
|
121990
122734
|
const movedTotal = results.tickets.moved + results.customers.moved + results.projects.moved + results.transactions.moved;
|
|
121991
122735
|
const skippedTotal = results.tickets.skipped + results.customers.skipped + results.projects.skipped + results.transactions.skipped;
|
|
121992
122736
|
const line2 = (label, r6) => `- ${label}: ${r6.moved} moved, ${r6.skipped} skipped (duplicate)`;
|
|
121993
|
-
return
|
|
122737
|
+
return textResponse7(
|
|
121994
122738
|
`\u2705 **Tags merged** into ${describeTag(target.tag)}${target.created ? " (newly created)" : ""}
|
|
121995
122739
|
|
|
121996
122740
|
Sources (${sourcesToMerge.length}): ${sourcesToMerge.map((t8) => `${t8.name} (${t8.id})`).join(", ")}
|
|
@@ -122357,15 +123101,15 @@ var TRIP_LOCKED_FIELDS = [
|
|
|
122357
123101
|
"invoiceId",
|
|
122358
123102
|
"isInvoiced"
|
|
122359
123103
|
];
|
|
122360
|
-
function
|
|
123104
|
+
function round23(value) {
|
|
122361
123105
|
return Math.round(value * 100) / 100;
|
|
122362
123106
|
}
|
|
122363
123107
|
function deriveTripAmount(input) {
|
|
122364
123108
|
if (input.amount != null) return input.amount;
|
|
122365
123109
|
if (input.rate == null) return null;
|
|
122366
|
-
if (input.billingType === "per_trip") return
|
|
123110
|
+
if (input.billingType === "per_trip") return round23(input.rate);
|
|
122367
123111
|
if (input.billingType === "per_km" && input.distance != null) {
|
|
122368
|
-
return
|
|
123112
|
+
return round23(input.distance * input.rate);
|
|
122369
123113
|
}
|
|
122370
123114
|
return null;
|
|
122371
123115
|
}
|
|
@@ -122376,7 +123120,7 @@ function attemptedLockedFields(update) {
|
|
|
122376
123120
|
// src/tools/trips.ts
|
|
122377
123121
|
var TRIP_TYPES = ["private", "business"];
|
|
122378
123122
|
var BILLING_TYPES2 = TRIP_BILLING_TYPES;
|
|
122379
|
-
function
|
|
123123
|
+
function textResponse8(text3) {
|
|
122380
123124
|
return { content: [{ type: "text", text: text3 }] };
|
|
122381
123125
|
}
|
|
122382
123126
|
function jsonResponse(payload) {
|
|
@@ -122432,19 +123176,19 @@ var TRIP_RELATIONS = {
|
|
|
122432
123176
|
};
|
|
122433
123177
|
async function handleGetTrips(input) {
|
|
122434
123178
|
if (input.tripType && !TRIP_TYPES.includes(input.tripType)) {
|
|
122435
|
-
return
|
|
123179
|
+
return textResponse8(
|
|
122436
123180
|
`Error: invalid tripType "${input.tripType}". Allowed: ${TRIP_TYPES.join(", ")}.`
|
|
122437
123181
|
);
|
|
122438
123182
|
}
|
|
122439
123183
|
if (input.billingType && !BILLING_TYPES2.includes(input.billingType)) {
|
|
122440
|
-
return
|
|
123184
|
+
return textResponse8(
|
|
122441
123185
|
`Error: invalid billingType "${input.billingType}". Allowed: ${BILLING_TYPES2.join(", ")}.`
|
|
122442
123186
|
);
|
|
122443
123187
|
}
|
|
122444
123188
|
const scope = await resolveTeamScope(input.teamId);
|
|
122445
123189
|
if (!scope.ok) return scope.response;
|
|
122446
123190
|
if (scope.teamIds.length === 0) {
|
|
122447
|
-
return
|
|
123191
|
+
return textResponse8("No accessible teams found.");
|
|
122448
123192
|
}
|
|
122449
123193
|
const filters = [inArray(schema_exports.trips.teamId, scope.teamIds)];
|
|
122450
123194
|
if (input.dateFrom) filters.push(gte(schema_exports.trips.date, input.dateFrom));
|
|
@@ -122489,10 +123233,10 @@ async function handleGetTrips(input) {
|
|
|
122489
123233
|
return jsonResponse({
|
|
122490
123234
|
count: rows.length,
|
|
122491
123235
|
totals: {
|
|
122492
|
-
businessKm:
|
|
122493
|
-
privateKm:
|
|
122494
|
-
totalKm:
|
|
122495
|
-
totalAmount:
|
|
123236
|
+
businessKm: round23(totals.businessKm),
|
|
123237
|
+
privateKm: round23(totals.privateKm),
|
|
123238
|
+
totalKm: round23(totals.totalKm),
|
|
123239
|
+
totalAmount: round23(totals.totalAmount)
|
|
122496
123240
|
},
|
|
122497
123241
|
trips: rows.map(formatTrip)
|
|
122498
123242
|
});
|
|
@@ -122546,20 +123290,20 @@ async function validateInvoice(invoiceId, teamId) {
|
|
|
122546
123290
|
}
|
|
122547
123291
|
async function handleCreateTrip(input) {
|
|
122548
123292
|
const ctx = getAuthContext();
|
|
122549
|
-
if (!input.date) return
|
|
123293
|
+
if (!input.date) return textResponse8("Error: `date` (YYYY-MM-DD) is required.");
|
|
122550
123294
|
if (!input.startLocation || !input.endLocation) {
|
|
122551
|
-
return
|
|
123295
|
+
return textResponse8(
|
|
122552
123296
|
"Error: `startLocation` and `endLocation` are required."
|
|
122553
123297
|
);
|
|
122554
123298
|
}
|
|
122555
123299
|
if (!input.tripType || !TRIP_TYPES.includes(input.tripType)) {
|
|
122556
|
-
return
|
|
123300
|
+
return textResponse8(
|
|
122557
123301
|
`Error: \`tripType\` is required and must be one of: ${TRIP_TYPES.join(", ")}.`
|
|
122558
123302
|
);
|
|
122559
123303
|
}
|
|
122560
123304
|
const billingType = input.billingType ?? "not_billable";
|
|
122561
123305
|
if (!BILLING_TYPES2.includes(billingType)) {
|
|
122562
|
-
return
|
|
123306
|
+
return textResponse8(
|
|
122563
123307
|
`Error: invalid billingType "${input.billingType}". Allowed: ${BILLING_TYPES2.join(", ")}.`
|
|
122564
123308
|
);
|
|
122565
123309
|
}
|
|
@@ -122571,7 +123315,7 @@ async function handleCreateTrip(input) {
|
|
|
122571
123315
|
customerId: input.customerId,
|
|
122572
123316
|
vehicleId: input.vehicleId
|
|
122573
123317
|
});
|
|
122574
|
-
if (linkError) return
|
|
123318
|
+
if (linkError) return textResponse8(`Error: ${linkError}`);
|
|
122575
123319
|
if (!input.allowDuplicate) {
|
|
122576
123320
|
const dupFilters = [
|
|
122577
123321
|
eq(schema_exports.trips.teamId, teamId),
|
|
@@ -122588,7 +123332,7 @@ async function handleCreateTrip(input) {
|
|
|
122588
123332
|
}
|
|
122589
123333
|
const [dup] = await db.select({ id: schema_exports.trips.id, distance: schema_exports.trips.distance }).from(schema_exports.trips).where(and(...dupFilters)).limit(1);
|
|
122590
123334
|
if (dup) {
|
|
122591
|
-
return
|
|
123335
|
+
return textResponse8(
|
|
122592
123336
|
`\u26A0\uFE0F A matching trip already exists for ${input.date} (${input.startLocation} \u2192 ${input.endLocation}): trip ${dup.id}${dup.distance != null ? ` (${toNumber2(dup.distance)} km)` : ""}. Not creating a duplicate. Use update-trip to adjust it, or re-call create-trip with allowDuplicate: true to record a second trip anyway.`
|
|
122593
123337
|
);
|
|
122594
123338
|
}
|
|
@@ -122618,7 +123362,7 @@ async function handleCreateTrip(input) {
|
|
|
122618
123362
|
vehicleId: input.vehicleId ?? null,
|
|
122619
123363
|
snapshotId: input.snapshotId ?? null
|
|
122620
123364
|
}).returning({ id: schema_exports.trips.id });
|
|
122621
|
-
if (!created) return
|
|
123365
|
+
if (!created) return textResponse8("Failed to create trip.");
|
|
122622
123366
|
const trip = await loadTripInTeams(created.id, [teamId]);
|
|
122623
123367
|
return {
|
|
122624
123368
|
content: [
|
|
@@ -122633,14 +123377,14 @@ ${JSON.stringify(formatTrip(trip), null, 2)}`
|
|
|
122633
123377
|
}
|
|
122634
123378
|
async function handleUpdateTrip(input) {
|
|
122635
123379
|
const ctx = getAuthContext();
|
|
122636
|
-
if (!input.id) return
|
|
123380
|
+
if (!input.id) return textResponse8("Error: `id` is required.");
|
|
122637
123381
|
if (input.tripType && !TRIP_TYPES.includes(input.tripType)) {
|
|
122638
|
-
return
|
|
123382
|
+
return textResponse8(
|
|
122639
123383
|
`Error: invalid tripType "${input.tripType}". Allowed: ${TRIP_TYPES.join(", ")}.`
|
|
122640
123384
|
);
|
|
122641
123385
|
}
|
|
122642
123386
|
if (input.billingType && !BILLING_TYPES2.includes(input.billingType)) {
|
|
122643
|
-
return
|
|
123387
|
+
return textResponse8(
|
|
122644
123388
|
`Error: invalid billingType "${input.billingType}". Allowed: ${BILLING_TYPES2.join(", ")}.`
|
|
122645
123389
|
);
|
|
122646
123390
|
}
|
|
@@ -122649,7 +123393,7 @@ async function handleUpdateTrip(input) {
|
|
|
122649
123393
|
const accessibleTeamIds = await getAccessibleTeamIds(resolved.teamId);
|
|
122650
123394
|
const existing = await loadTripInTeams(input.id, accessibleTeamIds);
|
|
122651
123395
|
if (!existing) {
|
|
122652
|
-
return
|
|
123396
|
+
return textResponse8(
|
|
122653
123397
|
`Trip ${input.id} not found or you don't have access to it. Call get-trips to find a valid id.`
|
|
122654
123398
|
);
|
|
122655
123399
|
}
|
|
@@ -122660,7 +123404,7 @@ async function handleUpdateTrip(input) {
|
|
|
122660
123404
|
input
|
|
122661
123405
|
);
|
|
122662
123406
|
if (attempted.length > 0) {
|
|
122663
|
-
return
|
|
123407
|
+
return textResponse8(
|
|
122664
123408
|
`Error: trip ${input.id} is invoiced${existing.invoiceId ? ` (invoice ${existing.invoiceId})` : ""}. Financial/distance fields are locked: ${attempted.join(", ")}. Re-call with allowInvoicedOverride: true to change them anyway, or only update project/customer/notes/vehicle links.`
|
|
122665
123409
|
);
|
|
122666
123410
|
}
|
|
@@ -122670,10 +123414,10 @@ async function handleUpdateTrip(input) {
|
|
|
122670
123414
|
customerId: input.customerId ?? void 0,
|
|
122671
123415
|
vehicleId: input.vehicleId ?? void 0
|
|
122672
123416
|
});
|
|
122673
|
-
if (linkError) return
|
|
123417
|
+
if (linkError) return textResponse8(`Error: ${linkError}`);
|
|
122674
123418
|
if (input.invoiceId) {
|
|
122675
123419
|
const invoiceError = await validateInvoice(input.invoiceId, teamId);
|
|
122676
|
-
if (invoiceError) return
|
|
123420
|
+
if (invoiceError) return textResponse8(`Error: ${invoiceError}`);
|
|
122677
123421
|
}
|
|
122678
123422
|
const updates = {};
|
|
122679
123423
|
if (input.date !== void 0) updates.date = input.date;
|
|
@@ -122721,7 +123465,7 @@ async function handleUpdateTrip(input) {
|
|
|
122721
123465
|
if (derived != null) updates.amount = derived;
|
|
122722
123466
|
}
|
|
122723
123467
|
if (Object.keys(updates).length === 0) {
|
|
122724
|
-
return
|
|
123468
|
+
return textResponse8(
|
|
122725
123469
|
"No fields to update. Provide at least one editable field."
|
|
122726
123470
|
);
|
|
122727
123471
|
}
|
|
@@ -122748,7 +123492,7 @@ async function handleGetVehicles(input) {
|
|
|
122748
123492
|
const scope = await resolveTeamScope(input.teamId);
|
|
122749
123493
|
if (!scope.ok) return scope.response;
|
|
122750
123494
|
if (scope.teamIds.length === 0) {
|
|
122751
|
-
return
|
|
123495
|
+
return textResponse8("No accessible teams found.");
|
|
122752
123496
|
}
|
|
122753
123497
|
const filters = [inArray(schema_exports.vehicles.teamId, scope.teamIds)];
|
|
122754
123498
|
if (input.q) filters.push(ilike(schema_exports.vehicles.name, `%${input.q}%`));
|
|
@@ -122774,7 +123518,7 @@ async function handleGetTripTemplates(input) {
|
|
|
122774
123518
|
const scope = await resolveTeamScope(input.teamId);
|
|
122775
123519
|
if (!scope.ok) return scope.response;
|
|
122776
123520
|
if (scope.teamIds.length === 0) {
|
|
122777
|
-
return
|
|
123521
|
+
return textResponse8("No accessible teams found.");
|
|
122778
123522
|
}
|
|
122779
123523
|
const filters = [inArray(schema_exports.tripTemplates.teamId, scope.teamIds)];
|
|
122780
123524
|
const userId = input.userId ?? ctx.userId;
|
|
@@ -122810,14 +123554,14 @@ async function handleGetTripTemplates(input) {
|
|
|
122810
123554
|
async function handleGetFrequentTripsForProject(input) {
|
|
122811
123555
|
const ctx = getAuthContext();
|
|
122812
123556
|
if (!input.projectId) {
|
|
122813
|
-
return
|
|
123557
|
+
return textResponse8("Error: `projectId` is required.");
|
|
122814
123558
|
}
|
|
122815
123559
|
const resolved = await resolveTeamId(input.teamId);
|
|
122816
123560
|
if (!resolved.ok) return resolved.response;
|
|
122817
123561
|
const teamId = resolved.teamId;
|
|
122818
123562
|
const projectIds = await getAccessibleProjectIds(ctx.userId, teamId);
|
|
122819
123563
|
if (!projectIds.includes(input.projectId)) {
|
|
122820
|
-
return
|
|
123564
|
+
return textResponse8(
|
|
122821
123565
|
`Project not found or no access: ${input.projectId}. Call get-projects first.`
|
|
122822
123566
|
);
|
|
122823
123567
|
}
|
|
@@ -122852,7 +123596,7 @@ async function handleGetFrequentTripsForProject(input) {
|
|
|
122852
123596
|
endLocation: g6.endLocation,
|
|
122853
123597
|
tripType: g6.tripType,
|
|
122854
123598
|
count: g6.count,
|
|
122855
|
-
avgDistance: g6.avgDistance != null ?
|
|
123599
|
+
avgDistance: g6.avgDistance != null ? round23(toNumber2(g6.avgDistance)) : null,
|
|
122856
123600
|
lastUsedDate: g6.lastUsedDate
|
|
122857
123601
|
}))
|
|
122858
123602
|
});
|
|
@@ -123442,6 +124186,22 @@ function createMcpServer() {
|
|
|
123442
124186
|
);
|
|
123443
124187
|
case "get-invoices":
|
|
123444
124188
|
return await handleGetInvoices(asToolArgs(toolArgs));
|
|
124189
|
+
case "get-invoice-by-id":
|
|
124190
|
+
return await handleGetInvoiceById(
|
|
124191
|
+
asToolArgs(toolArgs)
|
|
124192
|
+
);
|
|
124193
|
+
case "update-invoice":
|
|
124194
|
+
return await handleUpdateInvoice(
|
|
124195
|
+
asToolArgs(toolArgs)
|
|
124196
|
+
);
|
|
124197
|
+
case "update-invoice-lines":
|
|
124198
|
+
return await handleUpdateInvoiceLines(
|
|
124199
|
+
asToolArgs(toolArgs)
|
|
124200
|
+
);
|
|
124201
|
+
case "add-product-to-invoice":
|
|
124202
|
+
return await handleAddProductToInvoice(
|
|
124203
|
+
asToolArgs(toolArgs)
|
|
124204
|
+
);
|
|
123445
124205
|
case "link-document-to-invoice":
|
|
123446
124206
|
return await handleLinkDocumentToInvoice(
|
|
123447
124207
|
asToolArgs(toolArgs)
|