@mgsoftwarebv/mcp-server-bridge 3.5.0 → 3.5.2

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 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
- content: [
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
- (c6) => `**${c6.name}**
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
- ).join("\n") || "No customers found."}`
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
- content: [
107320
- {
107321
- type: "text",
107322
- text: `\u2705 **Customer Created Successfully!**
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: ${name21}
107325
- ${email5 ? `Email: ${email5}
107326
- ` : ""}${website ? `Website: ${website}
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 DEPENDENCY_LABELS = {
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(DEPENDENCY_LABELS).filter((key) => counts[key] > 0).map((key) => `${counts[key]} ${DEPENDENCY_LABELS[key]}`);
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 textResponse(text3) {
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 requireTeamOwner(teamId, userId) {
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 : textResponse(OWNER_REQUIRED);
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: textResponse(
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: textResponse(
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: textResponse(
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: textResponse(
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 textResponse(
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 textResponse(
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 textResponse(`Failed to update project ${id}.`);
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 textResponse(lines.join("\n"));
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 textResponse(
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 textResponse(
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 requireTeamOwner(resolved.teamId, ctx.userId);
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 textResponse(
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 textResponse(
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 requireTeamOwner(resolved.teamId, ctx.userId);
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 textResponse(
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 textResponse(
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 textResponse(text3);
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 requireTeamOwner(resolved.teamId, ctx.userId);
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 textResponse(
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 textResponse(
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 textResponse(text3);
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 textResponse("Error: `projectId` is required.");
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 textResponse(
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 textResponse(
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 textResponse(
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 textResponse("Error: `projectId` is required.");
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 requireTeamOwner(resolved.teamId, ctx.userId);
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 textResponse(
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 textResponse(
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 textResponse(
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 textResponse(
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 textResponse2(text3) {
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 textResponse2(
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 textResponse2("No accessible teams found.");
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 textResponse2(
114157
+ return textResponse3(
113443
114158
  `No products found${status !== "all" ? ` (status: ${status})` : ""}.`
113444
114159
  );
113445
114160
  }
113446
- return textResponse2(
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 textResponse2("Error: `productId` is required.");
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 textResponse2("No accessible teams found.");
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 textResponse2(
114182
+ return textResponse3(
113468
114183
  `Product ${productId} not found or you don't have access to it.`
113469
114184
  );
113470
114185
  }
113471
- return textResponse2(formatProduct(row));
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 textResponse2("Error: `name` is required.");
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 textResponse2("Failed to create product.");
113501
- return textResponse2(
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 textResponse2("Error: `productId` is required.");
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 existing = await loadProductInTeam(productId, resolved.teamId);
113513
- if (!existing) {
113514
- return textResponse2(
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.name !== void 0) {
113520
- if (!input.name || input.name.trim().length === 0) {
113521
- return textResponse2("Error: `name` cannot be empty.");
113522
- }
113523
- updates.name = input.name.trim();
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 textResponse2(
113532
- "No fields to update. Provide at least one of: name, description, price, currency, unit, isActive."
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.invoiceProducts).set(updates).where(eq(schema_exports.invoiceProducts.id, existing.id)).returning(PRODUCT_COLUMNS);
113537
- if (!updated) return textResponse2(`Failed to update product ${productId}.`);
113538
- return textResponse2(
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
- ${formatProduct(updated)}
113542
- Note: this only affects future invoices/quotes. Existing documents keep their line-item snapshots.`
113543
- );
114734
+ ${formatQuote(updated)}`);
113544
114735
  }
113545
- async function handleArchiveProduct(input) {
113546
- const { productId, reason } = input;
113547
- if (!productId) return textResponse2("Error: `productId` is required.");
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 existing = await loadProductInTeam(productId, resolved.teamId);
113551
- if (!existing) {
113552
- return textResponse2(
113553
- `Product ${productId} not found, or it is not owned by this team.`
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
- if (!existing.isActive) {
113557
- return textResponse2(
113558
- `Product "${existing.name}" (${existing.id}) is already archived.`
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
- 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);
113562
- if (!archived) return textResponse2(`Failed to archive product ${productId}.`);
113563
- return textResponse2(
113564
- `\u2705 **Product archived** (hidden from new invoices/quotes; existing documents are untouched).
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
- ${formatProduct(archived)}${reason ? `Reason: ${reason}
113567
- ` : ""}Reactivate it with update-product (isActive: true).`
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 textResponse3(text3) {
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 textResponse3(
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 textResponse3(
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 textResponse3(
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 textResponse3(
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 textResponse3("The file is empty (0 bytes); nothing to upload.");
120424
+ return textResponse5("The file is empty (0 bytes); nothing to upload.");
119202
120425
  }
119203
120426
  if (buffer2.byteLength > MAX_FILE_SIZE) {
119204
- return textResponse3(
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 textResponse3(
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 textResponse3(
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 textResponse3(
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 textResponse4(text3) {
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 textResponse4("Error: `tagId` is required.");
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 textResponse4(
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 textResponse4(
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 textResponse4("Error: `name` cannot be empty.");
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 textResponse4(
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 textResponse4(`Failed to update tag ${input.tagId}.`);
119750
- return textResponse4(
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 textResponse4("Error: `tagId` is required.");
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 textResponse4(
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 textResponse4(
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 textResponse4(
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 textResponse4(
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: textResponse4(
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: textResponse4(
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: textResponse4("Failed to create target tag.") };
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 textResponse4("Error: `sourceTagIds` must contain at least one tag id.");
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 textResponse4(
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 textResponse4(
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 textResponse4(
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(
@@ -120471,13 +122215,13 @@ async function handleGetTicketById(input) {
120471
122215
  }).from(schema_exports.ticketAttachments).leftJoin(
120472
122216
  schema_exports.users,
120473
122217
  eq(schema_exports.users.id, schema_exports.ticketAttachments.userId)
120474
- ).where(eq(schema_exports.ticketAttachments.ticketId, id)).orderBy(asc(schema_exports.ticketAttachments.createdAt));
122218
+ ).where(eq(schema_exports.ticketAttachments.ticketId, resolved.id)).orderBy(asc(schema_exports.ticketAttachments.createdAt));
120475
122219
  const comments = await db.select({
120476
122220
  id: schema_exports.ticketComments.id,
120477
122221
  content: schema_exports.ticketComments.content,
120478
122222
  createdAt: schema_exports.ticketComments.createdAt,
120479
122223
  userId: schema_exports.ticketComments.userId
120480
- }).from(schema_exports.ticketComments).where(eq(schema_exports.ticketComments.ticketId, id)).orderBy(asc(schema_exports.ticketComments.createdAt));
122224
+ }).from(schema_exports.ticketComments).where(eq(schema_exports.ticketComments.ticketId, resolved.id)).orderBy(asc(schema_exports.ticketComments.createdAt));
120481
122225
  const commentUserIds = [
120482
122226
  ...new Set(
120483
122227
  comments.map((c6) => c6.userId).filter((v2) => Boolean(v2))
@@ -120540,8 +122284,8 @@ ${text3.split("\n").map((l4) => ` ${l4}`).join("\n")}`;
120540
122284
  ` : ticketRow.requester ? `Assignee: (unassigned) \u2014 use requester id ${ticketRow.requester.id} for review handoff
120541
122285
  ` : `Assignee: (unassigned)
120542
122286
  `;
120543
- const ticketTagRows = await getTagsForTickets([id]);
120544
- const ticketTags2 = ticketTagRows.get(id) ?? [];
122287
+ const ticketTagRows = await getTagsForTickets([resolved.id]);
122288
+ const ticketTags2 = ticketTagRows.get(resolved.id) ?? [];
120545
122289
  const tagsLine = ticketTags2.length > 0 ? `Tags: ${formatTagList(ticketTags2)}
120546
122290
  ` : `Tags: (none)
120547
122291
  `;
@@ -120728,7 +122472,7 @@ ${tagErrors.map((e6) => ` \u2022 ${e6}`).join("\n")}
120728
122472
  }
120729
122473
 
120730
122474
  // src/server.ts
120731
- var SERVER_VERSION = "3.5.0";
122475
+ var SERVER_VERSION = "3.5.1";
120732
122476
  function createMcpServer() {
120733
122477
  const server = new Server(
120734
122478
  {
@@ -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":