@mgsoftwarebv/mcp-server-bridge 3.5.0 → 3.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1936 -154
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -105974,7 +105974,7 @@ var TOOLS = [
|
|
|
105974
105974
|
},
|
|
105975
105975
|
{
|
|
105976
105976
|
name: "get-customers",
|
|
105977
|
-
description: "Get customers with optional search",
|
|
105977
|
+
description: "Get customers with optional search. Each result includes its ID (UUID), name, email, website, phone, status, archived flag, and created date. Archived customers are hidden by default; pass status 'archived' or 'all' to include them.",
|
|
105978
105978
|
inputSchema: {
|
|
105979
105979
|
type: "object",
|
|
105980
105980
|
properties: {
|
|
@@ -105983,6 +105983,12 @@ var TOOLS = [
|
|
|
105983
105983
|
type: "string",
|
|
105984
105984
|
description: "Search query for customer name or email"
|
|
105985
105985
|
},
|
|
105986
|
+
status: {
|
|
105987
|
+
type: "string",
|
|
105988
|
+
enum: ["active", "archived", "all"],
|
|
105989
|
+
default: "active",
|
|
105990
|
+
description: "Archive filter: 'active' (default, hides archived), 'archived', or 'all'."
|
|
105991
|
+
},
|
|
105986
105992
|
pageSize: { type: "number", default: 20, maximum: 100 }
|
|
105987
105993
|
},
|
|
105988
105994
|
required: []
|
|
@@ -105990,7 +105996,7 @@ var TOOLS = [
|
|
|
105990
105996
|
},
|
|
105991
105997
|
{
|
|
105992
105998
|
name: "create-customer",
|
|
105993
|
-
description: "Create a new customer",
|
|
105999
|
+
description: "Create a new customer. Returns the created customer including its ID (UUID).",
|
|
105994
106000
|
inputSchema: {
|
|
105995
106001
|
type: "object",
|
|
105996
106002
|
properties: {
|
|
@@ -106002,6 +106008,94 @@ var TOOLS = [
|
|
|
106002
106008
|
required: ["name"]
|
|
106003
106009
|
}
|
|
106004
106010
|
},
|
|
106011
|
+
{
|
|
106012
|
+
name: "update-customer",
|
|
106013
|
+
description: "Update an existing customer's editable fields. Use this to fix a customer that was created with a wrong name/email/website. Only provided fields change. Set isArchived to false to reactivate an archived customer, or true to archive it (archive-customer is the friendlier way). Find the customer id via get-customers.",
|
|
106014
|
+
inputSchema: {
|
|
106015
|
+
type: "object",
|
|
106016
|
+
properties: {
|
|
106017
|
+
teamId: teamIdProp,
|
|
106018
|
+
customerId: { type: "string", description: "Customer ID (UUID) to update" },
|
|
106019
|
+
name: { type: "string" },
|
|
106020
|
+
email: {
|
|
106021
|
+
type: "string",
|
|
106022
|
+
description: "Email (required column \u2014 cannot be set empty)."
|
|
106023
|
+
},
|
|
106024
|
+
website: { type: ["string", "null"] },
|
|
106025
|
+
phone: { type: ["string", "null"] },
|
|
106026
|
+
companyName: { type: ["string", "null"] },
|
|
106027
|
+
billingEmail: { type: ["string", "null"] },
|
|
106028
|
+
vatNumber: { type: ["string", "null"] },
|
|
106029
|
+
contact: { type: ["string", "null"] },
|
|
106030
|
+
note: { type: ["string", "null"] },
|
|
106031
|
+
addressLine1: { type: ["string", "null"] },
|
|
106032
|
+
addressLine2: { type: ["string", "null"] },
|
|
106033
|
+
city: { type: ["string", "null"] },
|
|
106034
|
+
state: { type: ["string", "null"] },
|
|
106035
|
+
zip: { type: ["string", "null"] },
|
|
106036
|
+
country: { type: ["string", "null"] },
|
|
106037
|
+
countryCode: { type: ["string", "null"] },
|
|
106038
|
+
status: {
|
|
106039
|
+
type: "string",
|
|
106040
|
+
enum: ["active", "inactive", "prospect", "churned"],
|
|
106041
|
+
description: "Customer relationship status."
|
|
106042
|
+
},
|
|
106043
|
+
isArchived: {
|
|
106044
|
+
type: "boolean",
|
|
106045
|
+
description: "Set false to reactivate an archived customer, true to archive."
|
|
106046
|
+
}
|
|
106047
|
+
},
|
|
106048
|
+
required: ["customerId"]
|
|
106049
|
+
}
|
|
106050
|
+
},
|
|
106051
|
+
{
|
|
106052
|
+
name: "archive-customer",
|
|
106053
|
+
description: "Safely archive (soft-retire) a customer \u2014 the recommended way to clean up a mistakenly-created customer. Reversible and non-destructive: it keeps all projects, tickets, invoices, documents and other data, sets is_archived=true (status=inactive) and only hides the customer from get-customers by default. Identify the customer by `customerId` (preferred), or by an exact `customerName` and/or `email` \u2014 if more than one customer matches, the call is refused and the matches are listed. Use update-customer (isArchived: false) to reactivate.",
|
|
106054
|
+
inputSchema: {
|
|
106055
|
+
type: "object",
|
|
106056
|
+
properties: {
|
|
106057
|
+
teamId: teamIdProp,
|
|
106058
|
+
customerId: { type: "string", description: "Customer ID (UUID) to archive" },
|
|
106059
|
+
customerName: {
|
|
106060
|
+
type: "string",
|
|
106061
|
+
description: "Exact customer name (case-insensitive). Refused if it matches multiple customers."
|
|
106062
|
+
},
|
|
106063
|
+
email: {
|
|
106064
|
+
type: "string",
|
|
106065
|
+
description: "Exact customer email (case-insensitive). Refused if it matches multiple customers."
|
|
106066
|
+
},
|
|
106067
|
+
reason: {
|
|
106068
|
+
type: "string",
|
|
106069
|
+
description: "Optional note explaining why the customer is archived"
|
|
106070
|
+
}
|
|
106071
|
+
},
|
|
106072
|
+
required: []
|
|
106073
|
+
}
|
|
106074
|
+
},
|
|
106075
|
+
{
|
|
106076
|
+
name: "delete-customer",
|
|
106077
|
+
description: "Permanently hard-delete a customer, but ONLY when it is empty (no projects, tickets, invoices, quotations, documents, agenda/time entries, timesheet templates, trips, or trip templates). If any such dependencies exist the delete is rejected with a dependency summary \u2014 use archive-customer instead (deleting would cascade-delete the customer's projects). Identify the customer by `customerId` (preferred) or an exact `customerName`/`email` (refused on multiple matches). Requires team OWNER privileges and confirmEmptyOnly: true as an explicit safety interlock.",
|
|
106078
|
+
inputSchema: {
|
|
106079
|
+
type: "object",
|
|
106080
|
+
properties: {
|
|
106081
|
+
teamId: teamIdProp,
|
|
106082
|
+
customerId: { type: "string", description: "Customer ID (UUID) to delete" },
|
|
106083
|
+
customerName: {
|
|
106084
|
+
type: "string",
|
|
106085
|
+
description: "Exact customer name (case-insensitive). Refused on multiple matches."
|
|
106086
|
+
},
|
|
106087
|
+
email: {
|
|
106088
|
+
type: "string",
|
|
106089
|
+
description: "Exact customer email (case-insensitive). Refused on multiple matches."
|
|
106090
|
+
},
|
|
106091
|
+
confirmEmptyOnly: {
|
|
106092
|
+
type: "boolean",
|
|
106093
|
+
description: "Must be true to authorise the hard delete of an empty customer."
|
|
106094
|
+
}
|
|
106095
|
+
},
|
|
106096
|
+
required: []
|
|
106097
|
+
}
|
|
106098
|
+
},
|
|
106005
106099
|
{
|
|
106006
106100
|
name: "get-projects",
|
|
106007
106101
|
description: "Get projects with optional filtering. Each project includes its ID and, when archived, its archive timestamp/reason. Archived projects are hidden by default; pass status 'archived' or 'all' to include them.",
|
|
@@ -106484,6 +106578,148 @@ var TOOLS = [
|
|
|
106484
106578
|
required: ["productId"]
|
|
106485
106579
|
}
|
|
106486
106580
|
},
|
|
106581
|
+
{
|
|
106582
|
+
name: "get-quotes",
|
|
106583
|
+
description: "List quotes/offertes (the `quotations` module) with optional filtering by customer, status, or a search on quote number / customer name. Each entry includes its ID (UUID), quote number, customer, status, totals (amount/subtotal/VAT), validUntil and createdAt. Note: quotations are not linked to projects, so `projectId` is accepted but ignored.",
|
|
106584
|
+
inputSchema: {
|
|
106585
|
+
type: "object",
|
|
106586
|
+
properties: {
|
|
106587
|
+
teamId: teamIdProp,
|
|
106588
|
+
customerId: { type: "string", description: "Filter by customer ID" },
|
|
106589
|
+
projectId: {
|
|
106590
|
+
type: "string",
|
|
106591
|
+
description: "Accepted for API symmetry but ignored (quotes are not linked to projects)."
|
|
106592
|
+
},
|
|
106593
|
+
status: {
|
|
106594
|
+
type: "string",
|
|
106595
|
+
enum: ["draft", "sent", "accepted", "rejected", "expired"],
|
|
106596
|
+
description: "Filter by quote status (e.g. 'draft' for concepts)"
|
|
106597
|
+
},
|
|
106598
|
+
q: {
|
|
106599
|
+
type: "string",
|
|
106600
|
+
description: "Search query for quote number or customer name"
|
|
106601
|
+
},
|
|
106602
|
+
pageSize: { type: "number", default: 20, maximum: 100 }
|
|
106603
|
+
},
|
|
106604
|
+
required: []
|
|
106605
|
+
}
|
|
106606
|
+
},
|
|
106607
|
+
{
|
|
106608
|
+
name: "create-quote",
|
|
106609
|
+
description: "Create a DRAFT quote/offerte for a customer. Draft-only by design: this tool can only create status `draft` and rejects any other status \u2014 sending/accepting a quote stays a manual dashboard action. Applies the team's default quotation template (currency, VAT rate, labels) and computes totals. `lineItems` may be free-form ({name, quantity, unit, price}) or product-backed ({productId, quantity, optional name/price overrides}); product-backed items store an immutable product snapshot on the line item.",
|
|
106610
|
+
inputSchema: {
|
|
106611
|
+
type: "object",
|
|
106612
|
+
properties: {
|
|
106613
|
+
teamId: teamIdProp,
|
|
106614
|
+
customerId: { type: "string", description: "Customer ID (required)" },
|
|
106615
|
+
projectId: {
|
|
106616
|
+
type: "string",
|
|
106617
|
+
description: "Accepted for API symmetry but not persisted on quotes."
|
|
106618
|
+
},
|
|
106619
|
+
title: {
|
|
106620
|
+
type: "string",
|
|
106621
|
+
description: "Overrides the per-quote template title (e.g. 'Offerte')."
|
|
106622
|
+
},
|
|
106623
|
+
description: {
|
|
106624
|
+
type: "string",
|
|
106625
|
+
description: "Customer-facing note rendered on the quote."
|
|
106626
|
+
},
|
|
106627
|
+
status: {
|
|
106628
|
+
type: "string",
|
|
106629
|
+
enum: ["draft"],
|
|
106630
|
+
default: "draft",
|
|
106631
|
+
description: "Only 'draft' is allowed."
|
|
106632
|
+
},
|
|
106633
|
+
validUntil: {
|
|
106634
|
+
type: "string",
|
|
106635
|
+
description: "ISO date the quote is valid until (e.g. 2026-07-31)."
|
|
106636
|
+
},
|
|
106637
|
+
lineItems: {
|
|
106638
|
+
type: "array",
|
|
106639
|
+
description: "Line items. Each: { name?, quantity?, unit?, price?, productId? }. With productId the catalog product is snapshotted onto the line item (name/price overridable).",
|
|
106640
|
+
items: {
|
|
106641
|
+
type: "object",
|
|
106642
|
+
properties: {
|
|
106643
|
+
name: { type: "string" },
|
|
106644
|
+
quantity: { type: "number" },
|
|
106645
|
+
unit: { type: "string" },
|
|
106646
|
+
price: { type: "number", description: "Unit price excl. VAT" },
|
|
106647
|
+
productId: {
|
|
106648
|
+
type: "string",
|
|
106649
|
+
description: "Catalog product ID to snapshot onto this item"
|
|
106650
|
+
}
|
|
106651
|
+
}
|
|
106652
|
+
}
|
|
106653
|
+
}
|
|
106654
|
+
},
|
|
106655
|
+
required: ["customerId"]
|
|
106656
|
+
}
|
|
106657
|
+
},
|
|
106658
|
+
{
|
|
106659
|
+
name: "update-quote",
|
|
106660
|
+
description: "Update a DRAFT quote/offerte. Only quotes still in status `draft` can be changed \u2014 sent/accepted/rejected/expired quotes are immutable here so their product snapshots stay reproducible. Status can only stay `draft`; approve/send/accept/reject/expire are blocked and must be done manually from the dashboard. Provide `lineItems` to REPLACE all items (totals recomputed; productId items are re-snapshotted).",
|
|
106661
|
+
inputSchema: {
|
|
106662
|
+
type: "object",
|
|
106663
|
+
properties: {
|
|
106664
|
+
teamId: teamIdProp,
|
|
106665
|
+
id: { type: "string", description: "Quote ID (UUID)" },
|
|
106666
|
+
title: { type: "string" },
|
|
106667
|
+
description: {
|
|
106668
|
+
type: ["string", "null"],
|
|
106669
|
+
description: "Customer-facing note; null clears it."
|
|
106670
|
+
},
|
|
106671
|
+
validUntil: {
|
|
106672
|
+
type: ["string", "null"],
|
|
106673
|
+
description: "ISO date; null clears it."
|
|
106674
|
+
},
|
|
106675
|
+
status: {
|
|
106676
|
+
type: "string",
|
|
106677
|
+
enum: ["draft"],
|
|
106678
|
+
description: "Only 'draft' is allowed."
|
|
106679
|
+
},
|
|
106680
|
+
lineItems: {
|
|
106681
|
+
type: "array",
|
|
106682
|
+
description: "Replaces ALL line items. Each: { name?, quantity?, unit?, price?, productId? }.",
|
|
106683
|
+
items: {
|
|
106684
|
+
type: "object",
|
|
106685
|
+
properties: {
|
|
106686
|
+
name: { type: "string" },
|
|
106687
|
+
quantity: { type: "number" },
|
|
106688
|
+
unit: { type: "string" },
|
|
106689
|
+
price: { type: "number", description: "Unit price excl. VAT" },
|
|
106690
|
+
productId: { type: "string" }
|
|
106691
|
+
}
|
|
106692
|
+
}
|
|
106693
|
+
}
|
|
106694
|
+
},
|
|
106695
|
+
required: ["id"]
|
|
106696
|
+
}
|
|
106697
|
+
},
|
|
106698
|
+
{
|
|
106699
|
+
name: "add-product-to-quote",
|
|
106700
|
+
description: "Add a catalog product as a new line item on a DRAFT quote, storing an immutable product snapshot (name, description, unitPrice, currency, vatRate, unit, metadata) on the line item. Only works on `draft` quotes. Later catalog product edits never mutate this quote \u2014 it keeps its snapshot. Recomputes the quote totals.",
|
|
106701
|
+
inputSchema: {
|
|
106702
|
+
type: "object",
|
|
106703
|
+
properties: {
|
|
106704
|
+
teamId: teamIdProp,
|
|
106705
|
+
quoteId: { type: "string", description: "Quote ID (UUID)" },
|
|
106706
|
+
productId: {
|
|
106707
|
+
type: "string",
|
|
106708
|
+
description: "Catalog product ID (see get-products)"
|
|
106709
|
+
},
|
|
106710
|
+
quantity: { type: "number", default: 1 },
|
|
106711
|
+
customDescription: {
|
|
106712
|
+
type: "string",
|
|
106713
|
+
description: "Overrides the snapshotted product name on this line item."
|
|
106714
|
+
},
|
|
106715
|
+
customPrice: {
|
|
106716
|
+
type: "number",
|
|
106717
|
+
description: "Overrides the snapshotted unit price (excl. VAT)."
|
|
106718
|
+
}
|
|
106719
|
+
},
|
|
106720
|
+
required: ["quoteId", "productId"]
|
|
106721
|
+
}
|
|
106722
|
+
},
|
|
106487
106723
|
{
|
|
106488
106724
|
name: "log-hours",
|
|
106489
106725
|
description: "Analyze current chat conversation and log hours as draft tracker entry. AI analyzes chat context to estimate hours as a senior developer would (without AI assistance). Cursor AI matches workspace name to correct project from list (optional).",
|
|
@@ -106515,6 +106751,167 @@ var TOOLS = [
|
|
|
106515
106751
|
required: ["workDescription", "estimatedHours"]
|
|
106516
106752
|
}
|
|
106517
106753
|
},
|
|
106754
|
+
{
|
|
106755
|
+
name: "get-trips",
|
|
106756
|
+
description: "List trips / kilometer registration entries (rides) scoped to your provider team(s), with optional filters by period (dateFrom/dateTo), user, project, customer, trip type (business/private), billing type, and invoiced status. Returns each trip's id, date, start/end location, distance (km), odometer readings, trip type, billing type, rate/amount, linked user/project/customer/invoice/vehicle, plus aggregate business/private/total km and total amount.",
|
|
106757
|
+
inputSchema: {
|
|
106758
|
+
type: "object",
|
|
106759
|
+
properties: {
|
|
106760
|
+
teamId: teamIdProp,
|
|
106761
|
+
dateFrom: {
|
|
106762
|
+
type: "string",
|
|
106763
|
+
description: "Inclusive period start (YYYY-MM-DD)."
|
|
106764
|
+
},
|
|
106765
|
+
dateTo: {
|
|
106766
|
+
type: "string",
|
|
106767
|
+
description: "Inclusive period end (YYYY-MM-DD)."
|
|
106768
|
+
},
|
|
106769
|
+
userId: { type: "string", description: "Filter by driver user ID" },
|
|
106770
|
+
projectId: { type: "string", description: "Filter by project ID" },
|
|
106771
|
+
customerId: { type: "string", description: "Filter by customer ID" },
|
|
106772
|
+
tripType: { type: "string", enum: ["private", "business"] },
|
|
106773
|
+
billingType: {
|
|
106774
|
+
type: "string",
|
|
106775
|
+
enum: ["not_billable", "per_km", "per_trip"]
|
|
106776
|
+
},
|
|
106777
|
+
isInvoiced: {
|
|
106778
|
+
type: "boolean",
|
|
106779
|
+
description: "Filter by invoiced status"
|
|
106780
|
+
},
|
|
106781
|
+
pageSize: { type: "number", default: 50, maximum: 200 }
|
|
106782
|
+
},
|
|
106783
|
+
required: []
|
|
106784
|
+
}
|
|
106785
|
+
},
|
|
106786
|
+
{
|
|
106787
|
+
name: "create-trip",
|
|
106788
|
+
description: "Record a confirmed trip (kilometer registration entry) for the API key user in the resolved provider team. When `amount` is omitted it is auto-derived: distance * rate for billingType per_km, or the flat rate for per_trip. Distances/odometer are in km. Validates project/customer/vehicle access. Use get-projects/get-customers/get-vehicles to resolve ids first. Duplicate detection: a trip with the same driver, date and route (+ project/customer when given) is refused unless allowDuplicate: true.",
|
|
106789
|
+
inputSchema: {
|
|
106790
|
+
type: "object",
|
|
106791
|
+
properties: {
|
|
106792
|
+
teamId: teamIdProp,
|
|
106793
|
+
date: { type: "string", description: "Trip date (YYYY-MM-DD)" },
|
|
106794
|
+
startLocation: { type: "string" },
|
|
106795
|
+
endLocation: { type: "string" },
|
|
106796
|
+
tripType: { type: "string", enum: ["private", "business"] },
|
|
106797
|
+
distance: { type: "number", description: "Distance in km" },
|
|
106798
|
+
odometerStart: { type: "number" },
|
|
106799
|
+
odometerEnd: { type: "number" },
|
|
106800
|
+
projectId: { type: "string" },
|
|
106801
|
+
customerId: { type: "string" },
|
|
106802
|
+
billingType: {
|
|
106803
|
+
type: "string",
|
|
106804
|
+
enum: ["not_billable", "per_km", "per_trip"],
|
|
106805
|
+
default: "not_billable"
|
|
106806
|
+
},
|
|
106807
|
+
rate: { type: "number", description: "Rate per km (per_km) or per trip (per_trip)" },
|
|
106808
|
+
amount: {
|
|
106809
|
+
type: "number",
|
|
106810
|
+
description: "Total amount. Auto-derived from distance*rate (per_km) or rate (per_trip) when omitted."
|
|
106811
|
+
},
|
|
106812
|
+
notes: { type: "string" },
|
|
106813
|
+
vehicleId: { type: "string" },
|
|
106814
|
+
snapshotId: { type: "string" },
|
|
106815
|
+
allowDuplicate: {
|
|
106816
|
+
type: "boolean",
|
|
106817
|
+
description: "Set true to skip duplicate detection and record a second trip with the same driver/date/route."
|
|
106818
|
+
}
|
|
106819
|
+
},
|
|
106820
|
+
required: ["date", "startLocation", "endLocation", "tripType"]
|
|
106821
|
+
}
|
|
106822
|
+
},
|
|
106823
|
+
{
|
|
106824
|
+
name: "update-trip",
|
|
106825
|
+
description: "Update an existing trip and/or (re)link it to a project, customer or invoice. Only provided fields change. SAFETY: once a trip is invoiced (isInvoiced true or invoiceId set), the financial/distance fields (date, locations, tripType, distance, odometer, billingType, rate, amount, invoiceId, isInvoiced) are LOCKED \u2014 the call is rejected unless you pass allowInvoicedOverride: true. Project/customer/notes/vehicle links remain editable regardless. Setting invoiceId also marks the trip invoiced (pass invoiceId: null to unlink). Find trip ids via get-trips.",
|
|
106826
|
+
inputSchema: {
|
|
106827
|
+
type: "object",
|
|
106828
|
+
properties: {
|
|
106829
|
+
teamId: teamIdProp,
|
|
106830
|
+
id: { type: "string", description: "Trip ID (UUID)" },
|
|
106831
|
+
date: { type: "string", description: "YYYY-MM-DD" },
|
|
106832
|
+
startLocation: { type: "string" },
|
|
106833
|
+
endLocation: { type: "string" },
|
|
106834
|
+
tripType: { type: "string", enum: ["private", "business"] },
|
|
106835
|
+
distance: { type: ["number", "null"], description: "Distance in km" },
|
|
106836
|
+
odometerStart: { type: ["number", "null"] },
|
|
106837
|
+
odometerEnd: { type: ["number", "null"] },
|
|
106838
|
+
projectId: { type: ["string", "null"] },
|
|
106839
|
+
customerId: { type: ["string", "null"] },
|
|
106840
|
+
vehicleId: { type: ["string", "null"] },
|
|
106841
|
+
notes: { type: ["string", "null"] },
|
|
106842
|
+
billingType: {
|
|
106843
|
+
type: "string",
|
|
106844
|
+
enum: ["not_billable", "per_km", "per_trip"]
|
|
106845
|
+
},
|
|
106846
|
+
rate: { type: ["number", "null"] },
|
|
106847
|
+
amount: {
|
|
106848
|
+
type: ["number", "null"],
|
|
106849
|
+
description: "Total amount. Recomputed from distance*rate/rate when distance/rate/billingType change and amount is omitted."
|
|
106850
|
+
},
|
|
106851
|
+
linkedTripId: {
|
|
106852
|
+
type: ["string", "null"],
|
|
106853
|
+
description: "Paired return-trip id, or null to unlink."
|
|
106854
|
+
},
|
|
106855
|
+
invoiceId: {
|
|
106856
|
+
type: ["string", "null"],
|
|
106857
|
+
description: "Invoice ID to link this trip to (see get-invoices), or null to unlink. Marks the trip invoiced."
|
|
106858
|
+
},
|
|
106859
|
+
isInvoiced: { type: "boolean" },
|
|
106860
|
+
allowInvoicedOverride: {
|
|
106861
|
+
type: "boolean",
|
|
106862
|
+
description: "Set true to edit locked financial/distance fields on an already-invoiced trip."
|
|
106863
|
+
}
|
|
106864
|
+
},
|
|
106865
|
+
required: ["id"]
|
|
106866
|
+
}
|
|
106867
|
+
},
|
|
106868
|
+
{
|
|
106869
|
+
name: "get-vehicles",
|
|
106870
|
+
description: "List the team's vehicles used for trip / kilometer registration. Returns id, name, license plate and current odometer (km). Use the ids with create-trip / update-trip.",
|
|
106871
|
+
inputSchema: {
|
|
106872
|
+
type: "object",
|
|
106873
|
+
properties: {
|
|
106874
|
+
teamId: teamIdProp,
|
|
106875
|
+
q: { type: "string", description: "Search query for vehicle name" },
|
|
106876
|
+
pageSize: { type: "number", default: 50, maximum: 200 }
|
|
106877
|
+
},
|
|
106878
|
+
required: []
|
|
106879
|
+
}
|
|
106880
|
+
},
|
|
106881
|
+
{
|
|
106882
|
+
name: "get-trip-templates",
|
|
106883
|
+
description: "List reusable trip templates (saved start/end + billing defaults) for quick trip entry. Defaults to the API key user's templates; pass userId 'all' to list every team member's templates.",
|
|
106884
|
+
inputSchema: {
|
|
106885
|
+
type: "object",
|
|
106886
|
+
properties: {
|
|
106887
|
+
teamId: teamIdProp,
|
|
106888
|
+
userId: {
|
|
106889
|
+
type: "string",
|
|
106890
|
+
description: "User whose templates to list (defaults to the API key user). Pass 'all' for every team member."
|
|
106891
|
+
},
|
|
106892
|
+
pageSize: { type: "number", default: 50, maximum: 200 }
|
|
106893
|
+
},
|
|
106894
|
+
required: []
|
|
106895
|
+
}
|
|
106896
|
+
},
|
|
106897
|
+
{
|
|
106898
|
+
name: "get-frequent-trips-for-project",
|
|
106899
|
+
description: "Return the most frequent (start, end, type) trip combinations a user drove for a project in the last `daysBack` days, with counts, average distance and last-used date. Useful to suggest a standard ride before calling create-trip.",
|
|
106900
|
+
inputSchema: {
|
|
106901
|
+
type: "object",
|
|
106902
|
+
properties: {
|
|
106903
|
+
teamId: teamIdProp,
|
|
106904
|
+
projectId: { type: "string", description: "Project ID (UUID)" },
|
|
106905
|
+
userId: {
|
|
106906
|
+
type: "string",
|
|
106907
|
+
description: "Driver user ID (defaults to the API key user)"
|
|
106908
|
+
},
|
|
106909
|
+
daysBack: { type: "number", default: 60 },
|
|
106910
|
+
limit: { type: "number", default: 5, maximum: 25 }
|
|
106911
|
+
},
|
|
106912
|
+
required: ["projectId"]
|
|
106913
|
+
}
|
|
106914
|
+
},
|
|
106518
106915
|
{
|
|
106519
106916
|
name: "get-github-file",
|
|
106520
106917
|
description: "Get the contents of a specific file from a GitHub repository. Use this after finding relevant files to read their full content.",
|
|
@@ -107255,21 +107652,61 @@ async function syncTicketDeadline(teamId, ticket, dueDate) {
|
|
|
107255
107652
|
return null;
|
|
107256
107653
|
}
|
|
107257
107654
|
|
|
107655
|
+
// src/tools/customer-cleanup-util.ts
|
|
107656
|
+
var CUSTOMER_STATUS_FILTERS = [
|
|
107657
|
+
"active",
|
|
107658
|
+
"archived",
|
|
107659
|
+
"all"
|
|
107660
|
+
];
|
|
107661
|
+
var DEPENDENCY_LABELS = {
|
|
107662
|
+
projects: "project(s)",
|
|
107663
|
+
tickets: "ticket(s)",
|
|
107664
|
+
invoices: "invoice(s)",
|
|
107665
|
+
quotations: "quotation(s)",
|
|
107666
|
+
documents: "document(s)",
|
|
107667
|
+
timesheetEvents: "agenda/time entr(ies)",
|
|
107668
|
+
timesheetTemplates: "timesheet template(s)",
|
|
107669
|
+
trips: "trip(s)",
|
|
107670
|
+
tripTemplates: "trip template(s)"
|
|
107671
|
+
};
|
|
107672
|
+
function totalCustomerDependencies(counts) {
|
|
107673
|
+
return counts.projects + counts.tickets + counts.invoices + counts.quotations + counts.documents + counts.timesheetEvents + counts.timesheetTemplates + counts.trips + counts.tripTemplates;
|
|
107674
|
+
}
|
|
107675
|
+
function isCustomerEmpty(counts) {
|
|
107676
|
+
return totalCustomerDependencies(counts) === 0;
|
|
107677
|
+
}
|
|
107678
|
+
function formatCustomerDependencies(counts) {
|
|
107679
|
+
const parts = Object.keys(DEPENDENCY_LABELS).filter((key) => counts[key] > 0).map((key) => `${counts[key]} ${DEPENDENCY_LABELS[key]}`);
|
|
107680
|
+
return parts.length > 0 ? parts.join(", ") : "no dependencies";
|
|
107681
|
+
}
|
|
107682
|
+
function findExactCustomerMatches(customers2, opts) {
|
|
107683
|
+
const wantName = opts.name?.trim().toLowerCase();
|
|
107684
|
+
const wantEmail = opts.email?.trim().toLowerCase();
|
|
107685
|
+
if (!wantName && !wantEmail) return [];
|
|
107686
|
+
return customers2.filter((c6) => {
|
|
107687
|
+
const nameOk = wantName === void 0 || (c6.name ?? "").trim().toLowerCase() === wantName;
|
|
107688
|
+
const emailOk = wantEmail === void 0 || (c6.email ?? "").trim().toLowerCase() === wantEmail;
|
|
107689
|
+
return nameOk && emailOk;
|
|
107690
|
+
});
|
|
107691
|
+
}
|
|
107692
|
+
|
|
107258
107693
|
// src/tools/customers.ts
|
|
107694
|
+
function textResponse(text3) {
|
|
107695
|
+
return { content: [{ type: "text", text: text3 }] };
|
|
107696
|
+
}
|
|
107259
107697
|
async function handleGetCustomers(input) {
|
|
107260
107698
|
const { q: q3, pageSize = 20 } = input;
|
|
107699
|
+
const status = input.status ?? "active";
|
|
107700
|
+
if (!CUSTOMER_STATUS_FILTERS.includes(status)) {
|
|
107701
|
+
return textResponse(
|
|
107702
|
+
`Error: invalid status "${status}". Allowed: ${CUSTOMER_STATUS_FILTERS.join(", ")}.`
|
|
107703
|
+
);
|
|
107704
|
+
}
|
|
107261
107705
|
const resolved = await resolveTeamId(input.teamId);
|
|
107262
107706
|
if (!resolved.ok) return resolved.response;
|
|
107263
107707
|
const customerIds = await getAccessibleCustomerIds(resolved.teamId);
|
|
107264
107708
|
if (customerIds.length === 0) {
|
|
107265
|
-
return
|
|
107266
|
-
content: [
|
|
107267
|
-
{
|
|
107268
|
-
type: "text",
|
|
107269
|
-
text: "No customers found or no access to any customers."
|
|
107270
|
-
}
|
|
107271
|
-
]
|
|
107272
|
-
};
|
|
107709
|
+
return textResponse("No customers found or no access to any customers.");
|
|
107273
107710
|
}
|
|
107274
107711
|
const filters = [inArray(schema_exports.customers.id, customerIds)];
|
|
107275
107712
|
if (q3) {
|
|
@@ -107281,53 +107718,331 @@ async function handleGetCustomers(input) {
|
|
|
107281
107718
|
)
|
|
107282
107719
|
);
|
|
107283
107720
|
}
|
|
107721
|
+
if (status === "active") {
|
|
107722
|
+
filters.push(
|
|
107723
|
+
or(
|
|
107724
|
+
eq(schema_exports.customers.isArchived, false),
|
|
107725
|
+
sql`${schema_exports.customers.isArchived} IS NULL`
|
|
107726
|
+
)
|
|
107727
|
+
);
|
|
107728
|
+
} else if (status === "archived") {
|
|
107729
|
+
filters.push(eq(schema_exports.customers.isArchived, true));
|
|
107730
|
+
}
|
|
107284
107731
|
const rows = await db.select({
|
|
107285
107732
|
id: schema_exports.customers.id,
|
|
107286
107733
|
name: schema_exports.customers.name,
|
|
107287
107734
|
email: schema_exports.customers.email,
|
|
107288
107735
|
website: schema_exports.customers.website,
|
|
107736
|
+
phone: schema_exports.customers.phone,
|
|
107737
|
+
status: schema_exports.customers.status,
|
|
107738
|
+
isArchived: schema_exports.customers.isArchived,
|
|
107289
107739
|
createdAt: schema_exports.customers.createdAt
|
|
107290
107740
|
}).from(schema_exports.customers).where(and(...filters)).orderBy(asc(schema_exports.customers.name)).limit(Math.min(pageSize, 100));
|
|
107291
|
-
return
|
|
107292
|
-
|
|
107293
|
-
{
|
|
107294
|
-
type: "text",
|
|
107295
|
-
text: `Found ${rows.length} customers:
|
|
107741
|
+
return textResponse(
|
|
107742
|
+
`Found ${rows.length} customer(s)${status !== "all" ? ` (status: ${status})` : ""}:
|
|
107296
107743
|
|
|
107297
107744
|
${rows.map(
|
|
107298
|
-
|
|
107745
|
+
(c6) => `**${c6.name}** (ID: ${c6.id})${c6.isArchived ? " \u2014 ARCHIVED" : ""}
|
|
107299
107746
|
${c6.email ? `Email: ${c6.email}
|
|
107300
107747
|
` : ""}${c6.website ? `Website: ${c6.website}
|
|
107748
|
+
` : ""}${c6.phone ? `Phone: ${c6.phone}
|
|
107749
|
+
` : ""}${c6.status ? `Status: ${c6.status}
|
|
107301
107750
|
` : ""}Created: ${new Date(c6.createdAt).toLocaleDateString()}
|
|
107302
107751
|
`
|
|
107303
|
-
|
|
107304
|
-
|
|
107305
|
-
]
|
|
107306
|
-
};
|
|
107752
|
+
).join("\n") || "No customers found."}`
|
|
107753
|
+
);
|
|
107307
107754
|
}
|
|
107308
107755
|
async function handleCreateCustomer(input) {
|
|
107309
107756
|
const { name: name21, email: email5, website } = input;
|
|
107310
107757
|
const resolved = await resolveTeamId(input.teamId);
|
|
107311
107758
|
if (!resolved.ok) return resolved.response;
|
|
107312
|
-
await db.insert(schema_exports.customers).values({
|
|
107759
|
+
const [created] = await db.insert(schema_exports.customers).values({
|
|
107313
107760
|
teamId: resolved.teamId,
|
|
107314
107761
|
name: name21,
|
|
107315
107762
|
email: email5 ?? "",
|
|
107316
107763
|
website: website ?? null
|
|
107764
|
+
}).returning({ id: schema_exports.customers.id });
|
|
107765
|
+
return textResponse(
|
|
107766
|
+
`\u2705 **Customer Created Successfully!**
|
|
107767
|
+
|
|
107768
|
+
Name: ${name21}
|
|
107769
|
+
${created ? `ID: ${created.id}
|
|
107770
|
+
` : ""}${email5 ? `Email: ${email5}
|
|
107771
|
+
` : ""}${website ? `Website: ${website}
|
|
107772
|
+
` : ""}`
|
|
107773
|
+
);
|
|
107774
|
+
}
|
|
107775
|
+
async function loadAccessibleCustomer(customerId, teamId) {
|
|
107776
|
+
const accessibleIds = await getAccessibleCustomerIds(teamId);
|
|
107777
|
+
if (!accessibleIds.includes(customerId)) return null;
|
|
107778
|
+
const [row] = await db.select({
|
|
107779
|
+
id: schema_exports.customers.id,
|
|
107780
|
+
name: schema_exports.customers.name,
|
|
107781
|
+
email: schema_exports.customers.email,
|
|
107782
|
+
website: schema_exports.customers.website,
|
|
107783
|
+
status: schema_exports.customers.status,
|
|
107784
|
+
isArchived: schema_exports.customers.isArchived
|
|
107785
|
+
}).from(schema_exports.customers).where(eq(schema_exports.customers.id, customerId)).limit(1);
|
|
107786
|
+
return row ?? null;
|
|
107787
|
+
}
|
|
107788
|
+
async function resolveTargetCustomer(teamId, opts) {
|
|
107789
|
+
if (opts.customerId) {
|
|
107790
|
+
const customer2 = await loadAccessibleCustomer(opts.customerId, teamId);
|
|
107791
|
+
if (!customer2) {
|
|
107792
|
+
return {
|
|
107793
|
+
ok: false,
|
|
107794
|
+
response: textResponse(
|
|
107795
|
+
`Customer ${opts.customerId} not found, or this team cannot access it.`
|
|
107796
|
+
)
|
|
107797
|
+
};
|
|
107798
|
+
}
|
|
107799
|
+
return { ok: true, customer: customer2 };
|
|
107800
|
+
}
|
|
107801
|
+
if (!opts.customerName && !opts.email) {
|
|
107802
|
+
return {
|
|
107803
|
+
ok: false,
|
|
107804
|
+
response: textResponse(
|
|
107805
|
+
"Provide a `customerId`, or an exact `customerName` and/or `email` to identify the customer."
|
|
107806
|
+
)
|
|
107807
|
+
};
|
|
107808
|
+
}
|
|
107809
|
+
const accessibleIds = await getAccessibleCustomerIds(teamId);
|
|
107810
|
+
if (accessibleIds.length === 0) {
|
|
107811
|
+
return {
|
|
107812
|
+
ok: false,
|
|
107813
|
+
response: textResponse("No customers found or no access to any customers.")
|
|
107814
|
+
};
|
|
107815
|
+
}
|
|
107816
|
+
const rows = await db.select({
|
|
107817
|
+
id: schema_exports.customers.id,
|
|
107818
|
+
name: schema_exports.customers.name,
|
|
107819
|
+
email: schema_exports.customers.email,
|
|
107820
|
+
website: schema_exports.customers.website,
|
|
107821
|
+
status: schema_exports.customers.status,
|
|
107822
|
+
isArchived: schema_exports.customers.isArchived
|
|
107823
|
+
}).from(schema_exports.customers).where(inArray(schema_exports.customers.id, accessibleIds));
|
|
107824
|
+
const lite = rows.map((r6) => ({
|
|
107825
|
+
id: r6.id,
|
|
107826
|
+
name: r6.name,
|
|
107827
|
+
email: r6.email
|
|
107828
|
+
}));
|
|
107829
|
+
const matches = findExactCustomerMatches(lite, {
|
|
107830
|
+
name: opts.customerName,
|
|
107831
|
+
email: opts.email
|
|
107317
107832
|
});
|
|
107833
|
+
if (matches.length === 0) {
|
|
107834
|
+
const criteria = [
|
|
107835
|
+
opts.customerName ? `name "${opts.customerName}"` : null,
|
|
107836
|
+
opts.email ? `email "${opts.email}"` : null
|
|
107837
|
+
].filter(Boolean).join(" and ");
|
|
107838
|
+
return {
|
|
107839
|
+
ok: false,
|
|
107840
|
+
response: textResponse(
|
|
107841
|
+
`No customer found with an exact ${criteria}. Use get-customers to find the exact name/email or the customer id.`
|
|
107842
|
+
)
|
|
107843
|
+
};
|
|
107844
|
+
}
|
|
107845
|
+
if (matches.length > 1) {
|
|
107846
|
+
const list = matches.map((m4) => `- ${m4.name ?? "(no name)"} (ID: ${m4.id}, email: ${m4.email ?? "\u2014"})`).join("\n");
|
|
107847
|
+
return {
|
|
107848
|
+
ok: false,
|
|
107849
|
+
response: textResponse(
|
|
107850
|
+
`\u{1F6AB} Refusing to act: ${matches.length} customers match that name/email. Re-run with an explicit \`customerId\`.
|
|
107851
|
+
|
|
107852
|
+
Matches:
|
|
107853
|
+
${list}`
|
|
107854
|
+
)
|
|
107855
|
+
};
|
|
107856
|
+
}
|
|
107857
|
+
const customer = rows.find((r6) => r6.id === matches[0].id);
|
|
107858
|
+
return { ok: true, customer };
|
|
107859
|
+
}
|
|
107860
|
+
async function requireTeamOwner(teamId, userId) {
|
|
107861
|
+
const [membership] = await db.select({ role: schema_exports.usersOnTeam.role }).from(schema_exports.usersOnTeam).where(
|
|
107862
|
+
and(
|
|
107863
|
+
eq(schema_exports.usersOnTeam.userId, userId),
|
|
107864
|
+
eq(schema_exports.usersOnTeam.teamId, teamId)
|
|
107865
|
+
)
|
|
107866
|
+
).limit(1);
|
|
107867
|
+
return membership?.role === "owner" ? null : textResponse(
|
|
107868
|
+
"Only team owners can hard-delete a customer. Ask a team owner to run this action (or use archive-customer, which any team member can do)."
|
|
107869
|
+
);
|
|
107870
|
+
}
|
|
107871
|
+
async function countCustomerDependencies(customerId) {
|
|
107872
|
+
const countRows = (table) => db.select({ c: sql`count(*)::int` }).from(table).where(eq(table.customerId, customerId)).then((r6) => r6[0]?.c ?? 0);
|
|
107873
|
+
const [
|
|
107874
|
+
projects2,
|
|
107875
|
+
tickets3,
|
|
107876
|
+
invoices2,
|
|
107877
|
+
quotations2,
|
|
107878
|
+
documents2,
|
|
107879
|
+
timesheetEvents2,
|
|
107880
|
+
timesheetTemplates2,
|
|
107881
|
+
trips2,
|
|
107882
|
+
tripTemplates2
|
|
107883
|
+
] = await Promise.all([
|
|
107884
|
+
countRows(schema_exports.projects),
|
|
107885
|
+
countRows(schema_exports.tickets),
|
|
107886
|
+
countRows(schema_exports.invoices),
|
|
107887
|
+
countRows(schema_exports.quotations),
|
|
107888
|
+
countRows(schema_exports.documents),
|
|
107889
|
+
countRows(schema_exports.timesheetEvents),
|
|
107890
|
+
countRows(schema_exports.timesheetTemplates),
|
|
107891
|
+
countRows(schema_exports.trips),
|
|
107892
|
+
countRows(schema_exports.tripTemplates)
|
|
107893
|
+
]);
|
|
107318
107894
|
return {
|
|
107319
|
-
|
|
107320
|
-
|
|
107321
|
-
|
|
107322
|
-
|
|
107895
|
+
projects: projects2,
|
|
107896
|
+
tickets: tickets3,
|
|
107897
|
+
invoices: invoices2,
|
|
107898
|
+
quotations: quotations2,
|
|
107899
|
+
documents: documents2,
|
|
107900
|
+
timesheetEvents: timesheetEvents2,
|
|
107901
|
+
timesheetTemplates: timesheetTemplates2,
|
|
107902
|
+
trips: trips2,
|
|
107903
|
+
tripTemplates: tripTemplates2
|
|
107904
|
+
};
|
|
107905
|
+
}
|
|
107906
|
+
async function handleUpdateCustomer(input) {
|
|
107907
|
+
const { customerId } = input;
|
|
107908
|
+
if (!customerId) return textResponse("Error: `customerId` is required.");
|
|
107909
|
+
const resolved = await resolveTeamId(input.teamId);
|
|
107910
|
+
if (!resolved.ok) return resolved.response;
|
|
107911
|
+
const existing = await loadAccessibleCustomer(customerId, resolved.teamId);
|
|
107912
|
+
if (!existing) {
|
|
107913
|
+
return textResponse(
|
|
107914
|
+
`Customer ${customerId} not found, or this team cannot access it.`
|
|
107915
|
+
);
|
|
107916
|
+
}
|
|
107917
|
+
const set3 = {};
|
|
107918
|
+
const assign = (key, column) => {
|
|
107919
|
+
if (input[key] !== void 0) set3[column] = input[key];
|
|
107920
|
+
};
|
|
107921
|
+
assign("name", "name");
|
|
107922
|
+
assign("email", "email");
|
|
107923
|
+
assign("website", "website");
|
|
107924
|
+
assign("phone", "phone");
|
|
107925
|
+
assign("companyName", "companyName");
|
|
107926
|
+
assign("billingEmail", "billingEmail");
|
|
107927
|
+
assign("vatNumber", "vatNumber");
|
|
107928
|
+
assign("contact", "contact");
|
|
107929
|
+
assign("note", "note");
|
|
107930
|
+
assign("addressLine1", "addressLine1");
|
|
107931
|
+
assign("addressLine2", "addressLine2");
|
|
107932
|
+
assign("city", "city");
|
|
107933
|
+
assign("state", "state");
|
|
107934
|
+
assign("zip", "zip");
|
|
107935
|
+
assign("country", "country");
|
|
107936
|
+
assign("countryCode", "countryCode");
|
|
107937
|
+
assign("status", "status");
|
|
107938
|
+
assign("isArchived", "isArchived");
|
|
107939
|
+
if (Object.keys(set3).length === 0) {
|
|
107940
|
+
return textResponse(
|
|
107941
|
+
"No editable fields provided. Pass at least one of: name, email, website, phone, companyName, billingEmail, vatNumber, contact, note, address fields, status, or isArchived."
|
|
107942
|
+
);
|
|
107943
|
+
}
|
|
107944
|
+
if (set3.email === null || set3.email === "") {
|
|
107945
|
+
return textResponse("Error: `email` cannot be empty (the column is required).");
|
|
107946
|
+
}
|
|
107947
|
+
set3.updatedAt = sql`now()`;
|
|
107948
|
+
await db.update(schema_exports.customers).set(set3).where(eq(schema_exports.customers.id, customerId));
|
|
107949
|
+
const [updated] = await db.select({
|
|
107950
|
+
id: schema_exports.customers.id,
|
|
107951
|
+
name: schema_exports.customers.name,
|
|
107952
|
+
email: schema_exports.customers.email,
|
|
107953
|
+
website: schema_exports.customers.website,
|
|
107954
|
+
phone: schema_exports.customers.phone,
|
|
107955
|
+
status: schema_exports.customers.status,
|
|
107956
|
+
isArchived: schema_exports.customers.isArchived
|
|
107957
|
+
}).from(schema_exports.customers).where(eq(schema_exports.customers.id, customerId)).limit(1);
|
|
107958
|
+
if (!updated) return textResponse(`Failed to update customer ${customerId}.`);
|
|
107959
|
+
const lines = [
|
|
107960
|
+
"\u2705 **Customer Updated**",
|
|
107961
|
+
"",
|
|
107962
|
+
`Name: ${updated.name} (ID: ${updated.id})`
|
|
107963
|
+
];
|
|
107964
|
+
if (updated.email) lines.push(`Email: ${updated.email}`);
|
|
107965
|
+
if (updated.website) lines.push(`Website: ${updated.website}`);
|
|
107966
|
+
if (updated.phone) lines.push(`Phone: ${updated.phone}`);
|
|
107967
|
+
if (updated.status) lines.push(`Status: ${updated.status}`);
|
|
107968
|
+
lines.push(`Archived: ${updated.isArchived ? "yes" : "no"}`);
|
|
107969
|
+
return textResponse(lines.join("\n"));
|
|
107970
|
+
}
|
|
107971
|
+
async function handleArchiveCustomer(input) {
|
|
107972
|
+
const { reason } = input;
|
|
107973
|
+
const resolved = await resolveTeamId(input.teamId);
|
|
107974
|
+
if (!resolved.ok) return resolved.response;
|
|
107975
|
+
const target = await resolveTargetCustomer(resolved.teamId, {
|
|
107976
|
+
customerId: input.customerId,
|
|
107977
|
+
customerName: input.customerName,
|
|
107978
|
+
email: input.email
|
|
107979
|
+
});
|
|
107980
|
+
if (!target.ok) return target.response;
|
|
107981
|
+
const customer = target.customer;
|
|
107982
|
+
if (customer.isArchived) {
|
|
107983
|
+
return textResponse(
|
|
107984
|
+
`Customer "${customer.name}" (${customer.id}) is already archived.`
|
|
107985
|
+
);
|
|
107986
|
+
}
|
|
107987
|
+
const archivedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
107988
|
+
await db.update(schema_exports.customers).set({ isArchived: true, status: "inactive", updatedAt: sql`now()` }).where(eq(schema_exports.customers.id, customer.id));
|
|
107989
|
+
return textResponse(
|
|
107990
|
+
`\u2705 **Customer archived**
|
|
107991
|
+
|
|
107992
|
+
Name: ${customer.name}
|
|
107993
|
+
ID: ${customer.id}
|
|
107994
|
+
${customer.email ? `Email: ${customer.email}
|
|
107995
|
+
` : ""}Action: archived (soft, reversible)
|
|
107996
|
+
Status: inactive
|
|
107997
|
+
Timestamp: ${archivedAt}
|
|
107998
|
+
${reason ? `Reason: ${reason}
|
|
107999
|
+
` : ""}
|
|
108000
|
+
Archived customers are hidden from get-customers by default (pass status: 'archived' or 'all' to see them). No projects, tickets, invoices or other data were touched. Reactivate later with update-customer (isArchived: false).`
|
|
108001
|
+
);
|
|
108002
|
+
}
|
|
108003
|
+
async function handleDeleteCustomer(input) {
|
|
108004
|
+
const ctx = getAuthContext();
|
|
108005
|
+
const { confirmEmptyOnly } = input;
|
|
108006
|
+
const resolved = await resolveTeamId(input.teamId);
|
|
108007
|
+
if (!resolved.ok) return resolved.response;
|
|
108008
|
+
const ownerError = await requireTeamOwner(resolved.teamId, ctx.userId);
|
|
108009
|
+
if (ownerError) return ownerError;
|
|
108010
|
+
const target = await resolveTargetCustomer(resolved.teamId, {
|
|
108011
|
+
customerId: input.customerId,
|
|
108012
|
+
customerName: input.customerName,
|
|
108013
|
+
email: input.email
|
|
108014
|
+
});
|
|
108015
|
+
if (!target.ok) return target.response;
|
|
108016
|
+
const customer = target.customer;
|
|
108017
|
+
const deps = await countCustomerDependencies(customer.id);
|
|
108018
|
+
const summary = formatCustomerDependencies(deps);
|
|
108019
|
+
if (!isCustomerEmpty(deps)) {
|
|
108020
|
+
return textResponse(
|
|
108021
|
+
`\u{1F6AB} **Delete blocked** \u2014 customer "${customer.name}" (${customer.id}) is not empty.
|
|
108022
|
+
|
|
108023
|
+
Dependencies: ${summary}.
|
|
108024
|
+
|
|
108025
|
+
A hard delete would cascade-delete its projects and orphan the rest, so it is not allowed. Use archive-customer instead to safely retire this customer (reversible, keeps all data).`
|
|
108026
|
+
);
|
|
108027
|
+
}
|
|
108028
|
+
if (confirmEmptyOnly !== true) {
|
|
108029
|
+
return textResponse(
|
|
108030
|
+
`Customer "${customer.name}" (${customer.id}) has no projects, tickets, invoices, quotations, documents, time entries, or trips and can be safely deleted. This is a permanent hard delete. Re-run delete-customer with confirmEmptyOnly: true to proceed (or use archive-customer to keep the record).`
|
|
108031
|
+
);
|
|
108032
|
+
}
|
|
108033
|
+
await db.delete(schema_exports.customers).where(eq(schema_exports.customers.id, customer.id));
|
|
108034
|
+
return textResponse(
|
|
108035
|
+
`\u2705 **Customer deleted**
|
|
107323
108036
|
|
|
107324
|
-
Name: ${
|
|
107325
|
-
|
|
107326
|
-
|
|
107327
|
-
` : ""}
|
|
107328
|
-
|
|
107329
|
-
|
|
107330
|
-
|
|
108037
|
+
Name: ${customer.name}
|
|
108038
|
+
ID: ${customer.id}
|
|
108039
|
+
${customer.email ? `Email: ${customer.email}
|
|
108040
|
+
` : ""}Action: hard delete (empty customer)
|
|
108041
|
+
Status: deleted
|
|
108042
|
+
Timestamp: ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
108043
|
+
|
|
108044
|
+
The customer had no projects, tickets, invoices, quotations, documents, time entries, or trips. Any customer-scoped config (tags, shares, domain join requests, portal tokens) was removed with it.`
|
|
108045
|
+
);
|
|
107331
108046
|
}
|
|
107332
108047
|
|
|
107333
108048
|
// ../document/src/humanizer/rules.ts
|
|
@@ -112678,7 +113393,7 @@ var PROJECT_STATUS_FILTERS = [
|
|
|
112678
113393
|
"archived",
|
|
112679
113394
|
"all"
|
|
112680
113395
|
];
|
|
112681
|
-
var
|
|
113396
|
+
var DEPENDENCY_LABELS2 = {
|
|
112682
113397
|
tickets: "ticket(s)",
|
|
112683
113398
|
timesheetEvents: "agenda/time entr(ies)",
|
|
112684
113399
|
timesheetTemplates: "timesheet template(s)",
|
|
@@ -112706,7 +113421,7 @@ function isProjectEmpty(counts) {
|
|
|
112706
113421
|
return totalProjectDependencies(counts) === 0;
|
|
112707
113422
|
}
|
|
112708
113423
|
function formatProjectDependencies(counts) {
|
|
112709
|
-
const parts = Object.keys(
|
|
113424
|
+
const parts = Object.keys(DEPENDENCY_LABELS2).filter((key) => counts[key] > 0).map((key) => `${counts[key]} ${DEPENDENCY_LABELS2[key]}`);
|
|
112710
113425
|
return parts.length > 0 ? parts.join(", ") : "no dependencies";
|
|
112711
113426
|
}
|
|
112712
113427
|
|
|
@@ -112795,21 +113510,21 @@ ${description ? `Description: ${description}
|
|
|
112795
113510
|
]
|
|
112796
113511
|
};
|
|
112797
113512
|
}
|
|
112798
|
-
function
|
|
113513
|
+
function textResponse2(text3) {
|
|
112799
113514
|
return { content: [{ type: "text", text: text3 }] };
|
|
112800
113515
|
}
|
|
112801
113516
|
function memberLabel(m4) {
|
|
112802
113517
|
return m4.fullName || m4.email || m4.userId;
|
|
112803
113518
|
}
|
|
112804
113519
|
var OWNER_REQUIRED = "Only team owners can manage project members. Ask a team owner to run this action (or use an owner's API key).";
|
|
112805
|
-
async function
|
|
113520
|
+
async function requireTeamOwner2(teamId, userId) {
|
|
112806
113521
|
const [membership] = await db.select({ role: schema_exports.usersOnTeam.role }).from(schema_exports.usersOnTeam).where(
|
|
112807
113522
|
and(
|
|
112808
113523
|
eq(schema_exports.usersOnTeam.userId, userId),
|
|
112809
113524
|
eq(schema_exports.usersOnTeam.teamId, teamId)
|
|
112810
113525
|
)
|
|
112811
113526
|
).limit(1);
|
|
112812
|
-
return membership?.role === "owner" ? null :
|
|
113527
|
+
return membership?.role === "owner" ? null : textResponse2(OWNER_REQUIRED);
|
|
112813
113528
|
}
|
|
112814
113529
|
async function setProjectMemberAccess(params) {
|
|
112815
113530
|
const { projectId, teamId, memberIds, createdBy } = params;
|
|
@@ -112913,7 +113628,7 @@ async function resolveTeamMember(teamId, opts) {
|
|
|
112913
113628
|
if (!match) {
|
|
112914
113629
|
return {
|
|
112915
113630
|
ok: false,
|
|
112916
|
-
response:
|
|
113631
|
+
response: textResponse2(
|
|
112917
113632
|
`User ${opts.userId} is not a member of this team. Call get-project-members to see the team roster.`
|
|
112918
113633
|
)
|
|
112919
113634
|
};
|
|
@@ -112926,7 +113641,7 @@ async function resolveTeamMember(teamId, opts) {
|
|
|
112926
113641
|
if (matches.length === 0) {
|
|
112927
113642
|
return {
|
|
112928
113643
|
ok: false,
|
|
112929
|
-
response:
|
|
113644
|
+
response: textResponse2(
|
|
112930
113645
|
`No team member found with email "${opts.email}". Call get-project-members to see the team roster.`
|
|
112931
113646
|
)
|
|
112932
113647
|
};
|
|
@@ -112934,7 +113649,7 @@ async function resolveTeamMember(teamId, opts) {
|
|
|
112934
113649
|
if (matches.length > 1) {
|
|
112935
113650
|
return {
|
|
112936
113651
|
ok: false,
|
|
112937
|
-
response:
|
|
113652
|
+
response: textResponse2(
|
|
112938
113653
|
`Multiple team members match email "${opts.email}". Pass an explicit userId instead.`
|
|
112939
113654
|
)
|
|
112940
113655
|
};
|
|
@@ -112943,7 +113658,7 @@ async function resolveTeamMember(teamId, opts) {
|
|
|
112943
113658
|
}
|
|
112944
113659
|
return {
|
|
112945
113660
|
ok: false,
|
|
112946
|
-
response:
|
|
113661
|
+
response: textResponse2(
|
|
112947
113662
|
"Provide either a userId or an email to identify the member."
|
|
112948
113663
|
)
|
|
112949
113664
|
};
|
|
@@ -112992,7 +113707,7 @@ async function handleUpdateProject(input) {
|
|
|
112992
113707
|
if (!resolved.ok) return resolved.response;
|
|
112993
113708
|
const existing = await loadProjectInTeam(id, resolved.teamId);
|
|
112994
113709
|
if (!existing) {
|
|
112995
|
-
return
|
|
113710
|
+
return textResponse2(
|
|
112996
113711
|
`Project ${id} not found, or it is not owned by this team.`
|
|
112997
113712
|
);
|
|
112998
113713
|
}
|
|
@@ -113007,7 +113722,7 @@ async function handleUpdateProject(input) {
|
|
|
113007
113722
|
)
|
|
113008
113723
|
).limit(1);
|
|
113009
113724
|
if (dupe) {
|
|
113010
|
-
return
|
|
113725
|
+
return textResponse2(
|
|
113011
113726
|
`A project named "${input.name}" already exists in this team. Choose a different name.`
|
|
113012
113727
|
);
|
|
113013
113728
|
}
|
|
@@ -113072,7 +113787,7 @@ async function handleUpdateProject(input) {
|
|
|
113072
113787
|
customerName: schema_exports.customers.name
|
|
113073
113788
|
}).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);
|
|
113074
113789
|
if (!updated) {
|
|
113075
|
-
return
|
|
113790
|
+
return textResponse2(`Failed to update project ${id}.`);
|
|
113076
113791
|
}
|
|
113077
113792
|
const lines = [
|
|
113078
113793
|
"\u2705 **Project Updated**",
|
|
@@ -113090,7 +113805,7 @@ async function handleUpdateProject(input) {
|
|
|
113090
113805
|
if (willRename) {
|
|
113091
113806
|
lines.push("", "Note: tickets for this project were renumbered.");
|
|
113092
113807
|
}
|
|
113093
|
-
return
|
|
113808
|
+
return textResponse2(lines.join("\n"));
|
|
113094
113809
|
}
|
|
113095
113810
|
async function handleGetProjectMembers(input) {
|
|
113096
113811
|
const { projectId } = input;
|
|
@@ -113098,7 +113813,7 @@ async function handleGetProjectMembers(input) {
|
|
|
113098
113813
|
if (!resolved.ok) return resolved.response;
|
|
113099
113814
|
const project = await loadProjectInTeam(projectId, resolved.teamId);
|
|
113100
113815
|
if (!project) {
|
|
113101
|
-
return
|
|
113816
|
+
return textResponse2(
|
|
113102
113817
|
`Project ${projectId} not found, or it is not owned by this team.`
|
|
113103
113818
|
);
|
|
113104
113819
|
}
|
|
@@ -113127,7 +113842,7 @@ async function handleGetProjectMembers(input) {
|
|
|
113127
113842
|
return `- ${memberLabel(m4)} (userId: ${m4.userId}, role: ${m4.role ?? "member"}) \u2014 ${access}`;
|
|
113128
113843
|
}).join("\n");
|
|
113129
113844
|
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.`;
|
|
113130
|
-
return
|
|
113845
|
+
return textResponse2(
|
|
113131
113846
|
`**Project members for "${project.name}"** (ID: ${project.id})
|
|
113132
113847
|
|
|
113133
113848
|
${note}
|
|
@@ -113144,11 +113859,11 @@ async function handleSetProjectMembers(input) {
|
|
|
113144
113859
|
const { projectId } = input;
|
|
113145
113860
|
const resolved = await resolveTeamId(input.teamId);
|
|
113146
113861
|
if (!resolved.ok) return resolved.response;
|
|
113147
|
-
const ownerError = await
|
|
113862
|
+
const ownerError = await requireTeamOwner2(resolved.teamId, ctx.userId);
|
|
113148
113863
|
if (ownerError) return ownerError;
|
|
113149
113864
|
const project = await loadProjectInTeam(projectId, resolved.teamId);
|
|
113150
113865
|
if (!project) {
|
|
113151
|
-
return
|
|
113866
|
+
return textResponse2(
|
|
113152
113867
|
`Project ${projectId} not found, or it is not owned by this team.`
|
|
113153
113868
|
);
|
|
113154
113869
|
}
|
|
@@ -113186,7 +113901,7 @@ async function handleSetProjectMembers(input) {
|
|
|
113186
113901
|
|
|
113187
113902
|
\u26A0\uFE0F ${names} previously had no restrictions (could see all projects). They are now restricted to only the projects explicitly assigned to them.`;
|
|
113188
113903
|
}
|
|
113189
|
-
return
|
|
113904
|
+
return textResponse2(
|
|
113190
113905
|
`\u2705 **Project members updated**
|
|
113191
113906
|
|
|
113192
113907
|
Members with explicit access to this project:
|
|
@@ -113198,11 +113913,11 @@ async function handleAddProjectMember(input) {
|
|
|
113198
113913
|
const { projectId } = input;
|
|
113199
113914
|
const resolved = await resolveTeamId(input.teamId);
|
|
113200
113915
|
if (!resolved.ok) return resolved.response;
|
|
113201
|
-
const ownerError = await
|
|
113916
|
+
const ownerError = await requireTeamOwner2(resolved.teamId, ctx.userId);
|
|
113202
113917
|
if (ownerError) return ownerError;
|
|
113203
113918
|
const project = await loadProjectInTeam(projectId, resolved.teamId);
|
|
113204
113919
|
if (!project) {
|
|
113205
|
-
return
|
|
113920
|
+
return textResponse2(
|
|
113206
113921
|
`Project ${projectId} not found, or it is not owned by this team.`
|
|
113207
113922
|
);
|
|
113208
113923
|
}
|
|
@@ -113213,7 +113928,7 @@ async function handleAddProjectMember(input) {
|
|
|
113213
113928
|
if (!member2.ok) return member2.response;
|
|
113214
113929
|
const state2 = await getProjectAccessState(resolved.teamId, projectId);
|
|
113215
113930
|
if (state2.projectMemberIds.has(member2.member.userId)) {
|
|
113216
|
-
return
|
|
113931
|
+
return textResponse2(
|
|
113217
113932
|
`${memberLabel(member2.member)} already has explicit access to this project.`
|
|
113218
113933
|
);
|
|
113219
113934
|
}
|
|
@@ -113228,18 +113943,18 @@ async function handleAddProjectMember(input) {
|
|
|
113228
113943
|
if (wasUnrestricted) {
|
|
113229
113944
|
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.";
|
|
113230
113945
|
}
|
|
113231
|
-
return
|
|
113946
|
+
return textResponse2(text3);
|
|
113232
113947
|
}
|
|
113233
113948
|
async function handleRemoveProjectMember(input) {
|
|
113234
113949
|
const ctx = getAuthContext();
|
|
113235
113950
|
const { projectId } = input;
|
|
113236
113951
|
const resolved = await resolveTeamId(input.teamId);
|
|
113237
113952
|
if (!resolved.ok) return resolved.response;
|
|
113238
|
-
const ownerError = await
|
|
113953
|
+
const ownerError = await requireTeamOwner2(resolved.teamId, ctx.userId);
|
|
113239
113954
|
if (ownerError) return ownerError;
|
|
113240
113955
|
const project = await loadProjectInTeam(projectId, resolved.teamId);
|
|
113241
113956
|
if (!project) {
|
|
113242
|
-
return
|
|
113957
|
+
return textResponse2(
|
|
113243
113958
|
`Project ${projectId} not found, or it is not owned by this team.`
|
|
113244
113959
|
);
|
|
113245
113960
|
}
|
|
@@ -113250,7 +113965,7 @@ async function handleRemoveProjectMember(input) {
|
|
|
113250
113965
|
if (!member2.ok) return member2.response;
|
|
113251
113966
|
const state2 = await getProjectAccessState(resolved.teamId, projectId);
|
|
113252
113967
|
if (!state2.projectMemberIds.has(member2.member.userId)) {
|
|
113253
|
-
return
|
|
113968
|
+
return textResponse2(
|
|
113254
113969
|
`${memberLabel(member2.member)} has no explicit assignment to this project; nothing to remove.`
|
|
113255
113970
|
);
|
|
113256
113971
|
}
|
|
@@ -113266,7 +113981,7 @@ async function handleRemoveProjectMember(input) {
|
|
|
113266
113981
|
if ((state2.rowCountByUser.get(member2.member.userId) ?? 0) <= 1) {
|
|
113267
113982
|
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).";
|
|
113268
113983
|
}
|
|
113269
|
-
return
|
|
113984
|
+
return textResponse2(text3);
|
|
113270
113985
|
}
|
|
113271
113986
|
async function loadProjectForCleanup(projectId, teamId) {
|
|
113272
113987
|
const accessibleTeamIds = await getAccessibleTeamIds(teamId);
|
|
@@ -113294,25 +114009,25 @@ async function countProjectDependencies(projectId) {
|
|
|
113294
114009
|
}
|
|
113295
114010
|
async function handleArchiveProject(input) {
|
|
113296
114011
|
const { projectId, reason } = input;
|
|
113297
|
-
if (!projectId) return
|
|
114012
|
+
if (!projectId) return textResponse2("Error: `projectId` is required.");
|
|
113298
114013
|
const resolved = await resolveTeamId(input.teamId);
|
|
113299
114014
|
if (!resolved.ok) return resolved.response;
|
|
113300
114015
|
const project = await loadProjectForCleanup(projectId, resolved.teamId);
|
|
113301
114016
|
if (!project) {
|
|
113302
|
-
return
|
|
114017
|
+
return textResponse2(
|
|
113303
114018
|
`Project ${projectId} not found, or it is not owned by this team.`
|
|
113304
114019
|
);
|
|
113305
114020
|
}
|
|
113306
114021
|
const state2 = getProjectArchiveState(project.settings);
|
|
113307
114022
|
if (state2.archived) {
|
|
113308
|
-
return
|
|
114023
|
+
return textResponse2(
|
|
113309
114024
|
`Project "${project.name}" (${project.id}) is already archived${state2.archivedAt ? ` (since ${state2.archivedAt})` : ""}.`
|
|
113310
114025
|
);
|
|
113311
114026
|
}
|
|
113312
114027
|
const archivedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
113313
114028
|
const nextSettings = withArchiveSettings(project.settings, archivedAt, reason);
|
|
113314
114029
|
await db.update(schema_exports.projects).set({ settings: nextSettings, updatedAt: sql`now()` }).where(eq(schema_exports.projects.id, project.id));
|
|
113315
|
-
return
|
|
114030
|
+
return textResponse2(
|
|
113316
114031
|
`\u2705 **Project archived**
|
|
113317
114032
|
|
|
113318
114033
|
Project: ${project.name}
|
|
@@ -113330,21 +114045,21 @@ Note: the archive flag is stored in \`projects.settings.archivedAt\`; the dashbo
|
|
|
113330
114045
|
async function handleDeleteProject(input) {
|
|
113331
114046
|
const ctx = getAuthContext();
|
|
113332
114047
|
const { projectId, confirmEmptyOnly } = input;
|
|
113333
|
-
if (!projectId) return
|
|
114048
|
+
if (!projectId) return textResponse2("Error: `projectId` is required.");
|
|
113334
114049
|
const resolved = await resolveTeamId(input.teamId);
|
|
113335
114050
|
if (!resolved.ok) return resolved.response;
|
|
113336
|
-
const ownerError = await
|
|
114051
|
+
const ownerError = await requireTeamOwner2(resolved.teamId, ctx.userId);
|
|
113337
114052
|
if (ownerError) return ownerError;
|
|
113338
114053
|
const project = await loadProjectForCleanup(projectId, resolved.teamId);
|
|
113339
114054
|
if (!project) {
|
|
113340
|
-
return
|
|
114055
|
+
return textResponse2(
|
|
113341
114056
|
`Project ${projectId} not found, or it is not owned by this team.`
|
|
113342
114057
|
);
|
|
113343
114058
|
}
|
|
113344
114059
|
const deps = await countProjectDependencies(project.id);
|
|
113345
114060
|
const summary = formatProjectDependencies(deps);
|
|
113346
114061
|
if (!isProjectEmpty(deps)) {
|
|
113347
|
-
return
|
|
114062
|
+
return textResponse2(
|
|
113348
114063
|
`\u{1F6AB} **Delete blocked** \u2014 project "${project.name}" (${project.id}) is not empty.
|
|
113349
114064
|
|
|
113350
114065
|
Dependencies: ${summary}.
|
|
@@ -113353,12 +114068,12 @@ A hard delete would orphan these records, so it is not allowed. Use archive-proj
|
|
|
113353
114068
|
);
|
|
113354
114069
|
}
|
|
113355
114070
|
if (confirmEmptyOnly !== true) {
|
|
113356
|
-
return
|
|
114071
|
+
return textResponse2(
|
|
113357
114072
|
`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).`
|
|
113358
114073
|
);
|
|
113359
114074
|
}
|
|
113360
114075
|
await db.delete(schema_exports.projects).where(eq(schema_exports.projects.id, project.id));
|
|
113361
|
-
return
|
|
114076
|
+
return textResponse2(
|
|
113362
114077
|
`\u2705 **Project deleted**
|
|
113363
114078
|
|
|
113364
114079
|
Project: ${project.name}
|
|
@@ -113388,7 +114103,7 @@ var PRODUCT_COLUMNS = {
|
|
|
113388
114103
|
createdAt: schema_exports.invoiceProducts.createdAt,
|
|
113389
114104
|
updatedAt: schema_exports.invoiceProducts.updatedAt
|
|
113390
114105
|
};
|
|
113391
|
-
function
|
|
114106
|
+
function textResponse3(text3) {
|
|
113392
114107
|
return { content: [{ type: "text", text: text3 }] };
|
|
113393
114108
|
}
|
|
113394
114109
|
function formatPrice(p3) {
|
|
@@ -113409,14 +114124,14 @@ async function handleGetProducts(input) {
|
|
|
113409
114124
|
const { q: q3, currency, pageSize = 20 } = input;
|
|
113410
114125
|
const status = input.status ?? "active";
|
|
113411
114126
|
if (!PRODUCT_STATUSES.includes(status)) {
|
|
113412
|
-
return
|
|
114127
|
+
return textResponse3(
|
|
113413
114128
|
`Error: invalid status "${status}". Allowed: ${PRODUCT_STATUSES.join(", ")}.`
|
|
113414
114129
|
);
|
|
113415
114130
|
}
|
|
113416
114131
|
const scope = await resolveTeamScope(input.teamId);
|
|
113417
114132
|
if (!scope.ok) return scope.response;
|
|
113418
114133
|
if (scope.teamIds.length === 0) {
|
|
113419
|
-
return
|
|
114134
|
+
return textResponse3("No accessible teams found.");
|
|
113420
114135
|
}
|
|
113421
114136
|
const filters = [inArray(schema_exports.invoiceProducts.teamId, scope.teamIds)];
|
|
113422
114137
|
if (status === "active") {
|
|
@@ -113439,11 +114154,11 @@ async function handleGetProducts(input) {
|
|
|
113439
114154
|
asc(schema_exports.invoiceProducts.name)
|
|
113440
114155
|
).limit(Math.min(pageSize, 100));
|
|
113441
114156
|
if (rows.length === 0) {
|
|
113442
|
-
return
|
|
114157
|
+
return textResponse3(
|
|
113443
114158
|
`No products found${status !== "all" ? ` (status: ${status})` : ""}.`
|
|
113444
114159
|
);
|
|
113445
114160
|
}
|
|
113446
|
-
return
|
|
114161
|
+
return textResponse3(
|
|
113447
114162
|
`Found ${rows.length} product(s):
|
|
113448
114163
|
|
|
113449
114164
|
${rows.map(formatProduct).join("\n")}`
|
|
@@ -113451,11 +114166,11 @@ ${rows.map(formatProduct).join("\n")}`
|
|
|
113451
114166
|
}
|
|
113452
114167
|
async function handleGetProductById(input) {
|
|
113453
114168
|
const { productId } = input;
|
|
113454
|
-
if (!productId) return
|
|
114169
|
+
if (!productId) return textResponse3("Error: `productId` is required.");
|
|
113455
114170
|
const scope = await resolveTeamScope(input.teamId);
|
|
113456
114171
|
if (!scope.ok) return scope.response;
|
|
113457
114172
|
if (scope.teamIds.length === 0) {
|
|
113458
|
-
return
|
|
114173
|
+
return textResponse3("No accessible teams found.");
|
|
113459
114174
|
}
|
|
113460
114175
|
const [row] = await db.select(PRODUCT_COLUMNS).from(schema_exports.invoiceProducts).where(
|
|
113461
114176
|
and(
|
|
@@ -113464,11 +114179,11 @@ async function handleGetProductById(input) {
|
|
|
113464
114179
|
)
|
|
113465
114180
|
).limit(1);
|
|
113466
114181
|
if (!row) {
|
|
113467
|
-
return
|
|
114182
|
+
return textResponse3(
|
|
113468
114183
|
`Product ${productId} not found or you don't have access to it.`
|
|
113469
114184
|
);
|
|
113470
114185
|
}
|
|
113471
|
-
return
|
|
114186
|
+
return textResponse3(formatProduct(row));
|
|
113472
114187
|
}
|
|
113473
114188
|
async function loadProductInTeam(productId, teamId) {
|
|
113474
114189
|
const accessibleTeamIds = await getAccessibleTeamIds(teamId);
|
|
@@ -113483,7 +114198,7 @@ async function loadProductInTeam(productId, teamId) {
|
|
|
113483
114198
|
async function handleCreateProduct(input) {
|
|
113484
114199
|
const { name: name21, description, price, currency, unit } = input;
|
|
113485
114200
|
if (!name21 || name21.trim().length === 0) {
|
|
113486
|
-
return
|
|
114201
|
+
return textResponse3("Error: `name` is required.");
|
|
113487
114202
|
}
|
|
113488
114203
|
const resolved = await resolveTeamId(input.teamId);
|
|
113489
114204
|
if (!resolved.ok) return resolved.response;
|
|
@@ -113497,74 +114212,582 @@ async function handleCreateProduct(input) {
|
|
|
113497
114212
|
isActive: true,
|
|
113498
114213
|
lastUsedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
113499
114214
|
}).returning(PRODUCT_COLUMNS);
|
|
113500
|
-
if (!created) return
|
|
113501
|
-
return
|
|
114215
|
+
if (!created) return textResponse3("Failed to create product.");
|
|
114216
|
+
return textResponse3(
|
|
113502
114217
|
`\u2705 **Product created**
|
|
113503
114218
|
|
|
113504
114219
|
${formatProduct(created)}`
|
|
113505
114220
|
);
|
|
113506
114221
|
}
|
|
113507
|
-
async function handleUpdateProduct(input) {
|
|
113508
|
-
const { productId } = input;
|
|
113509
|
-
if (!productId) return
|
|
114222
|
+
async function handleUpdateProduct(input) {
|
|
114223
|
+
const { productId } = input;
|
|
114224
|
+
if (!productId) return textResponse3("Error: `productId` is required.");
|
|
114225
|
+
const resolved = await resolveTeamId(input.teamId);
|
|
114226
|
+
if (!resolved.ok) return resolved.response;
|
|
114227
|
+
const existing = await loadProductInTeam(productId, resolved.teamId);
|
|
114228
|
+
if (!existing) {
|
|
114229
|
+
return textResponse3(
|
|
114230
|
+
`Product ${productId} not found, or it is not owned by this team.`
|
|
114231
|
+
);
|
|
114232
|
+
}
|
|
114233
|
+
const updates = {};
|
|
114234
|
+
if (input.name !== void 0) {
|
|
114235
|
+
if (!input.name || input.name.trim().length === 0) {
|
|
114236
|
+
return textResponse3("Error: `name` cannot be empty.");
|
|
114237
|
+
}
|
|
114238
|
+
updates.name = input.name.trim();
|
|
114239
|
+
}
|
|
114240
|
+
if (input.description !== void 0) updates.description = input.description;
|
|
114241
|
+
if (input.price !== void 0) updates.price = input.price;
|
|
114242
|
+
if (input.currency !== void 0) updates.currency = input.currency;
|
|
114243
|
+
if (input.unit !== void 0) updates.unit = input.unit;
|
|
114244
|
+
if (input.isActive !== void 0) updates.isActive = input.isActive;
|
|
114245
|
+
if (Object.keys(updates).length === 0) {
|
|
114246
|
+
return textResponse3(
|
|
114247
|
+
"No fields to update. Provide at least one of: name, description, price, currency, unit, isActive."
|
|
114248
|
+
);
|
|
114249
|
+
}
|
|
114250
|
+
updates.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
114251
|
+
const [updated] = await db.update(schema_exports.invoiceProducts).set(updates).where(eq(schema_exports.invoiceProducts.id, existing.id)).returning(PRODUCT_COLUMNS);
|
|
114252
|
+
if (!updated) return textResponse3(`Failed to update product ${productId}.`);
|
|
114253
|
+
return textResponse3(
|
|
114254
|
+
`\u2705 **Product updated**
|
|
114255
|
+
|
|
114256
|
+
${formatProduct(updated)}
|
|
114257
|
+
Note: this only affects future invoices/quotes. Existing documents keep their line-item snapshots.`
|
|
114258
|
+
);
|
|
114259
|
+
}
|
|
114260
|
+
async function handleArchiveProduct(input) {
|
|
114261
|
+
const { productId, reason } = input;
|
|
114262
|
+
if (!productId) return textResponse3("Error: `productId` is required.");
|
|
114263
|
+
const resolved = await resolveTeamId(input.teamId);
|
|
114264
|
+
if (!resolved.ok) return resolved.response;
|
|
114265
|
+
const existing = await loadProductInTeam(productId, resolved.teamId);
|
|
114266
|
+
if (!existing) {
|
|
114267
|
+
return textResponse3(
|
|
114268
|
+
`Product ${productId} not found, or it is not owned by this team.`
|
|
114269
|
+
);
|
|
114270
|
+
}
|
|
114271
|
+
if (!existing.isActive) {
|
|
114272
|
+
return textResponse3(
|
|
114273
|
+
`Product "${existing.name}" (${existing.id}) is already archived.`
|
|
114274
|
+
);
|
|
114275
|
+
}
|
|
114276
|
+
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_COLUMNS);
|
|
114277
|
+
if (!archived) return textResponse3(`Failed to archive product ${productId}.`);
|
|
114278
|
+
return textResponse3(
|
|
114279
|
+
`\u2705 **Product archived** (hidden from new invoices/quotes; existing documents are untouched).
|
|
114280
|
+
|
|
114281
|
+
${formatProduct(archived)}${reason ? `Reason: ${reason}
|
|
114282
|
+
` : ""}Reactivate it with update-product (isActive: true).`
|
|
114283
|
+
);
|
|
114284
|
+
}
|
|
114285
|
+
|
|
114286
|
+
// src/tools/quote-line-util.ts
|
|
114287
|
+
function round2(n3) {
|
|
114288
|
+
return Math.round(n3 * 100) / 100;
|
|
114289
|
+
}
|
|
114290
|
+
function lineFinancials(quantity, price, defaults) {
|
|
114291
|
+
const lineTotal = quantity * price;
|
|
114292
|
+
return {
|
|
114293
|
+
vat: defaults.includeVat ? round2(lineTotal * (defaults.vatRate / 100)) : void 0,
|
|
114294
|
+
tax: defaults.includeTax ? round2(lineTotal * (defaults.taxRate / 100)) : void 0
|
|
114295
|
+
};
|
|
114296
|
+
}
|
|
114297
|
+
function computeTotals(items, defaults) {
|
|
114298
|
+
const subtotal = items.reduce(
|
|
114299
|
+
(sum, i6) => sum + (i6.quantity || 0) * (i6.price || 0),
|
|
114300
|
+
0
|
|
114301
|
+
);
|
|
114302
|
+
const vat = defaults.includeVat ? subtotal * (defaults.vatRate / 100) : 0;
|
|
114303
|
+
const tax = defaults.includeTax ? subtotal * (defaults.taxRate / 100) : 0;
|
|
114304
|
+
return {
|
|
114305
|
+
subtotal: round2(subtotal),
|
|
114306
|
+
vat: round2(vat),
|
|
114307
|
+
tax: round2(tax),
|
|
114308
|
+
amount: round2(subtotal + vat + tax)
|
|
114309
|
+
};
|
|
114310
|
+
}
|
|
114311
|
+
function snapshotFromProduct(product, defaults, now2 = () => (/* @__PURE__ */ new Date()).toISOString()) {
|
|
114312
|
+
return {
|
|
114313
|
+
productId: product.id,
|
|
114314
|
+
name: product.name,
|
|
114315
|
+
description: product.description,
|
|
114316
|
+
unitPrice: product.price,
|
|
114317
|
+
currency: product.currency || defaults.currency,
|
|
114318
|
+
vatRate: defaults.vatRate,
|
|
114319
|
+
unit: product.unit,
|
|
114320
|
+
capturedAt: now2(),
|
|
114321
|
+
metadata: {
|
|
114322
|
+
isConfigurable: product.isConfigurable,
|
|
114323
|
+
options: product.options ?? null
|
|
114324
|
+
}
|
|
114325
|
+
};
|
|
114326
|
+
}
|
|
114327
|
+
function lineItemFromProduct(product, opts, defaults, now2 = () => (/* @__PURE__ */ new Date()).toISOString()) {
|
|
114328
|
+
const quantity = opts.quantity ?? 1;
|
|
114329
|
+
const price = opts.customPrice ?? product.price ?? 0;
|
|
114330
|
+
const { vat, tax } = lineFinancials(quantity, price, defaults);
|
|
114331
|
+
return {
|
|
114332
|
+
name: opts.customDescription || product.name,
|
|
114333
|
+
quantity,
|
|
114334
|
+
unit: product.unit || void 0,
|
|
114335
|
+
price,
|
|
114336
|
+
vat,
|
|
114337
|
+
tax,
|
|
114338
|
+
productId: product.id,
|
|
114339
|
+
productSnapshot: snapshotFromProduct(product, defaults, now2)
|
|
114340
|
+
};
|
|
114341
|
+
}
|
|
114342
|
+
function templateDefaultsFromStored(template, currency) {
|
|
114343
|
+
const t8 = template ?? {};
|
|
114344
|
+
return {
|
|
114345
|
+
currency: t8.currency || currency || "EUR",
|
|
114346
|
+
vatRate: t8.vatRate ?? 21,
|
|
114347
|
+
taxRate: t8.taxRate ?? 0,
|
|
114348
|
+
includeVat: t8.includeVat ?? true,
|
|
114349
|
+
includeTax: t8.includeTax ?? false,
|
|
114350
|
+
includeDiscount: t8.includeDiscount ?? false,
|
|
114351
|
+
includeDecimals: t8.includeDecimals ?? true,
|
|
114352
|
+
includeUnits: t8.includeUnits ?? true,
|
|
114353
|
+
includeQr: t8.includeQr ?? false,
|
|
114354
|
+
size: t8.size || "a4",
|
|
114355
|
+
fromDetails: null,
|
|
114356
|
+
paymentDetails: null,
|
|
114357
|
+
raw: t8
|
|
114358
|
+
};
|
|
114359
|
+
}
|
|
114360
|
+
|
|
114361
|
+
// src/tools/quotes.ts
|
|
114362
|
+
var QUOTE_STATUSES = [
|
|
114363
|
+
"draft",
|
|
114364
|
+
"sent",
|
|
114365
|
+
"accepted",
|
|
114366
|
+
"rejected",
|
|
114367
|
+
"expired"
|
|
114368
|
+
];
|
|
114369
|
+
var SAFE_DRAFT_STATUSES = /* @__PURE__ */ new Set(["draft"]);
|
|
114370
|
+
function textResponse4(text3) {
|
|
114371
|
+
return { content: [{ type: "text", text: text3 }] };
|
|
114372
|
+
}
|
|
114373
|
+
async function loadTemplateDefaults(teamId) {
|
|
114374
|
+
const rows = await db.select().from(schema_exports.quotationTemplates).where(eq(schema_exports.quotationTemplates.teamId, teamId)).orderBy(desc(schema_exports.quotationTemplates.isDefault)).limit(1);
|
|
114375
|
+
const t8 = rows[0];
|
|
114376
|
+
return {
|
|
114377
|
+
currency: t8?.currency || "EUR",
|
|
114378
|
+
vatRate: t8?.vatRate ?? 21,
|
|
114379
|
+
taxRate: t8?.taxRate ?? 0,
|
|
114380
|
+
includeVat: t8?.includeVat ?? true,
|
|
114381
|
+
includeTax: t8?.includeTax ?? false,
|
|
114382
|
+
includeDiscount: t8?.includeDiscount ?? false,
|
|
114383
|
+
includeDecimals: t8?.includeDecimals ?? true,
|
|
114384
|
+
includeUnits: t8?.includeUnits ?? true,
|
|
114385
|
+
includeQr: t8?.includeQr ?? false,
|
|
114386
|
+
size: t8?.size || "a4",
|
|
114387
|
+
fromDetails: t8?.fromDetails ?? null,
|
|
114388
|
+
paymentDetails: t8?.paymentDetails ?? null,
|
|
114389
|
+
raw: t8 ?? {}
|
|
114390
|
+
};
|
|
114391
|
+
}
|
|
114392
|
+
function buildQuoteTemplate(defaults, titleOverride) {
|
|
114393
|
+
const t8 = defaults.raw;
|
|
114394
|
+
return {
|
|
114395
|
+
currency: defaults.currency,
|
|
114396
|
+
includeVat: defaults.includeVat,
|
|
114397
|
+
includeTax: defaults.includeTax,
|
|
114398
|
+
includeDiscount: defaults.includeDiscount,
|
|
114399
|
+
includeDecimals: defaults.includeDecimals,
|
|
114400
|
+
includeUnits: defaults.includeUnits,
|
|
114401
|
+
includeQr: defaults.includeQr,
|
|
114402
|
+
vatRate: defaults.vatRate,
|
|
114403
|
+
taxRate: defaults.taxRate,
|
|
114404
|
+
size: defaults.size,
|
|
114405
|
+
locale: t8.locale || "nl",
|
|
114406
|
+
timezone: "Europe/Amsterdam",
|
|
114407
|
+
customerLabel: t8.customerLabel || "Klant",
|
|
114408
|
+
title: titleOverride || t8.title || "Offerte",
|
|
114409
|
+
fromLabel: t8.fromLabel || "Van",
|
|
114410
|
+
quotationNoLabel: t8.quotationNoLabel || "Offerte nr.",
|
|
114411
|
+
issueDateLabel: t8.issueDateLabel || "Datum",
|
|
114412
|
+
validUntilLabel: t8.validUntilLabel || "Geldig tot",
|
|
114413
|
+
descriptionLabel: t8.descriptionLabel || "Omschrijving",
|
|
114414
|
+
priceLabel: t8.priceLabel || "Prijs",
|
|
114415
|
+
quantityLabel: t8.quantityLabel || "Aantal",
|
|
114416
|
+
totalLabel: t8.totalLabel || "Totaal",
|
|
114417
|
+
totalSummaryLabel: t8.totalSummaryLabel || "Totaal",
|
|
114418
|
+
vatLabel: t8.vatLabel || "BTW",
|
|
114419
|
+
subtotalLabel: t8.subtotalLabel || "Subtotaal",
|
|
114420
|
+
taxLabel: t8.taxLabel || "Belasting",
|
|
114421
|
+
discountLabel: t8.discountLabel || "Korting",
|
|
114422
|
+
paymentLabel: t8.paymentLabel || "Betaling",
|
|
114423
|
+
noteLabel: t8.noteLabel || "Notitie",
|
|
114424
|
+
logoUrl: t8.logoUrl ?? null,
|
|
114425
|
+
dateFormat: t8.dateFormat || "dd/MM/yyyy"
|
|
114426
|
+
};
|
|
114427
|
+
}
|
|
114428
|
+
async function nextQuotationNumber(teamId) {
|
|
114429
|
+
const rows = await db.execute(
|
|
114430
|
+
sql`SELECT get_next_quotation_number(${teamId}) AS next_quotation_number`
|
|
114431
|
+
);
|
|
114432
|
+
const value = rows[0]?.next_quotation_number;
|
|
114433
|
+
if (!value) throw new Error("Failed to fetch next quotation number");
|
|
114434
|
+
return value;
|
|
114435
|
+
}
|
|
114436
|
+
async function loadProductsInTeam(productIds, teamId) {
|
|
114437
|
+
if (productIds.length === 0) return /* @__PURE__ */ new Map();
|
|
114438
|
+
const accessibleTeamIds = await getAccessibleTeamIds(teamId);
|
|
114439
|
+
const rows = await db.select({
|
|
114440
|
+
id: schema_exports.invoiceProducts.id,
|
|
114441
|
+
name: schema_exports.invoiceProducts.name,
|
|
114442
|
+
description: schema_exports.invoiceProducts.description,
|
|
114443
|
+
price: schema_exports.invoiceProducts.price,
|
|
114444
|
+
currency: schema_exports.invoiceProducts.currency,
|
|
114445
|
+
unit: schema_exports.invoiceProducts.unit,
|
|
114446
|
+
isConfigurable: schema_exports.invoiceProducts.isConfigurable,
|
|
114447
|
+
options: schema_exports.invoiceProducts.options
|
|
114448
|
+
}).from(schema_exports.invoiceProducts).where(
|
|
114449
|
+
and(
|
|
114450
|
+
inArray(schema_exports.invoiceProducts.id, productIds),
|
|
114451
|
+
inArray(schema_exports.invoiceProducts.teamId, accessibleTeamIds)
|
|
114452
|
+
)
|
|
114453
|
+
);
|
|
114454
|
+
return new Map(rows.map((r6) => [r6.id, r6]));
|
|
114455
|
+
}
|
|
114456
|
+
async function resolveLineItems(inputs, defaults, teamId) {
|
|
114457
|
+
const productIds = inputs.map((i6) => i6.productId).filter((id) => Boolean(id));
|
|
114458
|
+
const products = await loadProductsInTeam([...new Set(productIds)], teamId);
|
|
114459
|
+
const items = [];
|
|
114460
|
+
for (const input of inputs) {
|
|
114461
|
+
if (input.productId) {
|
|
114462
|
+
const product = products.get(input.productId);
|
|
114463
|
+
if (!product) {
|
|
114464
|
+
return {
|
|
114465
|
+
items: [],
|
|
114466
|
+
error: `Product ${input.productId} not found or not owned by this team.`
|
|
114467
|
+
};
|
|
114468
|
+
}
|
|
114469
|
+
items.push(
|
|
114470
|
+
lineItemFromProduct(
|
|
114471
|
+
product,
|
|
114472
|
+
{
|
|
114473
|
+
quantity: input.quantity,
|
|
114474
|
+
customDescription: input.name,
|
|
114475
|
+
customPrice: input.price
|
|
114476
|
+
},
|
|
114477
|
+
defaults
|
|
114478
|
+
)
|
|
114479
|
+
);
|
|
114480
|
+
continue;
|
|
114481
|
+
}
|
|
114482
|
+
const quantity = input.quantity ?? 1;
|
|
114483
|
+
const price = input.price ?? 0;
|
|
114484
|
+
const { vat, tax } = lineFinancials(quantity, price, defaults);
|
|
114485
|
+
items.push({
|
|
114486
|
+
name: input.name?.trim() || "(no description)",
|
|
114487
|
+
quantity,
|
|
114488
|
+
unit: input.unit || void 0,
|
|
114489
|
+
price,
|
|
114490
|
+
vat,
|
|
114491
|
+
tax
|
|
114492
|
+
});
|
|
114493
|
+
}
|
|
114494
|
+
return { items };
|
|
114495
|
+
}
|
|
114496
|
+
var QUOTE_COLUMNS = {
|
|
114497
|
+
id: schema_exports.quotations.id,
|
|
114498
|
+
teamId: schema_exports.quotations.teamId,
|
|
114499
|
+
quotationNumber: schema_exports.quotations.quotationNumber,
|
|
114500
|
+
status: schema_exports.quotations.status,
|
|
114501
|
+
customerId: schema_exports.quotations.customerId,
|
|
114502
|
+
customerName: schema_exports.quotations.customerName,
|
|
114503
|
+
amount: schema_exports.quotations.amount,
|
|
114504
|
+
subtotal: schema_exports.quotations.subtotal,
|
|
114505
|
+
vat: schema_exports.quotations.vat,
|
|
114506
|
+
currency: schema_exports.quotations.currency,
|
|
114507
|
+
validUntil: schema_exports.quotations.validUntil,
|
|
114508
|
+
createdAt: schema_exports.quotations.createdAt,
|
|
114509
|
+
lineItems: schema_exports.quotations.lineItems,
|
|
114510
|
+
template: schema_exports.quotations.template
|
|
114511
|
+
};
|
|
114512
|
+
function formatQuote(q3) {
|
|
114513
|
+
const items = Array.isArray(q3.lineItems) ? q3.lineItems : [];
|
|
114514
|
+
return `**${q3.quotationNumber ?? "(draft, no number)"}** (${q3.status})
|
|
114515
|
+
ID: ${q3.id}
|
|
114516
|
+
Customer: ${q3.customerName ?? q3.customerId ?? "(none)"}
|
|
114517
|
+
Total: ${q3.amount ?? "?"} ${q3.currency ?? ""} (subtotal ${q3.subtotal ?? "?"}, VAT ${q3.vat ?? 0})
|
|
114518
|
+
Line items: ${items.length}
|
|
114519
|
+
${q3.validUntil ? `Valid until: ${new Date(q3.validUntil).toLocaleDateString()}
|
|
114520
|
+
` : ""}Created: ${new Date(q3.createdAt).toLocaleDateString()}
|
|
114521
|
+
`;
|
|
114522
|
+
}
|
|
114523
|
+
function tiptapNote(text3) {
|
|
114524
|
+
return {
|
|
114525
|
+
type: "doc",
|
|
114526
|
+
content: [{ type: "paragraph", content: [{ type: "text", text: text3 }] }]
|
|
114527
|
+
};
|
|
114528
|
+
}
|
|
114529
|
+
async function handleGetQuotes(input) {
|
|
114530
|
+
const { customerId, status, q: q3, pageSize = 20 } = input;
|
|
114531
|
+
if (status && !QUOTE_STATUSES.includes(status)) {
|
|
114532
|
+
return textResponse4(
|
|
114533
|
+
`Error: invalid status "${status}". Allowed: ${QUOTE_STATUSES.join(", ")}.`
|
|
114534
|
+
);
|
|
114535
|
+
}
|
|
114536
|
+
const scope = await resolveTeamScope(input.teamId);
|
|
114537
|
+
if (!scope.ok) return scope.response;
|
|
114538
|
+
if (scope.teamIds.length === 0) {
|
|
114539
|
+
return textResponse4("No accessible teams found.");
|
|
114540
|
+
}
|
|
114541
|
+
const filters = [inArray(schema_exports.quotations.teamId, scope.teamIds)];
|
|
114542
|
+
if (customerId) filters.push(eq(schema_exports.quotations.customerId, customerId));
|
|
114543
|
+
if (status) filters.push(eq(schema_exports.quotations.status, status));
|
|
114544
|
+
if (q3) {
|
|
114545
|
+
filters.push(
|
|
114546
|
+
or(
|
|
114547
|
+
ilike(schema_exports.quotations.quotationNumber, `%${q3}%`),
|
|
114548
|
+
ilike(schema_exports.quotations.customerName, `%${q3}%`)
|
|
114549
|
+
)
|
|
114550
|
+
);
|
|
114551
|
+
}
|
|
114552
|
+
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));
|
|
114553
|
+
if (rows.length === 0) {
|
|
114554
|
+
return textResponse4("No quotes found.");
|
|
114555
|
+
}
|
|
114556
|
+
const note = input.projectId ? "\nNote: `projectId` was ignored \u2014 quotations are not linked to projects." : "";
|
|
114557
|
+
return textResponse4(
|
|
114558
|
+
`Found ${rows.length} quote(s):
|
|
114559
|
+
|
|
114560
|
+
${rows.map(formatQuote).join("\n")}${note}`
|
|
114561
|
+
);
|
|
114562
|
+
}
|
|
114563
|
+
async function handleCreateQuote(input) {
|
|
114564
|
+
const { customerId } = input;
|
|
114565
|
+
if (!customerId) return textResponse4("Error: `customerId` is required.");
|
|
114566
|
+
const status = input.status ?? "draft";
|
|
114567
|
+
if (!SAFE_DRAFT_STATUSES.has(status)) {
|
|
114568
|
+
return textResponse4(
|
|
114569
|
+
`Error: this tool only creates draft quotes. Requested status "${status}" is not allowed. Sending/accepting a quote is a manual dashboard action.`
|
|
114570
|
+
);
|
|
114571
|
+
}
|
|
114572
|
+
const resolved = await resolveTeamId(input.teamId);
|
|
114573
|
+
if (!resolved.ok) return resolved.response;
|
|
114574
|
+
const teamId = resolved.teamId;
|
|
114575
|
+
const accessibleTeamIds = await getAccessibleTeamIds(teamId);
|
|
114576
|
+
const [customer] = await db.select({
|
|
114577
|
+
id: schema_exports.customers.id,
|
|
114578
|
+
name: schema_exports.customers.name,
|
|
114579
|
+
addressLine1: schema_exports.customers.addressLine1,
|
|
114580
|
+
city: schema_exports.customers.city,
|
|
114581
|
+
zip: schema_exports.customers.zip,
|
|
114582
|
+
country: schema_exports.customers.country,
|
|
114583
|
+
vatNumber: schema_exports.customers.vatNumber
|
|
114584
|
+
}).from(schema_exports.customers).where(
|
|
114585
|
+
and(
|
|
114586
|
+
eq(schema_exports.customers.id, customerId),
|
|
114587
|
+
inArray(schema_exports.customers.teamId, accessibleTeamIds)
|
|
114588
|
+
)
|
|
114589
|
+
).limit(1);
|
|
114590
|
+
if (!customer) {
|
|
114591
|
+
return textResponse4(
|
|
114592
|
+
`Customer ${customerId} not found or not owned by this team.`
|
|
114593
|
+
);
|
|
114594
|
+
}
|
|
114595
|
+
const defaults = await loadTemplateDefaults(teamId);
|
|
114596
|
+
const { items, error: error49 } = await resolveLineItems(
|
|
114597
|
+
input.lineItems ?? [],
|
|
114598
|
+
defaults,
|
|
114599
|
+
teamId
|
|
114600
|
+
);
|
|
114601
|
+
if (error49) return textResponse4(`Error: ${error49}`);
|
|
114602
|
+
const totals = computeTotals(items, defaults);
|
|
114603
|
+
const quotationNumber = await nextQuotationNumber(teamId);
|
|
114604
|
+
const template = buildQuoteTemplate(defaults, input.title);
|
|
114605
|
+
const customerDetails = {
|
|
114606
|
+
type: "doc",
|
|
114607
|
+
content: [
|
|
114608
|
+
{ type: "paragraph", content: [{ type: "text", text: customer.name }] },
|
|
114609
|
+
...customer.addressLine1 ? [
|
|
114610
|
+
{
|
|
114611
|
+
type: "paragraph",
|
|
114612
|
+
content: [{ type: "text", text: customer.addressLine1 }]
|
|
114613
|
+
}
|
|
114614
|
+
] : [],
|
|
114615
|
+
...customer.zip || customer.city ? [
|
|
114616
|
+
{
|
|
114617
|
+
type: "paragraph",
|
|
114618
|
+
content: [
|
|
114619
|
+
{
|
|
114620
|
+
type: "text",
|
|
114621
|
+
text: [customer.zip, customer.city].filter(Boolean).join(" ")
|
|
114622
|
+
}
|
|
114623
|
+
]
|
|
114624
|
+
}
|
|
114625
|
+
] : [],
|
|
114626
|
+
...customer.country ? [
|
|
114627
|
+
{
|
|
114628
|
+
type: "paragraph",
|
|
114629
|
+
content: [{ type: "text", text: customer.country }]
|
|
114630
|
+
}
|
|
114631
|
+
] : [],
|
|
114632
|
+
...customer.vatNumber ? [
|
|
114633
|
+
{
|
|
114634
|
+
type: "paragraph",
|
|
114635
|
+
content: [{ type: "text", text: `BTW: ${customer.vatNumber}` }]
|
|
114636
|
+
}
|
|
114637
|
+
] : []
|
|
114638
|
+
]
|
|
114639
|
+
};
|
|
114640
|
+
const [created] = await db.insert(schema_exports.quotations).values({
|
|
114641
|
+
teamId,
|
|
114642
|
+
userId: null,
|
|
114643
|
+
status: "draft",
|
|
114644
|
+
quotationNumber,
|
|
114645
|
+
customerId: customer.id,
|
|
114646
|
+
customerName: customer.name,
|
|
114647
|
+
currency: defaults.currency.toUpperCase(),
|
|
114648
|
+
template,
|
|
114649
|
+
customerDetails,
|
|
114650
|
+
fromDetails: defaults.fromDetails ?? null,
|
|
114651
|
+
paymentDetails: defaults.paymentDetails ?? null,
|
|
114652
|
+
noteDetails: input.description ? tiptapNote(input.description) : null,
|
|
114653
|
+
issueDate: (/* @__PURE__ */ new Date()).toISOString(),
|
|
114654
|
+
validUntil: input.validUntil ?? null,
|
|
114655
|
+
lineItems: items,
|
|
114656
|
+
subtotal: totals.subtotal,
|
|
114657
|
+
vat: totals.vat,
|
|
114658
|
+
tax: totals.tax,
|
|
114659
|
+
amount: totals.amount
|
|
114660
|
+
}).returning(QUOTE_COLUMNS);
|
|
114661
|
+
if (!created) return textResponse4("Failed to create quote.");
|
|
114662
|
+
return textResponse4(
|
|
114663
|
+
`\u2705 **Draft quote created**
|
|
114664
|
+
|
|
114665
|
+
${formatQuote(created)}
|
|
114666
|
+
Status is \`draft\`. Review, then send/accept manually from the dashboard.`
|
|
114667
|
+
);
|
|
114668
|
+
}
|
|
114669
|
+
async function loadQuoteInTeam(id, teamId) {
|
|
114670
|
+
const accessibleTeamIds = await getAccessibleTeamIds(teamId);
|
|
114671
|
+
const [row] = await db.select(QUOTE_COLUMNS).from(schema_exports.quotations).where(
|
|
114672
|
+
and(
|
|
114673
|
+
eq(schema_exports.quotations.id, id),
|
|
114674
|
+
inArray(schema_exports.quotations.teamId, accessibleTeamIds)
|
|
114675
|
+
)
|
|
114676
|
+
).limit(1);
|
|
114677
|
+
return row ?? null;
|
|
114678
|
+
}
|
|
114679
|
+
function notDraftResponse(quote) {
|
|
114680
|
+
return textResponse4(
|
|
114681
|
+
`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.`
|
|
114682
|
+
);
|
|
114683
|
+
}
|
|
114684
|
+
async function handleUpdateQuote(input) {
|
|
114685
|
+
const { id } = input;
|
|
114686
|
+
if (!id) return textResponse4("Error: `id` is required.");
|
|
114687
|
+
if (input.status !== void 0 && !SAFE_DRAFT_STATUSES.has(input.status)) {
|
|
114688
|
+
return textResponse4(
|
|
114689
|
+
`Error: status can only stay within {${[...SAFE_DRAFT_STATUSES].join(", ")}}. "${input.status}" (send/accept/reject/expire) must be done manually from the dashboard.`
|
|
114690
|
+
);
|
|
114691
|
+
}
|
|
113510
114692
|
const resolved = await resolveTeamId(input.teamId);
|
|
113511
114693
|
if (!resolved.ok) return resolved.response;
|
|
113512
|
-
const
|
|
113513
|
-
if (!
|
|
113514
|
-
return
|
|
113515
|
-
`Product ${productId} not found, or it is not owned by this team.`
|
|
113516
|
-
);
|
|
114694
|
+
const quote = await loadQuoteInTeam(id, resolved.teamId);
|
|
114695
|
+
if (!quote) {
|
|
114696
|
+
return textResponse4(`Quote ${id} not found or not owned by this team.`);
|
|
113517
114697
|
}
|
|
114698
|
+
if (quote.status !== "draft") return notDraftResponse(quote);
|
|
114699
|
+
const defaults = templateDefaultsFromStored(quote.template, quote.currency);
|
|
113518
114700
|
const updates = {};
|
|
113519
|
-
if (input.
|
|
113520
|
-
|
|
113521
|
-
|
|
113522
|
-
|
|
113523
|
-
updates.
|
|
114701
|
+
if (input.title !== void 0) {
|
|
114702
|
+
updates.template = buildQuoteTemplate(defaults, input.title);
|
|
114703
|
+
}
|
|
114704
|
+
if (input.description !== void 0) {
|
|
114705
|
+
updates.noteDetails = input.description ? tiptapNote(input.description) : null;
|
|
114706
|
+
}
|
|
114707
|
+
if (input.validUntil !== void 0) {
|
|
114708
|
+
updates.validUntil = input.validUntil;
|
|
114709
|
+
}
|
|
114710
|
+
if (input.lineItems !== void 0) {
|
|
114711
|
+
const { items, error: error49 } = await resolveLineItems(
|
|
114712
|
+
input.lineItems,
|
|
114713
|
+
defaults,
|
|
114714
|
+
quote.teamId
|
|
114715
|
+
);
|
|
114716
|
+
if (error49) return textResponse4(`Error: ${error49}`);
|
|
114717
|
+
const totals = computeTotals(items, defaults);
|
|
114718
|
+
updates.lineItems = items;
|
|
114719
|
+
updates.subtotal = totals.subtotal;
|
|
114720
|
+
updates.vat = totals.vat;
|
|
114721
|
+
updates.tax = totals.tax;
|
|
114722
|
+
updates.amount = totals.amount;
|
|
113524
114723
|
}
|
|
113525
|
-
if (input.description !== void 0) updates.description = input.description;
|
|
113526
|
-
if (input.price !== void 0) updates.price = input.price;
|
|
113527
|
-
if (input.currency !== void 0) updates.currency = input.currency;
|
|
113528
|
-
if (input.unit !== void 0) updates.unit = input.unit;
|
|
113529
|
-
if (input.isActive !== void 0) updates.isActive = input.isActive;
|
|
113530
114724
|
if (Object.keys(updates).length === 0) {
|
|
113531
|
-
return
|
|
113532
|
-
"No fields to update. Provide at least one of:
|
|
114725
|
+
return textResponse4(
|
|
114726
|
+
"No fields to update. Provide at least one of: title, description, validUntil, lineItems."
|
|
113533
114727
|
);
|
|
113534
114728
|
}
|
|
113535
114729
|
updates.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
113536
|
-
const [updated] = await db.update(schema_exports.
|
|
113537
|
-
if (!updated) return
|
|
113538
|
-
return
|
|
113539
|
-
`\u2705 **Product updated**
|
|
114730
|
+
const [updated] = await db.update(schema_exports.quotations).set(updates).where(eq(schema_exports.quotations.id, quote.id)).returning(QUOTE_COLUMNS);
|
|
114731
|
+
if (!updated) return textResponse4(`Failed to update quote ${id}.`);
|
|
114732
|
+
return textResponse4(`\u2705 **Draft quote updated**
|
|
113540
114733
|
|
|
113541
|
-
${
|
|
113542
|
-
Note: this only affects future invoices/quotes. Existing documents keep their line-item snapshots.`
|
|
113543
|
-
);
|
|
114734
|
+
${formatQuote(updated)}`);
|
|
113544
114735
|
}
|
|
113545
|
-
async function
|
|
113546
|
-
const {
|
|
113547
|
-
if (!
|
|
114736
|
+
async function handleAddProductToQuote(input) {
|
|
114737
|
+
const { quoteId, productId } = input;
|
|
114738
|
+
if (!quoteId) return textResponse4("Error: `quoteId` is required.");
|
|
114739
|
+
if (!productId) return textResponse4("Error: `productId` is required.");
|
|
113548
114740
|
const resolved = await resolveTeamId(input.teamId);
|
|
113549
114741
|
if (!resolved.ok) return resolved.response;
|
|
113550
|
-
const
|
|
113551
|
-
if (!
|
|
113552
|
-
return
|
|
113553
|
-
|
|
114742
|
+
const quote = await loadQuoteInTeam(quoteId, resolved.teamId);
|
|
114743
|
+
if (!quote) {
|
|
114744
|
+
return textResponse4(`Quote ${quoteId} not found or not owned by this team.`);
|
|
114745
|
+
}
|
|
114746
|
+
if (quote.status !== "draft") return notDraftResponse(quote);
|
|
114747
|
+
const products = await loadProductsInTeam([productId], quote.teamId);
|
|
114748
|
+
const product = products.get(productId);
|
|
114749
|
+
if (!product) {
|
|
114750
|
+
return textResponse4(
|
|
114751
|
+
`Product ${productId} not found or not owned by this team.`
|
|
113554
114752
|
);
|
|
113555
114753
|
}
|
|
113556
|
-
|
|
113557
|
-
|
|
113558
|
-
|
|
113559
|
-
|
|
114754
|
+
const defaults = templateDefaultsFromStored(quote.template, quote.currency);
|
|
114755
|
+
const newItem = lineItemFromProduct(
|
|
114756
|
+
product,
|
|
114757
|
+
{
|
|
114758
|
+
quantity: input.quantity,
|
|
114759
|
+
customDescription: input.customDescription,
|
|
114760
|
+
customPrice: input.customPrice
|
|
114761
|
+
},
|
|
114762
|
+
defaults
|
|
114763
|
+
);
|
|
114764
|
+
const existing = Array.isArray(quote.lineItems) ? quote.lineItems : [];
|
|
114765
|
+
const items = [...existing, newItem];
|
|
114766
|
+
const totals = computeTotals(items, defaults);
|
|
114767
|
+
const [updated] = await db.update(schema_exports.quotations).set({
|
|
114768
|
+
lineItems: items,
|
|
114769
|
+
subtotal: totals.subtotal,
|
|
114770
|
+
vat: totals.vat,
|
|
114771
|
+
tax: totals.tax,
|
|
114772
|
+
amount: totals.amount,
|
|
114773
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
114774
|
+
}).where(eq(schema_exports.quotations.id, quote.id)).returning(QUOTE_COLUMNS);
|
|
114775
|
+
if (!updated) {
|
|
114776
|
+
return textResponse4(`Failed to add product to quote ${quoteId}.`);
|
|
113560
114777
|
}
|
|
113561
|
-
|
|
113562
|
-
|
|
113563
|
-
|
|
113564
|
-
|
|
114778
|
+
await db.update(schema_exports.invoiceProducts).set({
|
|
114779
|
+
usageCount: sql`${schema_exports.invoiceProducts.usageCount} + 1`,
|
|
114780
|
+
lastUsedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
114781
|
+
}).where(eq(schema_exports.invoiceProducts.id, product.id));
|
|
114782
|
+
const snap = newItem.productSnapshot;
|
|
114783
|
+
return textResponse4(
|
|
114784
|
+
`\u2705 **Product added to draft quote ${updated.quotationNumber ?? updated.id}**
|
|
113565
114785
|
|
|
113566
|
-
${
|
|
113567
|
-
|
|
114786
|
+
Line item: ${newItem.name} \xD7 ${newItem.quantity}${newItem.unit ? ` ${newItem.unit}` : ""} @ ${newItem.price} ${snap.currency}
|
|
114787
|
+
Snapshot: name="${snap.name}", unitPrice=${snap.unitPrice}, currency=${snap.currency}, vatRate=${snap.vatRate}%, unit=${snap.unit ?? "-"}
|
|
114788
|
+
|
|
114789
|
+
New quote total: ${updated.amount} ${updated.currency} (subtotal ${updated.subtotal}, VAT ${updated.vat})
|
|
114790
|
+
The snapshot is immutable: later catalog edits won't change this quote.`
|
|
113568
114791
|
);
|
|
113569
114792
|
}
|
|
113570
114793
|
|
|
@@ -119059,7 +120282,7 @@ var EXT_MIME = {
|
|
|
119059
120282
|
ppt: "application/vnd.ms-powerpoint",
|
|
119060
120283
|
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
|
119061
120284
|
};
|
|
119062
|
-
function
|
|
120285
|
+
function textResponse5(text3) {
|
|
119063
120286
|
return { content: [{ type: "text", text: text3 }] };
|
|
119064
120287
|
}
|
|
119065
120288
|
function mimeFromName(name21) {
|
|
@@ -119140,12 +120363,12 @@ async function handleUploadTicketAttachment(input) {
|
|
|
119140
120363
|
(v2) => typeof v2 === "string" && v2.trim().length > 0
|
|
119141
120364
|
);
|
|
119142
120365
|
if (sources.length === 0) {
|
|
119143
|
-
return
|
|
120366
|
+
return textResponse5(
|
|
119144
120367
|
"Provide exactly one source: filePath (absolute local path), imageUrl, or base64Data."
|
|
119145
120368
|
);
|
|
119146
120369
|
}
|
|
119147
120370
|
if (sources.length > 1) {
|
|
119148
|
-
return
|
|
120371
|
+
return textResponse5(
|
|
119149
120372
|
"Provide only one source (filePath, imageUrl, or base64Data), not several."
|
|
119150
120373
|
);
|
|
119151
120374
|
}
|
|
@@ -119165,7 +120388,7 @@ async function handleUploadTicketAttachment(input) {
|
|
|
119165
120388
|
} else if (input.imageUrl) {
|
|
119166
120389
|
const res = await fetch(input.imageUrl);
|
|
119167
120390
|
if (!res.ok) {
|
|
119168
|
-
return
|
|
120391
|
+
return textResponse5(
|
|
119169
120392
|
`Could not download from URL: HTTP ${res.status}.`
|
|
119170
120393
|
);
|
|
119171
120394
|
}
|
|
@@ -119193,22 +120416,22 @@ async function handleUploadTicketAttachment(input) {
|
|
|
119193
120416
|
}
|
|
119194
120417
|
}
|
|
119195
120418
|
} catch (error49) {
|
|
119196
|
-
return
|
|
120419
|
+
return textResponse5(
|
|
119197
120420
|
`Failed to read the file: ${error49 instanceof Error ? error49.message : String(error49)}`
|
|
119198
120421
|
);
|
|
119199
120422
|
}
|
|
119200
120423
|
if (buffer2.byteLength === 0) {
|
|
119201
|
-
return
|
|
120424
|
+
return textResponse5("The file is empty (0 bytes); nothing to upload.");
|
|
119202
120425
|
}
|
|
119203
120426
|
if (buffer2.byteLength > MAX_FILE_SIZE) {
|
|
119204
|
-
return
|
|
120427
|
+
return textResponse5(
|
|
119205
120428
|
`File too large (${(buffer2.byteLength / 1024 / 1024).toFixed(
|
|
119206
120429
|
1
|
|
119207
120430
|
)} MB). Max: 25 MB.`
|
|
119208
120431
|
);
|
|
119209
120432
|
}
|
|
119210
120433
|
if (!ALLOWED_MIME_TYPES.has(mimeType)) {
|
|
119211
|
-
return
|
|
120434
|
+
return textResponse5(
|
|
119212
120435
|
`Unsupported file type: ${mimeType}. Allowed: JPEG, PNG, GIF, WebP, PDF, DOC(X), XLS(X), PPT(X), TXT, CSV.`
|
|
119213
120436
|
);
|
|
119214
120437
|
}
|
|
@@ -119221,7 +120444,7 @@ async function handleUploadTicketAttachment(input) {
|
|
|
119221
120444
|
options: { contentType: mimeType, upsert: true }
|
|
119222
120445
|
});
|
|
119223
120446
|
} catch (error49) {
|
|
119224
|
-
return
|
|
120447
|
+
return textResponse5(
|
|
119225
120448
|
`Upload failed: ${error49 instanceof Error ? error49.message : String(error49)}`
|
|
119226
120449
|
);
|
|
119227
120450
|
}
|
|
@@ -119244,7 +120467,7 @@ async function handleUploadTicketAttachment(input) {
|
|
|
119244
120467
|
url3 = signed.url;
|
|
119245
120468
|
} catch {
|
|
119246
120469
|
}
|
|
119247
|
-
return
|
|
120470
|
+
return textResponse5(
|
|
119248
120471
|
`\u{1F4CE} **Attached to ${ticket.ticketNumber}**
|
|
119249
120472
|
File: ${fileName}
|
|
119250
120473
|
Type: ${mimeType}
|
|
@@ -119670,7 +120893,7 @@ function formatTagUsage(usage) {
|
|
|
119670
120893
|
}
|
|
119671
120894
|
|
|
119672
120895
|
// src/tools/tag-management.ts
|
|
119673
|
-
function
|
|
120896
|
+
function textResponse6(text3) {
|
|
119674
120897
|
return { content: [{ type: "text", text: text3 }] };
|
|
119675
120898
|
}
|
|
119676
120899
|
var TAG_COLUMNS = {
|
|
@@ -119711,24 +120934,24 @@ function scopeFilter(projectId) {
|
|
|
119711
120934
|
return projectId === null ? isNull(schema_exports.tags.projectId) : eq(schema_exports.tags.projectId, projectId);
|
|
119712
120935
|
}
|
|
119713
120936
|
async function handleUpdateTag(input) {
|
|
119714
|
-
if (!input.tagId) return
|
|
120937
|
+
if (!input.tagId) return textResponse6("Error: `tagId` is required.");
|
|
119715
120938
|
const resolved = await resolveTeamId(input.teamId);
|
|
119716
120939
|
if (!resolved.ok) return resolved.response;
|
|
119717
120940
|
const existing = await loadTagInTeam(input.tagId, resolved.teamId);
|
|
119718
120941
|
if (!existing) {
|
|
119719
|
-
return
|
|
120942
|
+
return textResponse6(
|
|
119720
120943
|
`Tag ${input.tagId} not found, or it is not owned by this team.`
|
|
119721
120944
|
);
|
|
119722
120945
|
}
|
|
119723
120946
|
const renaming = input.name !== void 0;
|
|
119724
120947
|
const rescoping = input.projectId !== void 0;
|
|
119725
120948
|
if (!renaming && !rescoping) {
|
|
119726
|
-
return
|
|
120949
|
+
return textResponse6(
|
|
119727
120950
|
"No changes requested. Provide `name` to rename and/or `projectId` (string, or null for a general tag) to change scope."
|
|
119728
120951
|
);
|
|
119729
120952
|
}
|
|
119730
120953
|
if (renaming && !isValidTagName(input.name)) {
|
|
119731
|
-
return
|
|
120954
|
+
return textResponse6("Error: `name` cannot be empty.");
|
|
119732
120955
|
}
|
|
119733
120956
|
const nextName = renaming ? input.name.trim() : existing.name;
|
|
119734
120957
|
const nextProjectId = rescoping ? input.projectId ?? null : existing.projectId;
|
|
@@ -119741,13 +120964,13 @@ async function handleUpdateTag(input) {
|
|
|
119741
120964
|
)
|
|
119742
120965
|
).limit(1);
|
|
119743
120966
|
if (collision) {
|
|
119744
|
-
return
|
|
120967
|
+
return textResponse6(
|
|
119745
120968
|
`\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.`
|
|
119746
120969
|
);
|
|
119747
120970
|
}
|
|
119748
120971
|
const [updated] = await db.update(schema_exports.tags).set({ name: nextName, projectId: nextProjectId }).where(eq(schema_exports.tags.id, existing.id)).returning(TAG_COLUMNS);
|
|
119749
|
-
if (!updated) return
|
|
119750
|
-
return
|
|
120972
|
+
if (!updated) return textResponse6(`Failed to update tag ${input.tagId}.`);
|
|
120973
|
+
return textResponse6(
|
|
119751
120974
|
`\u2705 **Tag updated**
|
|
119752
120975
|
|
|
119753
120976
|
${describeTag(updated)}
|
|
@@ -119756,34 +120979,34 @@ Existing ticket/customer/project/transaction tag relations are preserved.`
|
|
|
119756
120979
|
);
|
|
119757
120980
|
}
|
|
119758
120981
|
async function handleDeleteTag(input) {
|
|
119759
|
-
if (!input.tagId) return
|
|
120982
|
+
if (!input.tagId) return textResponse6("Error: `tagId` is required.");
|
|
119760
120983
|
const mode = input.mode ?? "delete_if_unused";
|
|
119761
120984
|
const resolved = await resolveTeamId(input.teamId);
|
|
119762
120985
|
if (!resolved.ok) return resolved.response;
|
|
119763
120986
|
const existing = await loadTagInTeam(input.tagId, resolved.teamId);
|
|
119764
120987
|
if (!existing) {
|
|
119765
|
-
return
|
|
120988
|
+
return textResponse6(
|
|
119766
120989
|
`Tag ${input.tagId} not found, or it is not owned by this team.`
|
|
119767
120990
|
);
|
|
119768
120991
|
}
|
|
119769
120992
|
const usage = await getTagUsage(existing.id);
|
|
119770
120993
|
const total = totalTagUsage(usage);
|
|
119771
120994
|
if (mode === "archive") {
|
|
119772
|
-
return
|
|
120995
|
+
return textResponse6(
|
|
119773
120996
|
`\u2139\uFE0F Archiving is not supported for team tags: the \`tags\` table has no archived column. ${describeTag(existing)} is used by ${formatTagUsage(usage)}.
|
|
119774
120997
|
|
|
119775
120998
|
Options: use merge-tags to fold it into another tag, or delete it once it is unused (mode: delete_if_unused).`
|
|
119776
120999
|
);
|
|
119777
121000
|
}
|
|
119778
121001
|
if (total > 0) {
|
|
119779
|
-
return
|
|
121002
|
+
return textResponse6(
|
|
119780
121003
|
`\u274C Refusing to delete ${describeTag(existing)}: it is still used by ${formatTagUsage(usage)}. Deleting would strip the tag off those entities.
|
|
119781
121004
|
|
|
119782
121005
|
Use merge-tags to move usage onto another tag first, then delete the (now-empty) tag.`
|
|
119783
121006
|
);
|
|
119784
121007
|
}
|
|
119785
121008
|
await db.delete(schema_exports.tags).where(eq(schema_exports.tags.id, existing.id));
|
|
119786
|
-
return
|
|
121009
|
+
return textResponse6(
|
|
119787
121010
|
`\u2705 **Tag deleted** (was unused): ${describeTag(existing)}`
|
|
119788
121011
|
);
|
|
119789
121012
|
}
|
|
@@ -119793,7 +121016,7 @@ async function resolveMergeTarget(teamId, input) {
|
|
|
119793
121016
|
if (!tag) {
|
|
119794
121017
|
return {
|
|
119795
121018
|
ok: false,
|
|
119796
|
-
response:
|
|
121019
|
+
response: textResponse6(
|
|
119797
121020
|
`Target tag ${input.targetTagId} not found, or it is not owned by this team.`
|
|
119798
121021
|
)
|
|
119799
121022
|
};
|
|
@@ -119803,7 +121026,7 @@ async function resolveMergeTarget(teamId, input) {
|
|
|
119803
121026
|
if (!isValidTagName(input.targetName)) {
|
|
119804
121027
|
return {
|
|
119805
121028
|
ok: false,
|
|
119806
|
-
response:
|
|
121029
|
+
response: textResponse6(
|
|
119807
121030
|
"Error: provide either `targetTagId` or a non-empty `targetName`."
|
|
119808
121031
|
)
|
|
119809
121032
|
};
|
|
@@ -119821,14 +121044,14 @@ async function resolveMergeTarget(teamId, input) {
|
|
|
119821
121044
|
}
|
|
119822
121045
|
const [created] = await db.insert(schema_exports.tags).values({ teamId, name: input.targetName.trim(), projectId: null }).returning(TAG_COLUMNS);
|
|
119823
121046
|
if (!created) {
|
|
119824
|
-
return { ok: false, response:
|
|
121047
|
+
return { ok: false, response: textResponse6("Failed to create target tag.") };
|
|
119825
121048
|
}
|
|
119826
121049
|
return { ok: true, tag: created, created: true };
|
|
119827
121050
|
}
|
|
119828
121051
|
async function handleMergeTags(input) {
|
|
119829
121052
|
const rawSourceIds = [...new Set(input.sourceTagIds ?? [])].filter(Boolean);
|
|
119830
121053
|
if (rawSourceIds.length === 0) {
|
|
119831
|
-
return
|
|
121054
|
+
return textResponse6("Error: `sourceTagIds` must contain at least one tag id.");
|
|
119832
121055
|
}
|
|
119833
121056
|
const resolved = await resolveTeamId(input.teamId);
|
|
119834
121057
|
if (!resolved.ok) return resolved.response;
|
|
@@ -119842,7 +121065,7 @@ async function handleMergeTags(input) {
|
|
|
119842
121065
|
const foundIds = new Set(sourceTags.map((t8) => t8.id));
|
|
119843
121066
|
const missing = rawSourceIds.filter((id) => !foundIds.has(id));
|
|
119844
121067
|
if (missing.length > 0) {
|
|
119845
|
-
return
|
|
121068
|
+
return textResponse6(
|
|
119846
121069
|
`Error: source tag(s) not found or not owned by this team: ${missing.join(", ")}.`
|
|
119847
121070
|
);
|
|
119848
121071
|
}
|
|
@@ -119850,7 +121073,7 @@ async function handleMergeTags(input) {
|
|
|
119850
121073
|
if (!target.ok) return target.response;
|
|
119851
121074
|
const sourcesToMerge = sourceTags.filter((t8) => t8.id !== target.tag.id);
|
|
119852
121075
|
if (sourcesToMerge.length === 0) {
|
|
119853
|
-
return
|
|
121076
|
+
return textResponse6(
|
|
119854
121077
|
"Error: nothing to merge \u2014 the only source tag is the same as the target tag."
|
|
119855
121078
|
);
|
|
119856
121079
|
}
|
|
@@ -119947,7 +121170,7 @@ async function handleMergeTags(input) {
|
|
|
119947
121170
|
const movedTotal = results.tickets.moved + results.customers.moved + results.projects.moved + results.transactions.moved;
|
|
119948
121171
|
const skippedTotal = results.tickets.skipped + results.customers.skipped + results.projects.skipped + results.transactions.skipped;
|
|
119949
121172
|
const line2 = (label, r6) => `- ${label}: ${r6.moved} moved, ${r6.skipped} skipped (duplicate)`;
|
|
119950
|
-
return
|
|
121173
|
+
return textResponse6(
|
|
119951
121174
|
`\u2705 **Tags merged** into ${describeTag(target.tag)}${target.created ? " (newly created)" : ""}
|
|
119952
121175
|
|
|
119953
121176
|
Sources (${sourcesToMerge.length}): ${sourcesToMerge.map((t8) => `${t8.name} (${t8.id})`).join(", ")}
|
|
@@ -120294,6 +121517,527 @@ ${changes.map((c6) => ` \u2022 ${c6}`).join("\n")}` : "No field changes were a
|
|
|
120294
121517
|
};
|
|
120295
121518
|
}
|
|
120296
121519
|
|
|
121520
|
+
// src/tools/trip-billing-util.ts
|
|
121521
|
+
var TRIP_BILLING_TYPES = [
|
|
121522
|
+
"not_billable",
|
|
121523
|
+
"per_km",
|
|
121524
|
+
"per_trip"
|
|
121525
|
+
];
|
|
121526
|
+
var TRIP_LOCKED_FIELDS = [
|
|
121527
|
+
"date",
|
|
121528
|
+
"startLocation",
|
|
121529
|
+
"endLocation",
|
|
121530
|
+
"tripType",
|
|
121531
|
+
"distance",
|
|
121532
|
+
"odometerStart",
|
|
121533
|
+
"odometerEnd",
|
|
121534
|
+
"billingType",
|
|
121535
|
+
"rate",
|
|
121536
|
+
"amount",
|
|
121537
|
+
"invoiceId",
|
|
121538
|
+
"isInvoiced"
|
|
121539
|
+
];
|
|
121540
|
+
function round22(value) {
|
|
121541
|
+
return Math.round(value * 100) / 100;
|
|
121542
|
+
}
|
|
121543
|
+
function deriveTripAmount(input) {
|
|
121544
|
+
if (input.amount != null) return input.amount;
|
|
121545
|
+
if (input.rate == null) return null;
|
|
121546
|
+
if (input.billingType === "per_trip") return round22(input.rate);
|
|
121547
|
+
if (input.billingType === "per_km" && input.distance != null) {
|
|
121548
|
+
return round22(input.distance * input.rate);
|
|
121549
|
+
}
|
|
121550
|
+
return null;
|
|
121551
|
+
}
|
|
121552
|
+
function attemptedLockedFields(update) {
|
|
121553
|
+
return TRIP_LOCKED_FIELDS.filter((field) => update[field] !== void 0);
|
|
121554
|
+
}
|
|
121555
|
+
|
|
121556
|
+
// src/tools/trips.ts
|
|
121557
|
+
var TRIP_TYPES = ["private", "business"];
|
|
121558
|
+
var BILLING_TYPES = TRIP_BILLING_TYPES;
|
|
121559
|
+
function textResponse7(text3) {
|
|
121560
|
+
return { content: [{ type: "text", text: text3 }] };
|
|
121561
|
+
}
|
|
121562
|
+
function jsonResponse(payload) {
|
|
121563
|
+
return {
|
|
121564
|
+
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }]
|
|
121565
|
+
};
|
|
121566
|
+
}
|
|
121567
|
+
function toNumber2(value) {
|
|
121568
|
+
if (value == null) return 0;
|
|
121569
|
+
if (typeof value === "number") return value;
|
|
121570
|
+
const parsed = Number.parseFloat(String(value));
|
|
121571
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
121572
|
+
}
|
|
121573
|
+
function formatTrip(t8) {
|
|
121574
|
+
return {
|
|
121575
|
+
id: t8.id,
|
|
121576
|
+
date: t8.date,
|
|
121577
|
+
startLocation: t8.startLocation,
|
|
121578
|
+
endLocation: t8.endLocation,
|
|
121579
|
+
tripType: t8.tripType,
|
|
121580
|
+
distance: t8.distance != null ? toNumber2(t8.distance) : null,
|
|
121581
|
+
odometerStart: t8.odometerStart != null ? toNumber2(t8.odometerStart) : null,
|
|
121582
|
+
odometerEnd: t8.odometerEnd != null ? toNumber2(t8.odometerEnd) : null,
|
|
121583
|
+
billingType: t8.billingType,
|
|
121584
|
+
rate: t8.rate,
|
|
121585
|
+
amount: t8.amount,
|
|
121586
|
+
isInvoiced: t8.isInvoiced,
|
|
121587
|
+
invoiceId: t8.invoiceId,
|
|
121588
|
+
notes: t8.notes,
|
|
121589
|
+
user: t8.user ? { id: t8.user.id, name: t8.user.fullName } : null,
|
|
121590
|
+
project: t8.project ? { id: t8.project.id, name: t8.project.name } : null,
|
|
121591
|
+
customer: t8.customer ? { id: t8.customer.id, name: t8.customer.name } : null,
|
|
121592
|
+
invoice: t8.invoice ? {
|
|
121593
|
+
id: t8.invoice.id,
|
|
121594
|
+
invoiceNumber: t8.invoice.invoiceNumber,
|
|
121595
|
+
status: t8.invoice.status
|
|
121596
|
+
} : null,
|
|
121597
|
+
vehicle: t8.vehicle ? {
|
|
121598
|
+
id: t8.vehicle.id,
|
|
121599
|
+
name: t8.vehicle.name,
|
|
121600
|
+
licensePlate: t8.vehicle.licensePlate
|
|
121601
|
+
} : null,
|
|
121602
|
+
snapshotId: t8.snapshotId,
|
|
121603
|
+
linkedTripId: t8.linkedTripId
|
|
121604
|
+
};
|
|
121605
|
+
}
|
|
121606
|
+
var TRIP_RELATIONS = {
|
|
121607
|
+
user: { columns: { id: true, fullName: true } },
|
|
121608
|
+
project: { columns: { id: true, name: true } },
|
|
121609
|
+
customer: { columns: { id: true, name: true } },
|
|
121610
|
+
invoice: { columns: { id: true, invoiceNumber: true, status: true } },
|
|
121611
|
+
vehicle: { columns: { id: true, name: true, licensePlate: true } }
|
|
121612
|
+
};
|
|
121613
|
+
async function handleGetTrips(input) {
|
|
121614
|
+
if (input.tripType && !TRIP_TYPES.includes(input.tripType)) {
|
|
121615
|
+
return textResponse7(
|
|
121616
|
+
`Error: invalid tripType "${input.tripType}". Allowed: ${TRIP_TYPES.join(", ")}.`
|
|
121617
|
+
);
|
|
121618
|
+
}
|
|
121619
|
+
if (input.billingType && !BILLING_TYPES.includes(input.billingType)) {
|
|
121620
|
+
return textResponse7(
|
|
121621
|
+
`Error: invalid billingType "${input.billingType}". Allowed: ${BILLING_TYPES.join(", ")}.`
|
|
121622
|
+
);
|
|
121623
|
+
}
|
|
121624
|
+
const scope = await resolveTeamScope(input.teamId);
|
|
121625
|
+
if (!scope.ok) return scope.response;
|
|
121626
|
+
if (scope.teamIds.length === 0) {
|
|
121627
|
+
return textResponse7("No accessible teams found.");
|
|
121628
|
+
}
|
|
121629
|
+
const filters = [inArray(schema_exports.trips.teamId, scope.teamIds)];
|
|
121630
|
+
if (input.dateFrom) filters.push(gte(schema_exports.trips.date, input.dateFrom));
|
|
121631
|
+
if (input.dateTo) filters.push(lte(schema_exports.trips.date, input.dateTo));
|
|
121632
|
+
if (input.userId) filters.push(eq(schema_exports.trips.userId, input.userId));
|
|
121633
|
+
if (input.projectId) {
|
|
121634
|
+
filters.push(eq(schema_exports.trips.projectId, input.projectId));
|
|
121635
|
+
}
|
|
121636
|
+
if (input.customerId) {
|
|
121637
|
+
filters.push(eq(schema_exports.trips.customerId, input.customerId));
|
|
121638
|
+
}
|
|
121639
|
+
if (input.tripType) {
|
|
121640
|
+
filters.push(eq(schema_exports.trips.tripType, input.tripType));
|
|
121641
|
+
}
|
|
121642
|
+
if (input.billingType) {
|
|
121643
|
+
filters.push(
|
|
121644
|
+
eq(schema_exports.trips.billingType, input.billingType)
|
|
121645
|
+
);
|
|
121646
|
+
}
|
|
121647
|
+
if (input.isInvoiced !== void 0) {
|
|
121648
|
+
filters.push(eq(schema_exports.trips.isInvoiced, input.isInvoiced));
|
|
121649
|
+
}
|
|
121650
|
+
const pageSize = Math.min(input.pageSize ?? 50, 200);
|
|
121651
|
+
const rows = await db.query.trips.findMany({
|
|
121652
|
+
where: and(...filters),
|
|
121653
|
+
with: TRIP_RELATIONS,
|
|
121654
|
+
orderBy: [desc(schema_exports.trips.date), desc(schema_exports.trips.createdAt)],
|
|
121655
|
+
limit: pageSize
|
|
121656
|
+
});
|
|
121657
|
+
const totals = rows.reduce(
|
|
121658
|
+
(acc, t8) => {
|
|
121659
|
+
const distance = toNumber2(t8.distance);
|
|
121660
|
+
const amount = t8.amount ?? 0;
|
|
121661
|
+
if (t8.tripType === "business") acc.businessKm += distance;
|
|
121662
|
+
else acc.privateKm += distance;
|
|
121663
|
+
acc.totalKm += distance;
|
|
121664
|
+
acc.totalAmount += amount;
|
|
121665
|
+
return acc;
|
|
121666
|
+
},
|
|
121667
|
+
{ businessKm: 0, privateKm: 0, totalKm: 0, totalAmount: 0 }
|
|
121668
|
+
);
|
|
121669
|
+
return jsonResponse({
|
|
121670
|
+
count: rows.length,
|
|
121671
|
+
totals: {
|
|
121672
|
+
businessKm: round22(totals.businessKm),
|
|
121673
|
+
privateKm: round22(totals.privateKm),
|
|
121674
|
+
totalKm: round22(totals.totalKm),
|
|
121675
|
+
totalAmount: round22(totals.totalAmount)
|
|
121676
|
+
},
|
|
121677
|
+
trips: rows.map(formatTrip)
|
|
121678
|
+
});
|
|
121679
|
+
}
|
|
121680
|
+
async function loadTripInTeams(id, teamIds) {
|
|
121681
|
+
const row = await db.query.trips.findFirst({
|
|
121682
|
+
where: and(
|
|
121683
|
+
eq(schema_exports.trips.id, id),
|
|
121684
|
+
inArray(schema_exports.trips.teamId, teamIds)
|
|
121685
|
+
),
|
|
121686
|
+
with: TRIP_RELATIONS
|
|
121687
|
+
});
|
|
121688
|
+
return row ?? null;
|
|
121689
|
+
}
|
|
121690
|
+
async function validateLinks(ctxUserId, teamId, links) {
|
|
121691
|
+
if (links.projectId) {
|
|
121692
|
+
const projectIds = await getAccessibleProjectIds(ctxUserId, teamId);
|
|
121693
|
+
if (!projectIds.includes(links.projectId)) {
|
|
121694
|
+
return `Project not found or no access: ${links.projectId}. Call get-projects first.`;
|
|
121695
|
+
}
|
|
121696
|
+
}
|
|
121697
|
+
if (links.customerId) {
|
|
121698
|
+
const customerIds = await getAccessibleCustomerIds(teamId);
|
|
121699
|
+
if (!customerIds.includes(links.customerId)) {
|
|
121700
|
+
return `Customer not found or no access: ${links.customerId}. Call get-customers first.`;
|
|
121701
|
+
}
|
|
121702
|
+
}
|
|
121703
|
+
if (links.vehicleId) {
|
|
121704
|
+
const accessibleTeamIds = await getAccessibleTeamIds(teamId);
|
|
121705
|
+
const [vehicle] = await db.select({ id: schema_exports.vehicles.id }).from(schema_exports.vehicles).where(
|
|
121706
|
+
and(
|
|
121707
|
+
eq(schema_exports.vehicles.id, links.vehicleId),
|
|
121708
|
+
inArray(schema_exports.vehicles.teamId, accessibleTeamIds)
|
|
121709
|
+
)
|
|
121710
|
+
).limit(1);
|
|
121711
|
+
if (!vehicle) {
|
|
121712
|
+
return `Vehicle not found or no access: ${links.vehicleId}. Call get-vehicles first.`;
|
|
121713
|
+
}
|
|
121714
|
+
}
|
|
121715
|
+
return null;
|
|
121716
|
+
}
|
|
121717
|
+
async function validateInvoice(invoiceId, teamId) {
|
|
121718
|
+
const accessibleTeamIds = await getAccessibleTeamIds(teamId);
|
|
121719
|
+
const [invoice] = await db.select({ id: schema_exports.invoices.id }).from(schema_exports.invoices).where(
|
|
121720
|
+
and(
|
|
121721
|
+
eq(schema_exports.invoices.id, invoiceId),
|
|
121722
|
+
inArray(schema_exports.invoices.teamId, accessibleTeamIds)
|
|
121723
|
+
)
|
|
121724
|
+
).limit(1);
|
|
121725
|
+
return invoice ? null : `Invoice not found or no access: ${invoiceId}. Call get-invoices first.`;
|
|
121726
|
+
}
|
|
121727
|
+
async function handleCreateTrip(input) {
|
|
121728
|
+
const ctx = getAuthContext();
|
|
121729
|
+
if (!input.date) return textResponse7("Error: `date` (YYYY-MM-DD) is required.");
|
|
121730
|
+
if (!input.startLocation || !input.endLocation) {
|
|
121731
|
+
return textResponse7(
|
|
121732
|
+
"Error: `startLocation` and `endLocation` are required."
|
|
121733
|
+
);
|
|
121734
|
+
}
|
|
121735
|
+
if (!input.tripType || !TRIP_TYPES.includes(input.tripType)) {
|
|
121736
|
+
return textResponse7(
|
|
121737
|
+
`Error: \`tripType\` is required and must be one of: ${TRIP_TYPES.join(", ")}.`
|
|
121738
|
+
);
|
|
121739
|
+
}
|
|
121740
|
+
const billingType = input.billingType ?? "not_billable";
|
|
121741
|
+
if (!BILLING_TYPES.includes(billingType)) {
|
|
121742
|
+
return textResponse7(
|
|
121743
|
+
`Error: invalid billingType "${input.billingType}". Allowed: ${BILLING_TYPES.join(", ")}.`
|
|
121744
|
+
);
|
|
121745
|
+
}
|
|
121746
|
+
const resolved = await resolveTeamId(input.teamId);
|
|
121747
|
+
if (!resolved.ok) return resolved.response;
|
|
121748
|
+
const teamId = resolved.teamId;
|
|
121749
|
+
const linkError = await validateLinks(ctx.userId, teamId, {
|
|
121750
|
+
projectId: input.projectId,
|
|
121751
|
+
customerId: input.customerId,
|
|
121752
|
+
vehicleId: input.vehicleId
|
|
121753
|
+
});
|
|
121754
|
+
if (linkError) return textResponse7(`Error: ${linkError}`);
|
|
121755
|
+
if (!input.allowDuplicate) {
|
|
121756
|
+
const dupFilters = [
|
|
121757
|
+
eq(schema_exports.trips.teamId, teamId),
|
|
121758
|
+
eq(schema_exports.trips.userId, ctx.userId),
|
|
121759
|
+
eq(schema_exports.trips.date, input.date),
|
|
121760
|
+
ilike(schema_exports.trips.startLocation, input.startLocation),
|
|
121761
|
+
ilike(schema_exports.trips.endLocation, input.endLocation)
|
|
121762
|
+
];
|
|
121763
|
+
if (input.projectId) {
|
|
121764
|
+
dupFilters.push(eq(schema_exports.trips.projectId, input.projectId));
|
|
121765
|
+
}
|
|
121766
|
+
if (input.customerId) {
|
|
121767
|
+
dupFilters.push(eq(schema_exports.trips.customerId, input.customerId));
|
|
121768
|
+
}
|
|
121769
|
+
const [dup] = await db.select({ id: schema_exports.trips.id, distance: schema_exports.trips.distance }).from(schema_exports.trips).where(and(...dupFilters)).limit(1);
|
|
121770
|
+
if (dup) {
|
|
121771
|
+
return textResponse7(
|
|
121772
|
+
`\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.`
|
|
121773
|
+
);
|
|
121774
|
+
}
|
|
121775
|
+
}
|
|
121776
|
+
const amount = deriveTripAmount({
|
|
121777
|
+
billingType,
|
|
121778
|
+
distance: input.distance ?? null,
|
|
121779
|
+
rate: input.rate ?? null,
|
|
121780
|
+
amount: input.amount ?? null
|
|
121781
|
+
});
|
|
121782
|
+
const [created] = await db.insert(schema_exports.trips).values({
|
|
121783
|
+
teamId,
|
|
121784
|
+
userId: ctx.userId,
|
|
121785
|
+
date: input.date,
|
|
121786
|
+
startLocation: input.startLocation,
|
|
121787
|
+
endLocation: input.endLocation,
|
|
121788
|
+
tripType: input.tripType,
|
|
121789
|
+
distance: input.distance != null ? String(input.distance) : null,
|
|
121790
|
+
odometerStart: input.odometerStart != null ? String(input.odometerStart) : null,
|
|
121791
|
+
odometerEnd: input.odometerEnd != null ? String(input.odometerEnd) : null,
|
|
121792
|
+
projectId: input.projectId ?? null,
|
|
121793
|
+
customerId: input.customerId ?? null,
|
|
121794
|
+
billingType,
|
|
121795
|
+
rate: input.rate ?? null,
|
|
121796
|
+
amount,
|
|
121797
|
+
notes: input.notes ?? null,
|
|
121798
|
+
vehicleId: input.vehicleId ?? null,
|
|
121799
|
+
snapshotId: input.snapshotId ?? null
|
|
121800
|
+
}).returning({ id: schema_exports.trips.id });
|
|
121801
|
+
if (!created) return textResponse7("Failed to create trip.");
|
|
121802
|
+
const trip = await loadTripInTeams(created.id, [teamId]);
|
|
121803
|
+
return {
|
|
121804
|
+
content: [
|
|
121805
|
+
{
|
|
121806
|
+
type: "text",
|
|
121807
|
+
text: `\u2705 **Trip created**
|
|
121808
|
+
|
|
121809
|
+
${JSON.stringify(formatTrip(trip), null, 2)}`
|
|
121810
|
+
}
|
|
121811
|
+
]
|
|
121812
|
+
};
|
|
121813
|
+
}
|
|
121814
|
+
async function handleUpdateTrip(input) {
|
|
121815
|
+
const ctx = getAuthContext();
|
|
121816
|
+
if (!input.id) return textResponse7("Error: `id` is required.");
|
|
121817
|
+
if (input.tripType && !TRIP_TYPES.includes(input.tripType)) {
|
|
121818
|
+
return textResponse7(
|
|
121819
|
+
`Error: invalid tripType "${input.tripType}". Allowed: ${TRIP_TYPES.join(", ")}.`
|
|
121820
|
+
);
|
|
121821
|
+
}
|
|
121822
|
+
if (input.billingType && !BILLING_TYPES.includes(input.billingType)) {
|
|
121823
|
+
return textResponse7(
|
|
121824
|
+
`Error: invalid billingType "${input.billingType}". Allowed: ${BILLING_TYPES.join(", ")}.`
|
|
121825
|
+
);
|
|
121826
|
+
}
|
|
121827
|
+
const resolved = await resolveTeamId(input.teamId);
|
|
121828
|
+
if (!resolved.ok) return resolved.response;
|
|
121829
|
+
const accessibleTeamIds = await getAccessibleTeamIds(resolved.teamId);
|
|
121830
|
+
const existing = await loadTripInTeams(input.id, accessibleTeamIds);
|
|
121831
|
+
if (!existing) {
|
|
121832
|
+
return textResponse7(
|
|
121833
|
+
`Trip ${input.id} not found or you don't have access to it. Call get-trips to find a valid id.`
|
|
121834
|
+
);
|
|
121835
|
+
}
|
|
121836
|
+
const teamId = resolved.teamId;
|
|
121837
|
+
const isLocked = existing.isInvoiced || existing.invoiceId != null;
|
|
121838
|
+
if (isLocked && !input.allowInvoicedOverride) {
|
|
121839
|
+
const attempted = attemptedLockedFields(
|
|
121840
|
+
input
|
|
121841
|
+
);
|
|
121842
|
+
if (attempted.length > 0) {
|
|
121843
|
+
return textResponse7(
|
|
121844
|
+
`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.`
|
|
121845
|
+
);
|
|
121846
|
+
}
|
|
121847
|
+
}
|
|
121848
|
+
const linkError = await validateLinks(ctx.userId, teamId, {
|
|
121849
|
+
projectId: input.projectId ?? void 0,
|
|
121850
|
+
customerId: input.customerId ?? void 0,
|
|
121851
|
+
vehicleId: input.vehicleId ?? void 0
|
|
121852
|
+
});
|
|
121853
|
+
if (linkError) return textResponse7(`Error: ${linkError}`);
|
|
121854
|
+
if (input.invoiceId) {
|
|
121855
|
+
const invoiceError = await validateInvoice(input.invoiceId, teamId);
|
|
121856
|
+
if (invoiceError) return textResponse7(`Error: ${invoiceError}`);
|
|
121857
|
+
}
|
|
121858
|
+
const updates = {};
|
|
121859
|
+
if (input.date !== void 0) updates.date = input.date;
|
|
121860
|
+
if (input.startLocation !== void 0) {
|
|
121861
|
+
updates.startLocation = input.startLocation;
|
|
121862
|
+
}
|
|
121863
|
+
if (input.endLocation !== void 0) updates.endLocation = input.endLocation;
|
|
121864
|
+
if (input.tripType !== void 0) updates.tripType = input.tripType;
|
|
121865
|
+
if (input.distance !== void 0) {
|
|
121866
|
+
updates.distance = input.distance != null ? String(input.distance) : null;
|
|
121867
|
+
}
|
|
121868
|
+
if (input.odometerStart !== void 0) {
|
|
121869
|
+
updates.odometerStart = input.odometerStart != null ? String(input.odometerStart) : null;
|
|
121870
|
+
}
|
|
121871
|
+
if (input.odometerEnd !== void 0) {
|
|
121872
|
+
updates.odometerEnd = input.odometerEnd != null ? String(input.odometerEnd) : null;
|
|
121873
|
+
}
|
|
121874
|
+
if (input.projectId !== void 0) updates.projectId = input.projectId;
|
|
121875
|
+
if (input.customerId !== void 0) updates.customerId = input.customerId;
|
|
121876
|
+
if (input.vehicleId !== void 0) updates.vehicleId = input.vehicleId;
|
|
121877
|
+
if (input.notes !== void 0) updates.notes = input.notes;
|
|
121878
|
+
if (input.billingType !== void 0) updates.billingType = input.billingType;
|
|
121879
|
+
if (input.rate !== void 0) updates.rate = input.rate;
|
|
121880
|
+
if (input.amount !== void 0) updates.amount = input.amount;
|
|
121881
|
+
if (input.linkedTripId !== void 0) {
|
|
121882
|
+
updates.linkedTripId = input.linkedTripId;
|
|
121883
|
+
}
|
|
121884
|
+
if (input.invoiceId !== void 0) {
|
|
121885
|
+
updates.invoiceId = input.invoiceId;
|
|
121886
|
+
if (input.isInvoiced === void 0) {
|
|
121887
|
+
updates.isInvoiced = input.invoiceId != null;
|
|
121888
|
+
}
|
|
121889
|
+
}
|
|
121890
|
+
if (input.isInvoiced !== void 0) updates.isInvoiced = input.isInvoiced;
|
|
121891
|
+
if (input.amount === void 0 && (input.distance !== void 0 || input.rate !== void 0 || input.billingType !== void 0)) {
|
|
121892
|
+
const nextBilling = input.billingType ?? existing.billingType;
|
|
121893
|
+
const nextDistance = input.distance !== void 0 ? input.distance : existing.distance != null ? toNumber2(existing.distance) : null;
|
|
121894
|
+
const nextRate = input.rate !== void 0 ? input.rate : existing.rate;
|
|
121895
|
+
const derived = deriveTripAmount({
|
|
121896
|
+
billingType: nextBilling,
|
|
121897
|
+
distance: nextDistance,
|
|
121898
|
+
rate: nextRate,
|
|
121899
|
+
amount: null
|
|
121900
|
+
});
|
|
121901
|
+
if (derived != null) updates.amount = derived;
|
|
121902
|
+
}
|
|
121903
|
+
if (Object.keys(updates).length === 0) {
|
|
121904
|
+
return textResponse7(
|
|
121905
|
+
"No fields to update. Provide at least one editable field."
|
|
121906
|
+
);
|
|
121907
|
+
}
|
|
121908
|
+
updates.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
121909
|
+
await db.update(schema_exports.trips).set(updates).where(
|
|
121910
|
+
and(
|
|
121911
|
+
eq(schema_exports.trips.id, existing.id),
|
|
121912
|
+
inArray(schema_exports.trips.teamId, accessibleTeamIds)
|
|
121913
|
+
)
|
|
121914
|
+
);
|
|
121915
|
+
const updated = await loadTripInTeams(existing.id, accessibleTeamIds);
|
|
121916
|
+
return {
|
|
121917
|
+
content: [
|
|
121918
|
+
{
|
|
121919
|
+
type: "text",
|
|
121920
|
+
text: `\u2705 **Trip updated**
|
|
121921
|
+
|
|
121922
|
+
${JSON.stringify(formatTrip(updated), null, 2)}`
|
|
121923
|
+
}
|
|
121924
|
+
]
|
|
121925
|
+
};
|
|
121926
|
+
}
|
|
121927
|
+
async function handleGetVehicles(input) {
|
|
121928
|
+
const scope = await resolveTeamScope(input.teamId);
|
|
121929
|
+
if (!scope.ok) return scope.response;
|
|
121930
|
+
if (scope.teamIds.length === 0) {
|
|
121931
|
+
return textResponse7("No accessible teams found.");
|
|
121932
|
+
}
|
|
121933
|
+
const filters = [inArray(schema_exports.vehicles.teamId, scope.teamIds)];
|
|
121934
|
+
if (input.q) filters.push(ilike(schema_exports.vehicles.name, `%${input.q}%`));
|
|
121935
|
+
const rows = await db.select({
|
|
121936
|
+
id: schema_exports.vehicles.id,
|
|
121937
|
+
name: schema_exports.vehicles.name,
|
|
121938
|
+
licensePlate: schema_exports.vehicles.licensePlate,
|
|
121939
|
+
currentOdometer: schema_exports.vehicles.currentOdometer,
|
|
121940
|
+
teamId: schema_exports.vehicles.teamId
|
|
121941
|
+
}).from(schema_exports.vehicles).where(and(...filters)).orderBy(asc(schema_exports.vehicles.name)).limit(Math.min(input.pageSize ?? 50, 200));
|
|
121942
|
+
return jsonResponse({
|
|
121943
|
+
count: rows.length,
|
|
121944
|
+
vehicles: rows.map((v2) => ({
|
|
121945
|
+
id: v2.id,
|
|
121946
|
+
name: v2.name,
|
|
121947
|
+
licensePlate: v2.licensePlate,
|
|
121948
|
+
currentOdometer: v2.currentOdometer != null ? toNumber2(v2.currentOdometer) : null
|
|
121949
|
+
}))
|
|
121950
|
+
});
|
|
121951
|
+
}
|
|
121952
|
+
async function handleGetTripTemplates(input) {
|
|
121953
|
+
const ctx = getAuthContext();
|
|
121954
|
+
const scope = await resolveTeamScope(input.teamId);
|
|
121955
|
+
if (!scope.ok) return scope.response;
|
|
121956
|
+
if (scope.teamIds.length === 0) {
|
|
121957
|
+
return textResponse7("No accessible teams found.");
|
|
121958
|
+
}
|
|
121959
|
+
const filters = [inArray(schema_exports.tripTemplates.teamId, scope.teamIds)];
|
|
121960
|
+
const userId = input.userId ?? ctx.userId;
|
|
121961
|
+
if (userId !== "all") {
|
|
121962
|
+
filters.push(eq(schema_exports.tripTemplates.userId, userId));
|
|
121963
|
+
}
|
|
121964
|
+
const rows = await db.select({
|
|
121965
|
+
id: schema_exports.tripTemplates.id,
|
|
121966
|
+
name: schema_exports.tripTemplates.name,
|
|
121967
|
+
startLocation: schema_exports.tripTemplates.startLocation,
|
|
121968
|
+
endLocation: schema_exports.tripTemplates.endLocation,
|
|
121969
|
+
distance: schema_exports.tripTemplates.distance,
|
|
121970
|
+
withReturn: schema_exports.tripTemplates.withReturn,
|
|
121971
|
+
returnDistance: schema_exports.tripTemplates.returnDistance,
|
|
121972
|
+
tripType: schema_exports.tripTemplates.tripType,
|
|
121973
|
+
billingType: schema_exports.tripTemplates.billingType,
|
|
121974
|
+
rate: schema_exports.tripTemplates.rate,
|
|
121975
|
+
amount: schema_exports.tripTemplates.amount,
|
|
121976
|
+
projectId: schema_exports.tripTemplates.projectId,
|
|
121977
|
+
customerId: schema_exports.tripTemplates.customerId,
|
|
121978
|
+
vehicleId: schema_exports.tripTemplates.vehicleId,
|
|
121979
|
+
notes: schema_exports.tripTemplates.notes
|
|
121980
|
+
}).from(schema_exports.tripTemplates).where(and(...filters)).orderBy(asc(schema_exports.tripTemplates.name)).limit(Math.min(input.pageSize ?? 50, 200));
|
|
121981
|
+
return jsonResponse({
|
|
121982
|
+
count: rows.length,
|
|
121983
|
+
templates: rows.map((t8) => ({
|
|
121984
|
+
...t8,
|
|
121985
|
+
distance: t8.distance != null ? toNumber2(t8.distance) : null,
|
|
121986
|
+
returnDistance: t8.returnDistance != null ? toNumber2(t8.returnDistance) : null
|
|
121987
|
+
}))
|
|
121988
|
+
});
|
|
121989
|
+
}
|
|
121990
|
+
async function handleGetFrequentTripsForProject(input) {
|
|
121991
|
+
const ctx = getAuthContext();
|
|
121992
|
+
if (!input.projectId) {
|
|
121993
|
+
return textResponse7("Error: `projectId` is required.");
|
|
121994
|
+
}
|
|
121995
|
+
const resolved = await resolveTeamId(input.teamId);
|
|
121996
|
+
if (!resolved.ok) return resolved.response;
|
|
121997
|
+
const teamId = resolved.teamId;
|
|
121998
|
+
const projectIds = await getAccessibleProjectIds(ctx.userId, teamId);
|
|
121999
|
+
if (!projectIds.includes(input.projectId)) {
|
|
122000
|
+
return textResponse7(
|
|
122001
|
+
`Project not found or no access: ${input.projectId}. Call get-projects first.`
|
|
122002
|
+
);
|
|
122003
|
+
}
|
|
122004
|
+
const userId = input.userId ?? ctx.userId;
|
|
122005
|
+
const daysBack = input.daysBack ?? 60;
|
|
122006
|
+
const limitN = Math.min(input.limit ?? 5, 25);
|
|
122007
|
+
const fromDate = new Date(Date.now() - daysBack * 24 * 60 * 60 * 1e3).toISOString().slice(0, 10);
|
|
122008
|
+
const groups = await db.select({
|
|
122009
|
+
startLocation: schema_exports.trips.startLocation,
|
|
122010
|
+
endLocation: schema_exports.trips.endLocation,
|
|
122011
|
+
tripType: schema_exports.trips.tripType,
|
|
122012
|
+
count: sql`count(*)::int`,
|
|
122013
|
+
avgDistance: sql`avg(${schema_exports.trips.distance})::text`,
|
|
122014
|
+
lastUsedDate: sql`max(${schema_exports.trips.date})`
|
|
122015
|
+
}).from(schema_exports.trips).where(
|
|
122016
|
+
and(
|
|
122017
|
+
eq(schema_exports.trips.teamId, teamId),
|
|
122018
|
+
eq(schema_exports.trips.userId, userId),
|
|
122019
|
+
eq(schema_exports.trips.projectId, input.projectId),
|
|
122020
|
+
gte(schema_exports.trips.date, fromDate)
|
|
122021
|
+
)
|
|
122022
|
+
).groupBy(
|
|
122023
|
+
schema_exports.trips.startLocation,
|
|
122024
|
+
schema_exports.trips.endLocation,
|
|
122025
|
+
schema_exports.trips.tripType
|
|
122026
|
+
).orderBy(desc(sql`count(*)`), desc(sql`max(${schema_exports.trips.date})`)).limit(limitN);
|
|
122027
|
+
return jsonResponse({
|
|
122028
|
+
count: groups.length,
|
|
122029
|
+
daysBack,
|
|
122030
|
+
frequentTrips: groups.map((g6) => ({
|
|
122031
|
+
startLocation: g6.startLocation,
|
|
122032
|
+
endLocation: g6.endLocation,
|
|
122033
|
+
tripType: g6.tripType,
|
|
122034
|
+
count: g6.count,
|
|
122035
|
+
avgDistance: g6.avgDistance != null ? round22(toNumber2(g6.avgDistance)) : null,
|
|
122036
|
+
lastUsedDate: g6.lastUsedDate
|
|
122037
|
+
}))
|
|
122038
|
+
});
|
|
122039
|
+
}
|
|
122040
|
+
|
|
120297
122041
|
// src/tools/tickets.ts
|
|
120298
122042
|
function isImageFile(mimeType) {
|
|
120299
122043
|
return mimeType.startsWith("image/") && ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"].includes(
|
|
@@ -120814,6 +122558,18 @@ function createMcpServer() {
|
|
|
120814
122558
|
return await handleGetCustomers(asToolArgs(toolArgs));
|
|
120815
122559
|
case "create-customer":
|
|
120816
122560
|
return await handleCreateCustomer(asToolArgs(toolArgs));
|
|
122561
|
+
case "update-customer":
|
|
122562
|
+
return await handleUpdateCustomer(
|
|
122563
|
+
asToolArgs(toolArgs)
|
|
122564
|
+
);
|
|
122565
|
+
case "archive-customer":
|
|
122566
|
+
return await handleArchiveCustomer(
|
|
122567
|
+
asToolArgs(toolArgs)
|
|
122568
|
+
);
|
|
122569
|
+
case "delete-customer":
|
|
122570
|
+
return await handleDeleteCustomer(
|
|
122571
|
+
asToolArgs(toolArgs)
|
|
122572
|
+
);
|
|
120817
122573
|
case "get-projects":
|
|
120818
122574
|
return await handleGetProjects(asToolArgs(toolArgs));
|
|
120819
122575
|
case "create-project":
|
|
@@ -120884,6 +122640,32 @@ function createMcpServer() {
|
|
|
120884
122640
|
return await handleArchiveProduct(
|
|
120885
122641
|
asToolArgs(toolArgs)
|
|
120886
122642
|
);
|
|
122643
|
+
case "get-trips":
|
|
122644
|
+
return await handleGetTrips(asToolArgs(toolArgs));
|
|
122645
|
+
case "create-trip":
|
|
122646
|
+
return await handleCreateTrip(asToolArgs(toolArgs));
|
|
122647
|
+
case "update-trip":
|
|
122648
|
+
return await handleUpdateTrip(asToolArgs(toolArgs));
|
|
122649
|
+
case "get-vehicles":
|
|
122650
|
+
return await handleGetVehicles(asToolArgs(toolArgs));
|
|
122651
|
+
case "get-trip-templates":
|
|
122652
|
+
return await handleGetTripTemplates(
|
|
122653
|
+
asToolArgs(toolArgs)
|
|
122654
|
+
);
|
|
122655
|
+
case "get-frequent-trips-for-project":
|
|
122656
|
+
return await handleGetFrequentTripsForProject(
|
|
122657
|
+
asToolArgs(toolArgs)
|
|
122658
|
+
);
|
|
122659
|
+
case "get-quotes":
|
|
122660
|
+
return await handleGetQuotes(asToolArgs(toolArgs));
|
|
122661
|
+
case "create-quote":
|
|
122662
|
+
return await handleCreateQuote(asToolArgs(toolArgs));
|
|
122663
|
+
case "update-quote":
|
|
122664
|
+
return await handleUpdateQuote(asToolArgs(toolArgs));
|
|
122665
|
+
case "add-product-to-quote":
|
|
122666
|
+
return await handleAddProductToQuote(
|
|
122667
|
+
asToolArgs(toolArgs)
|
|
122668
|
+
);
|
|
120887
122669
|
case "log-hours":
|
|
120888
122670
|
return await handleLogHours(asToolArgs(toolArgs));
|
|
120889
122671
|
case "get-github-file":
|