@mgsoftwarebv/mcp-server-bridge 3.4.1 → 3.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -6170,20 +6170,20 @@ var require_resolve = __commonJS({
6170
6170
  return false;
6171
6171
  }
6172
6172
  function countKeys(schema) {
6173
- let count = 0;
6173
+ let count2 = 0;
6174
6174
  for (const key in schema) {
6175
6175
  if (key === "$ref")
6176
6176
  return Infinity;
6177
- count++;
6177
+ count2++;
6178
6178
  if (SIMPLE_INLINED.has(key))
6179
6179
  continue;
6180
6180
  if (typeof schema[key] == "object") {
6181
- (0, util_1.eachItem)(schema[key], (sch) => count += countKeys(sch));
6181
+ (0, util_1.eachItem)(schema[key], (sch) => count2 += countKeys(sch));
6182
6182
  }
6183
- if (count === Infinity)
6183
+ if (count2 === Infinity)
6184
6184
  return Infinity;
6185
6185
  }
6186
- return count;
6186
+ return count2;
6187
6187
  }
6188
6188
  function getFullPath(resolver, id = "", normalize2) {
6189
6189
  if (normalize2 !== false)
@@ -9228,8 +9228,8 @@ var require_contains = __commonJS({
9228
9228
  cxt.result(valid, () => cxt.reset());
9229
9229
  function validateItemsWithCount() {
9230
9230
  const schValid = gen.name("_valid");
9231
- const count = gen.let("count", 0);
9232
- validateItems(schValid, () => gen.if(schValid, () => checkLimits(count)));
9231
+ const count2 = gen.let("count", 0);
9232
+ validateItems(schValid, () => gen.if(schValid, () => checkLimits(count2)));
9233
9233
  }
9234
9234
  function validateItems(_valid, block) {
9235
9235
  gen.forRange("i", 0, len, (i6) => {
@@ -9242,16 +9242,16 @@ var require_contains = __commonJS({
9242
9242
  block();
9243
9243
  });
9244
9244
  }
9245
- function checkLimits(count) {
9246
- gen.code((0, codegen_1._)`${count}++`);
9245
+ function checkLimits(count2) {
9246
+ gen.code((0, codegen_1._)`${count2}++`);
9247
9247
  if (max === void 0) {
9248
- gen.if((0, codegen_1._)`${count} >= ${min}`, () => gen.assign(valid, true).break());
9248
+ gen.if((0, codegen_1._)`${count2} >= ${min}`, () => gen.assign(valid, true).break());
9249
9249
  } else {
9250
- gen.if((0, codegen_1._)`${count} > ${max}`, () => gen.assign(valid, false).break());
9250
+ gen.if((0, codegen_1._)`${count2} > ${max}`, () => gen.assign(valid, false).break());
9251
9251
  if (min === 1)
9252
9252
  gen.assign(valid, true);
9253
9253
  else
9254
- gen.if((0, codegen_1._)`${count} >= ${min}`, () => gen.assign(valid, true));
9254
+ gen.if((0, codegen_1._)`${count2} >= ${min}`, () => gen.assign(valid, true));
9255
9255
  }
9256
9256
  }
9257
9257
  }
@@ -15018,8 +15018,8 @@ var init_az = __esm({
15018
15018
  });
15019
15019
 
15020
15020
  // ../../node_modules/zod/v4/locales/be.js
15021
- function getBelarusianPlural(count, one, few, many) {
15022
- const absCount = Math.abs(count);
15021
+ function getBelarusianPlural(count2, one, few, many) {
15022
+ const absCount = Math.abs(count2);
15023
15023
  const lastDigit = absCount % 10;
15024
15024
  const lastTwoDigits = absCount % 100;
15025
15025
  if (lastTwoDigits >= 11 && lastTwoDigits <= 19) {
@@ -16933,8 +16933,8 @@ var init_hu = __esm({
16933
16933
  });
16934
16934
 
16935
16935
  // ../../node_modules/zod/v4/locales/hy.js
16936
- function getArmenianPlural(count, one, many) {
16937
- return Math.abs(count) === 1 ? one : many;
16936
+ function getArmenianPlural(count2, one, many) {
16937
+ return Math.abs(count2) === 1 ? one : many;
16938
16938
  }
16939
16939
  function withDefiniteArticle(word) {
16940
16940
  if (!word)
@@ -19049,8 +19049,8 @@ var init_pt = __esm({
19049
19049
  });
19050
19050
 
19051
19051
  // ../../node_modules/zod/v4/locales/ru.js
19052
- function getRussianPlural(count, one, few, many) {
19053
- const absCount = Math.abs(count);
19052
+ function getRussianPlural(count2, one, few, many) {
19053
+ const absCount = Math.abs(count2);
19054
19054
  const lastDigit = absCount % 10;
19055
19055
  const lastTwoDigits = absCount % 100;
19056
19056
  if (lastTwoDigits >= 11 && lastTwoDigits <= 19) {
@@ -59475,9 +59475,9 @@ var init_DefaultRetryToken = __esm({
59475
59475
  count;
59476
59476
  cost;
59477
59477
  longPoll;
59478
- constructor(delay2, count, cost, longPoll) {
59478
+ constructor(delay2, count2, cost, longPoll) {
59479
59479
  this.delay = delay2;
59480
- this.count = count;
59480
+ this.count = count2;
59481
59481
  this.cost = cost;
59482
59482
  this.longPoll = longPoll;
59483
59483
  }
@@ -62418,9 +62418,9 @@ function validateAmpersand(xmlData, i6) {
62418
62418
  i6++;
62419
62419
  return validateNumberAmpersand(xmlData, i6);
62420
62420
  }
62421
- let count = 0;
62422
- for (; i6 < xmlData.length; i6++, count++) {
62423
- if (xmlData[i6].match(/\w/) && count < 20)
62421
+ let count2 = 0;
62422
+ for (; i6 < xmlData.length; i6++, count2++) {
62423
+ if (xmlData[i6].match(/\w/) && count2 < 20)
62424
62424
  continue;
62425
62425
  if (xmlData[i6] === ";")
62426
62426
  break;
@@ -65059,8 +65059,8 @@ var init_Matcher = __esm({
65059
65059
  const siblingKey = namespace ? `${namespace}:${tagName}` : tagName;
65060
65060
  const counter = siblings.get(siblingKey) || 0;
65061
65061
  let position = 0;
65062
- for (const count of siblings.values()) {
65063
- position += count;
65062
+ for (const count2 of siblings.values()) {
65063
+ position += count2;
65064
65064
  }
65065
65065
  siblings.set(siblingKey, counter + 1);
65066
65066
  const node = {
@@ -86413,6 +86413,11 @@ function mapRelationalRow(tablesConfig, tableConfig, row, buildQueryResultSelect
86413
86413
  return result;
86414
86414
  }
86415
86415
 
86416
+ // ../../node_modules/drizzle-orm/sql/functions/aggregate.js
86417
+ function count(expression) {
86418
+ return sql`count(${sql.raw("*")})`.mapWith(Number);
86419
+ }
86420
+
86416
86421
  // ../../node_modules/postgres/src/query.js
86417
86422
  var originCache = /* @__PURE__ */ new Map();
86418
86423
  var originStackCache = /* @__PURE__ */ new Map();
@@ -105727,6 +105732,70 @@ var TOOLS = [
105727
105732
  required: ["name"]
105728
105733
  }
105729
105734
  },
105735
+ {
105736
+ name: "update-tag",
105737
+ description: "Rename a team tag and/or change its scope. Provide `name` to rename (a case-insensitive name collision in the same scope is rejected \u2014 use merge-tags instead), and/or `projectId` to re-scope (a project UUID, or null for a general team tag). Existing ticket/customer/project/transaction tag relations are preserved. Find tag ids via get-tags.",
105738
+ inputSchema: {
105739
+ type: "object",
105740
+ properties: {
105741
+ teamId: teamIdProp,
105742
+ tagId: { type: "string", description: "Tag ID (UUID) to update" },
105743
+ name: { type: "string", description: "New tag name" },
105744
+ projectId: {
105745
+ type: ["string", "null"],
105746
+ description: "Project UUID to make it project-specific, or null for a general team tag"
105747
+ }
105748
+ },
105749
+ required: ["tagId"]
105750
+ }
105751
+ },
105752
+ {
105753
+ name: "delete-tag",
105754
+ description: "Delete a team tag SAFELY. With mode 'delete_if_unused' (default) the tag is hard-deleted only when it is not used by any ticket/customer/project/transaction; if it is still used, the call is refused and usage counts are returned (it never strips the tag off entities). Mode 'archive' is not supported (the tags table has no archived column) \u2014 use merge-tags to fold a used tag into another, then delete the empty tag.",
105755
+ inputSchema: {
105756
+ type: "object",
105757
+ properties: {
105758
+ teamId: teamIdProp,
105759
+ tagId: { type: "string", description: "Tag ID (UUID) to delete" },
105760
+ mode: {
105761
+ type: "string",
105762
+ enum: ["delete_if_unused", "archive"],
105763
+ default: "delete_if_unused",
105764
+ description: "delete_if_unused = hard-delete only when unused (default); archive = unsupported, returns guidance"
105765
+ }
105766
+ },
105767
+ required: ["tagId"]
105768
+ }
105769
+ },
105770
+ {
105771
+ name: "merge-tags",
105772
+ description: "Merge one or more duplicate/misspelled source tags into a single target tag. Specify the target by `targetTagId` (existing) or `targetName` (reused if it exists, otherwise created as a general team tag). All ticket/customer/project/transaction relations are moved onto the target without creating duplicates (entities that already have the target keep a single tag). By default the empty source tags are deleted afterwards (set deleteSources=false to keep them). Returns moved/skipped counts per entity type. Find tag ids via get-tags.",
105773
+ inputSchema: {
105774
+ type: "object",
105775
+ properties: {
105776
+ teamId: teamIdProp,
105777
+ sourceTagIds: {
105778
+ type: "array",
105779
+ items: { type: "string" },
105780
+ description: "Tag IDs to merge away (at least one)"
105781
+ },
105782
+ targetTagId: {
105783
+ type: "string",
105784
+ description: "Existing target tag ID (takes precedence over targetName)"
105785
+ },
105786
+ targetName: {
105787
+ type: "string",
105788
+ description: "Target tag name; an existing match is reused, otherwise a general tag is created"
105789
+ },
105790
+ deleteSources: {
105791
+ type: "boolean",
105792
+ default: true,
105793
+ description: "Delete the now-empty source tags after merging"
105794
+ }
105795
+ },
105796
+ required: ["sourceTagIds"]
105797
+ }
105798
+ },
105730
105799
  {
105731
105800
  name: "get-calendar-items",
105732
105801
  description: "List agenda/calendar items (deadlines, meetings, reminders, deliveries) with optional filters by date range, project, ticket, customer, assignee, type, or status. Returns items with id, title, startsAt, endsAt, dueDate, linked ticket/project/customer ids, and status.",
@@ -105905,7 +105974,7 @@ var TOOLS = [
105905
105974
  },
105906
105975
  {
105907
105976
  name: "get-customers",
105908
- 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.",
105909
105978
  inputSchema: {
105910
105979
  type: "object",
105911
105980
  properties: {
@@ -105914,6 +105983,12 @@ var TOOLS = [
105914
105983
  type: "string",
105915
105984
  description: "Search query for customer name or email"
105916
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
+ },
105917
105992
  pageSize: { type: "number", default: 20, maximum: 100 }
105918
105993
  },
105919
105994
  required: []
@@ -105921,7 +105996,7 @@ var TOOLS = [
105921
105996
  },
105922
105997
  {
105923
105998
  name: "create-customer",
105924
- description: "Create a new customer",
105999
+ description: "Create a new customer. Returns the created customer including its ID (UUID).",
105925
106000
  inputSchema: {
105926
106001
  type: "object",
105927
106002
  properties: {
@@ -105933,15 +106008,109 @@ var TOOLS = [
105933
106008
  required: ["name"]
105934
106009
  }
105935
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
+ },
105936
106099
  {
105937
106100
  name: "get-projects",
105938
- description: "Get projects with optional filtering",
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.",
105939
106102
  inputSchema: {
105940
106103
  type: "object",
105941
106104
  properties: {
105942
106105
  teamId: teamIdProp,
105943
106106
  customerId: { type: "string", description: "Filter by customer ID" },
105944
106107
  q: { type: "string", description: "Search query for project name" },
106108
+ status: {
106109
+ type: "string",
106110
+ enum: ["active", "archived", "all"],
106111
+ default: "active",
106112
+ description: "Archive filter: 'active' (default, hides archived), 'archived', or 'all'."
106113
+ },
105945
106114
  pageSize: { type: "number", default: 20, maximum: 100 }
105946
106115
  },
105947
106116
  required: []
@@ -105968,7 +106137,7 @@ var TOOLS = [
105968
106137
  },
105969
106138
  {
105970
106139
  name: "update-project",
105971
- description: "Update an existing project's fields (name, description, customer, rate, currency, billable, estimate, internal). Only provided fields change. Renaming a project renumbers its tickets. There is no project 'status' field. Find the project id via get-projects.",
106140
+ description: "Update an existing project's fields (name, description, customer, rate, currency, billable, estimate, internal). Only provided fields change. Renaming a project renumbers its tickets. To retire a mistakenly-created project use archive-project (reversible) or delete-project (empty projects only). Find the project id via get-projects.",
105972
106141
  inputSchema: {
105973
106142
  type: "object",
105974
106143
  properties: {
@@ -105995,6 +106164,38 @@ var TOOLS = [
105995
106164
  required: ["id"]
105996
106165
  }
105997
106166
  },
106167
+ {
106168
+ name: "archive-project",
106169
+ description: "Safely archive (soft-retire) a project \u2014 the recommended way to clean up a mistakenly-created project. Reversible and non-destructive: it keeps all tickets, hours, trips, documents and other data, and only hides the project from get-projects by default. Use this instead of delete-project whenever a project has any history. Find the project id via get-projects. Note: the archive flag is stored in projects.settings.archivedAt; the dashboard UI does not yet read it, so the project still appears there.",
106170
+ inputSchema: {
106171
+ type: "object",
106172
+ properties: {
106173
+ teamId: teamIdProp,
106174
+ projectId: { type: "string", description: "Project ID to archive" },
106175
+ reason: {
106176
+ type: "string",
106177
+ description: "Optional note explaining why the project is archived"
106178
+ }
106179
+ },
106180
+ required: ["projectId"]
106181
+ }
106182
+ },
106183
+ {
106184
+ name: "delete-project",
106185
+ description: "Permanently hard-delete a project, but ONLY when it is empty (no tickets, agenda/time entries, timesheet templates, trips, or trip templates). If any such dependencies exist the delete is rejected with a dependency summary \u2014 archive-project instead. Requires team OWNER privileges and confirmEmptyOnly: true as an explicit safety interlock. Prefer archive-project unless you are certain the project should be erased.",
106186
+ inputSchema: {
106187
+ type: "object",
106188
+ properties: {
106189
+ teamId: teamIdProp,
106190
+ projectId: { type: "string", description: "Project ID to delete" },
106191
+ confirmEmptyOnly: {
106192
+ type: "boolean",
106193
+ description: "Must be true to authorise the hard delete of an empty project."
106194
+ }
106195
+ },
106196
+ required: ["projectId"]
106197
+ }
106198
+ },
105998
106199
  {
105999
106200
  name: "get-project-members",
106000
106201
  description: "List the members explicitly assigned to a project (member_project_access) plus the full team roster with each member's effective access. Use the returned userIds with set/add/remove-project-member. Access model: owners and members with no restrictions see ALL projects; once a member is explicitly assigned to ANY project they can ONLY see their explicitly-assigned projects.",
@@ -106377,6 +106578,148 @@ var TOOLS = [
106377
106578
  required: ["productId"]
106378
106579
  }
106379
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
+ },
106380
106723
  {
106381
106724
  name: "log-hours",
106382
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).",
@@ -106408,6 +106751,167 @@ var TOOLS = [
106408
106751
  required: ["workDescription", "estimatedHours"]
106409
106752
  }
106410
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
+ },
106411
106915
  {
106412
106916
  name: "get-github-file",
106413
106917
  description: "Get the contents of a specific file from a GitHub repository. Use this after finding relevant files to read their full content.",
@@ -107148,21 +107652,61 @@ async function syncTicketDeadline(teamId, ticket, dueDate) {
107148
107652
  return null;
107149
107653
  }
107150
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
+
107151
107693
  // src/tools/customers.ts
107694
+ function textResponse(text3) {
107695
+ return { content: [{ type: "text", text: text3 }] };
107696
+ }
107152
107697
  async function handleGetCustomers(input) {
107153
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
+ }
107154
107705
  const resolved = await resolveTeamId(input.teamId);
107155
107706
  if (!resolved.ok) return resolved.response;
107156
107707
  const customerIds = await getAccessibleCustomerIds(resolved.teamId);
107157
107708
  if (customerIds.length === 0) {
107158
- return {
107159
- content: [
107160
- {
107161
- type: "text",
107162
- text: "No customers found or no access to any customers."
107163
- }
107164
- ]
107165
- };
107709
+ return textResponse("No customers found or no access to any customers.");
107166
107710
  }
107167
107711
  const filters = [inArray(schema_exports.customers.id, customerIds)];
107168
107712
  if (q3) {
@@ -107174,54 +107718,332 @@ async function handleGetCustomers(input) {
107174
107718
  )
107175
107719
  );
107176
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
+ }
107177
107731
  const rows = await db.select({
107178
107732
  id: schema_exports.customers.id,
107179
107733
  name: schema_exports.customers.name,
107180
107734
  email: schema_exports.customers.email,
107181
107735
  website: schema_exports.customers.website,
107736
+ phone: schema_exports.customers.phone,
107737
+ status: schema_exports.customers.status,
107738
+ isArchived: schema_exports.customers.isArchived,
107182
107739
  createdAt: schema_exports.customers.createdAt
107183
107740
  }).from(schema_exports.customers).where(and(...filters)).orderBy(asc(schema_exports.customers.name)).limit(Math.min(pageSize, 100));
107184
- return {
107185
- content: [
107186
- {
107187
- type: "text",
107188
- text: `Found ${rows.length} customers:
107741
+ return textResponse(
107742
+ `Found ${rows.length} customer(s)${status !== "all" ? ` (status: ${status})` : ""}:
107189
107743
 
107190
107744
  ${rows.map(
107191
- (c6) => `**${c6.name}**
107745
+ (c6) => `**${c6.name}** (ID: ${c6.id})${c6.isArchived ? " \u2014 ARCHIVED" : ""}
107192
107746
  ${c6.email ? `Email: ${c6.email}
107193
107747
  ` : ""}${c6.website ? `Website: ${c6.website}
107748
+ ` : ""}${c6.phone ? `Phone: ${c6.phone}
107749
+ ` : ""}${c6.status ? `Status: ${c6.status}
107194
107750
  ` : ""}Created: ${new Date(c6.createdAt).toLocaleDateString()}
107195
107751
  `
107196
- ).join("\n") || "No customers found."}`
107197
- }
107198
- ]
107199
- };
107752
+ ).join("\n") || "No customers found."}`
107753
+ );
107200
107754
  }
107201
107755
  async function handleCreateCustomer(input) {
107202
107756
  const { name: name21, email: email5, website } = input;
107203
107757
  const resolved = await resolveTeamId(input.teamId);
107204
107758
  if (!resolved.ok) return resolved.response;
107205
- await db.insert(schema_exports.customers).values({
107759
+ const [created] = await db.insert(schema_exports.customers).values({
107206
107760
  teamId: resolved.teamId,
107207
107761
  name: name21,
107208
107762
  email: email5 ?? "",
107209
107763
  website: website ?? null
107210
- });
107211
- return {
107212
- content: [
107213
- {
107214
- type: "text",
107215
- text: `\u2705 **Customer Created Successfully!**
107764
+ }).returning({ id: schema_exports.customers.id });
107765
+ return textResponse(
107766
+ `\u2705 **Customer Created Successfully!**
107216
107767
 
107217
107768
  Name: ${name21}
107218
- ${email5 ? `Email: ${email5}
107769
+ ${created ? `ID: ${created.id}
107770
+ ` : ""}${email5 ? `Email: ${email5}
107219
107771
  ` : ""}${website ? `Website: ${website}
107220
107772
  ` : ""}`
107221
- }
107222
- ]
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
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
+ ]);
107894
+ return {
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
107223
107904
  };
107224
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**
108036
+
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
+ );
108046
+ }
107225
108047
 
107226
108048
  // ../document/src/humanizer/rules.ts
107227
108049
  var REPLACEMENTS = [
@@ -107985,7 +108807,7 @@ async function applyHumanizer(blocks, mode) {
107985
108807
  for (const change of rules.report) {
107986
108808
  byRule.set(change.rule, (byRule.get(change.rule) ?? 0) + 1);
107987
108809
  }
107988
- const ruleSummary = [...byRule.entries()].map(([rule, count]) => `${rule} \xD7${count}`).join(", ");
108810
+ const ruleSummary = [...byRule.entries()].map(([rule, count2]) => `${rule} \xD7${count2}`).join(", ");
107989
108811
  lines.push(
107990
108812
  `Humanizer (rules): ${rules.report.length} aanpassing(en) \u2014 ${ruleSummary}.`
107991
108813
  );
@@ -112565,10 +113387,59 @@ The document can now be selected as a PDF attachment when sending this invoice f
112565
113387
  };
112566
113388
  }
112567
113389
 
113390
+ // src/tools/project-cleanup-util.ts
113391
+ var PROJECT_STATUS_FILTERS = [
113392
+ "active",
113393
+ "archived",
113394
+ "all"
113395
+ ];
113396
+ var DEPENDENCY_LABELS2 = {
113397
+ tickets: "ticket(s)",
113398
+ timesheetEvents: "agenda/time entr(ies)",
113399
+ timesheetTemplates: "timesheet template(s)",
113400
+ trips: "trip(s)",
113401
+ tripTemplates: "trip template(s)"
113402
+ };
113403
+ function getProjectArchiveState(settings) {
113404
+ const obj = settings && typeof settings === "object" && !Array.isArray(settings) ? settings : {};
113405
+ const archivedAt = typeof obj.archivedAt === "string" && obj.archivedAt.trim().length > 0 ? obj.archivedAt : null;
113406
+ const archiveReason = typeof obj.archiveReason === "string" && obj.archiveReason.trim().length > 0 ? obj.archiveReason : null;
113407
+ return { archived: archivedAt !== null, archivedAt, archiveReason };
113408
+ }
113409
+ function withArchiveSettings(settings, archivedAt, reason) {
113410
+ const base = settings && typeof settings === "object" && !Array.isArray(settings) ? { ...settings } : {};
113411
+ base.archivedAt = archivedAt;
113412
+ if (reason && reason.trim().length > 0) {
113413
+ base.archiveReason = reason.trim();
113414
+ }
113415
+ return base;
113416
+ }
113417
+ function totalProjectDependencies(counts) {
113418
+ return counts.tickets + counts.timesheetEvents + counts.timesheetTemplates + counts.trips + counts.tripTemplates;
113419
+ }
113420
+ function isProjectEmpty(counts) {
113421
+ return totalProjectDependencies(counts) === 0;
113422
+ }
113423
+ function formatProjectDependencies(counts) {
113424
+ const parts = Object.keys(DEPENDENCY_LABELS2).filter((key) => counts[key] > 0).map((key) => `${counts[key]} ${DEPENDENCY_LABELS2[key]}`);
113425
+ return parts.length > 0 ? parts.join(", ") : "no dependencies";
113426
+ }
113427
+
112568
113428
  // src/tools/projects.ts
112569
113429
  async function handleGetProjects(input) {
112570
113430
  const ctx = getAuthContext();
112571
113431
  const { customerId, q: q3, pageSize = 20 } = input;
113432
+ const status = input.status ?? "active";
113433
+ if (!PROJECT_STATUS_FILTERS.includes(status)) {
113434
+ return {
113435
+ content: [
113436
+ {
113437
+ type: "text",
113438
+ text: `Error: invalid status "${status}". Allowed: ${PROJECT_STATUS_FILTERS.join(", ")}.`
113439
+ }
113440
+ ]
113441
+ };
113442
+ }
112572
113443
  const resolved = await resolveTeamId(input.teamId);
112573
113444
  if (!resolved.ok) return resolved.response;
112574
113445
  const projectIds = await getAccessibleProjectIds(ctx.userId, resolved.teamId);
@@ -112585,25 +113456,33 @@ async function handleGetProjects(input) {
112585
113456
  const filters = [inArray(schema_exports.projects.id, projectIds)];
112586
113457
  if (customerId) filters.push(eq(schema_exports.projects.customerId, customerId));
112587
113458
  if (q3) filters.push(ilike(schema_exports.projects.name, `%${q3}%`));
113459
+ if (status === "active") {
113460
+ filters.push(sql`${schema_exports.projects.settings} ->> 'archivedAt' IS NULL`);
113461
+ } else if (status === "archived") {
113462
+ filters.push(sql`${schema_exports.projects.settings} ->> 'archivedAt' IS NOT NULL`);
113463
+ }
112588
113464
  const rows = await db.select({
112589
113465
  id: schema_exports.projects.id,
112590
113466
  name: schema_exports.projects.name,
112591
113467
  description: schema_exports.projects.description,
112592
113468
  customerId: schema_exports.projects.customerId,
112593
- createdAt: schema_exports.projects.createdAt
113469
+ createdAt: schema_exports.projects.createdAt,
113470
+ settings: schema_exports.projects.settings
112594
113471
  }).from(schema_exports.projects).where(and(...filters)).orderBy(asc(schema_exports.projects.name)).limit(Math.min(pageSize, 100));
112595
113472
  return {
112596
113473
  content: [
112597
113474
  {
112598
113475
  type: "text",
112599
- text: `Found ${rows.length} projects:
113476
+ text: `Found ${rows.length} project(s)${status !== "all" ? ` (status: ${status})` : ""}:
112600
113477
 
112601
- ${rows.map(
112602
- (p3) => `**${p3.name}** (ID: ${p3.id})
113478
+ ${rows.map((p3) => {
113479
+ const archive = getProjectArchiveState(p3.settings);
113480
+ return `**${p3.name}** (ID: ${p3.id})${archive.archived ? " \u2014 ARCHIVED" : ""}
112603
113481
  ${p3.description ? `Description: ${p3.description}
112604
113482
  ` : ""}Created: ${new Date(p3.createdAt).toLocaleDateString()}
112605
- `
112606
- ).join("\n") || "No projects found."}`
113483
+ ${archive.archived ? `Archived: ${archive.archivedAt}${archive.archiveReason ? ` (${archive.archiveReason})` : ""}
113484
+ ` : ""}`;
113485
+ }).join("\n") || "No projects found."}`
112607
113486
  }
112608
113487
  ]
112609
113488
  };
@@ -112631,21 +113510,21 @@ ${description ? `Description: ${description}
112631
113510
  ]
112632
113511
  };
112633
113512
  }
112634
- function textResponse(text3) {
113513
+ function textResponse2(text3) {
112635
113514
  return { content: [{ type: "text", text: text3 }] };
112636
113515
  }
112637
113516
  function memberLabel(m4) {
112638
113517
  return m4.fullName || m4.email || m4.userId;
112639
113518
  }
112640
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).";
112641
- async function requireTeamOwner(teamId, userId) {
113520
+ async function requireTeamOwner2(teamId, userId) {
112642
113521
  const [membership] = await db.select({ role: schema_exports.usersOnTeam.role }).from(schema_exports.usersOnTeam).where(
112643
113522
  and(
112644
113523
  eq(schema_exports.usersOnTeam.userId, userId),
112645
113524
  eq(schema_exports.usersOnTeam.teamId, teamId)
112646
113525
  )
112647
113526
  ).limit(1);
112648
- return membership?.role === "owner" ? null : textResponse(OWNER_REQUIRED);
113527
+ return membership?.role === "owner" ? null : textResponse2(OWNER_REQUIRED);
112649
113528
  }
112650
113529
  async function setProjectMemberAccess(params) {
112651
113530
  const { projectId, teamId, memberIds, createdBy } = params;
@@ -112749,7 +113628,7 @@ async function resolveTeamMember(teamId, opts) {
112749
113628
  if (!match) {
112750
113629
  return {
112751
113630
  ok: false,
112752
- response: textResponse(
113631
+ response: textResponse2(
112753
113632
  `User ${opts.userId} is not a member of this team. Call get-project-members to see the team roster.`
112754
113633
  )
112755
113634
  };
@@ -112762,7 +113641,7 @@ async function resolveTeamMember(teamId, opts) {
112762
113641
  if (matches.length === 0) {
112763
113642
  return {
112764
113643
  ok: false,
112765
- response: textResponse(
113644
+ response: textResponse2(
112766
113645
  `No team member found with email "${opts.email}". Call get-project-members to see the team roster.`
112767
113646
  )
112768
113647
  };
@@ -112770,7 +113649,7 @@ async function resolveTeamMember(teamId, opts) {
112770
113649
  if (matches.length > 1) {
112771
113650
  return {
112772
113651
  ok: false,
112773
- response: textResponse(
113652
+ response: textResponse2(
112774
113653
  `Multiple team members match email "${opts.email}". Pass an explicit userId instead.`
112775
113654
  )
112776
113655
  };
@@ -112779,7 +113658,7 @@ async function resolveTeamMember(teamId, opts) {
112779
113658
  }
112780
113659
  return {
112781
113660
  ok: false,
112782
- response: textResponse(
113661
+ response: textResponse2(
112783
113662
  "Provide either a userId or an email to identify the member."
112784
113663
  )
112785
113664
  };
@@ -112828,7 +113707,7 @@ async function handleUpdateProject(input) {
112828
113707
  if (!resolved.ok) return resolved.response;
112829
113708
  const existing = await loadProjectInTeam(id, resolved.teamId);
112830
113709
  if (!existing) {
112831
- return textResponse(
113710
+ return textResponse2(
112832
113711
  `Project ${id} not found, or it is not owned by this team.`
112833
113712
  );
112834
113713
  }
@@ -112843,7 +113722,7 @@ async function handleUpdateProject(input) {
112843
113722
  )
112844
113723
  ).limit(1);
112845
113724
  if (dupe) {
112846
- return textResponse(
113725
+ return textResponse2(
112847
113726
  `A project named "${input.name}" already exists in this team. Choose a different name.`
112848
113727
  );
112849
113728
  }
@@ -112908,7 +113787,7 @@ async function handleUpdateProject(input) {
112908
113787
  customerName: schema_exports.customers.name
112909
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);
112910
113789
  if (!updated) {
112911
- return textResponse(`Failed to update project ${id}.`);
113790
+ return textResponse2(`Failed to update project ${id}.`);
112912
113791
  }
112913
113792
  const lines = [
112914
113793
  "\u2705 **Project Updated**",
@@ -112926,7 +113805,7 @@ async function handleUpdateProject(input) {
112926
113805
  if (willRename) {
112927
113806
  lines.push("", "Note: tickets for this project were renumbered.");
112928
113807
  }
112929
- return textResponse(lines.join("\n"));
113808
+ return textResponse2(lines.join("\n"));
112930
113809
  }
112931
113810
  async function handleGetProjectMembers(input) {
112932
113811
  const { projectId } = input;
@@ -112934,7 +113813,7 @@ async function handleGetProjectMembers(input) {
112934
113813
  if (!resolved.ok) return resolved.response;
112935
113814
  const project = await loadProjectInTeam(projectId, resolved.teamId);
112936
113815
  if (!project) {
112937
- return textResponse(
113816
+ return textResponse2(
112938
113817
  `Project ${projectId} not found, or it is not owned by this team.`
112939
113818
  );
112940
113819
  }
@@ -112963,7 +113842,7 @@ async function handleGetProjectMembers(input) {
112963
113842
  return `- ${memberLabel(m4)} (userId: ${m4.userId}, role: ${m4.role ?? "member"}) \u2014 ${access}`;
112964
113843
  }).join("\n");
112965
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.`;
112966
- return textResponse(
113845
+ return textResponse2(
112967
113846
  `**Project members for "${project.name}"** (ID: ${project.id})
112968
113847
 
112969
113848
  ${note}
@@ -112980,11 +113859,11 @@ async function handleSetProjectMembers(input) {
112980
113859
  const { projectId } = input;
112981
113860
  const resolved = await resolveTeamId(input.teamId);
112982
113861
  if (!resolved.ok) return resolved.response;
112983
- const ownerError = await requireTeamOwner(resolved.teamId, ctx.userId);
113862
+ const ownerError = await requireTeamOwner2(resolved.teamId, ctx.userId);
112984
113863
  if (ownerError) return ownerError;
112985
113864
  const project = await loadProjectInTeam(projectId, resolved.teamId);
112986
113865
  if (!project) {
112987
- return textResponse(
113866
+ return textResponse2(
112988
113867
  `Project ${projectId} not found, or it is not owned by this team.`
112989
113868
  );
112990
113869
  }
@@ -113022,7 +113901,7 @@ async function handleSetProjectMembers(input) {
113022
113901
 
113023
113902
  \u26A0\uFE0F ${names} previously had no restrictions (could see all projects). They are now restricted to only the projects explicitly assigned to them.`;
113024
113903
  }
113025
- return textResponse(
113904
+ return textResponse2(
113026
113905
  `\u2705 **Project members updated**
113027
113906
 
113028
113907
  Members with explicit access to this project:
@@ -113034,11 +113913,11 @@ async function handleAddProjectMember(input) {
113034
113913
  const { projectId } = input;
113035
113914
  const resolved = await resolveTeamId(input.teamId);
113036
113915
  if (!resolved.ok) return resolved.response;
113037
- const ownerError = await requireTeamOwner(resolved.teamId, ctx.userId);
113916
+ const ownerError = await requireTeamOwner2(resolved.teamId, ctx.userId);
113038
113917
  if (ownerError) return ownerError;
113039
113918
  const project = await loadProjectInTeam(projectId, resolved.teamId);
113040
113919
  if (!project) {
113041
- return textResponse(
113920
+ return textResponse2(
113042
113921
  `Project ${projectId} not found, or it is not owned by this team.`
113043
113922
  );
113044
113923
  }
@@ -113049,7 +113928,7 @@ async function handleAddProjectMember(input) {
113049
113928
  if (!member2.ok) return member2.response;
113050
113929
  const state2 = await getProjectAccessState(resolved.teamId, projectId);
113051
113930
  if (state2.projectMemberIds.has(member2.member.userId)) {
113052
- return textResponse(
113931
+ return textResponse2(
113053
113932
  `${memberLabel(member2.member)} already has explicit access to this project.`
113054
113933
  );
113055
113934
  }
@@ -113064,18 +113943,18 @@ async function handleAddProjectMember(input) {
113064
113943
  if (wasUnrestricted) {
113065
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.";
113066
113945
  }
113067
- return textResponse(text3);
113946
+ return textResponse2(text3);
113068
113947
  }
113069
113948
  async function handleRemoveProjectMember(input) {
113070
113949
  const ctx = getAuthContext();
113071
113950
  const { projectId } = input;
113072
113951
  const resolved = await resolveTeamId(input.teamId);
113073
113952
  if (!resolved.ok) return resolved.response;
113074
- const ownerError = await requireTeamOwner(resolved.teamId, ctx.userId);
113953
+ const ownerError = await requireTeamOwner2(resolved.teamId, ctx.userId);
113075
113954
  if (ownerError) return ownerError;
113076
113955
  const project = await loadProjectInTeam(projectId, resolved.teamId);
113077
113956
  if (!project) {
113078
- return textResponse(
113957
+ return textResponse2(
113079
113958
  `Project ${projectId} not found, or it is not owned by this team.`
113080
113959
  );
113081
113960
  }
@@ -113086,7 +113965,7 @@ async function handleRemoveProjectMember(input) {
113086
113965
  if (!member2.ok) return member2.response;
113087
113966
  const state2 = await getProjectAccessState(resolved.teamId, projectId);
113088
113967
  if (!state2.projectMemberIds.has(member2.member.userId)) {
113089
- return textResponse(
113968
+ return textResponse2(
113090
113969
  `${memberLabel(member2.member)} has no explicit assignment to this project; nothing to remove.`
113091
113970
  );
113092
113971
  }
@@ -113102,7 +113981,109 @@ async function handleRemoveProjectMember(input) {
113102
113981
  if ((state2.rowCountByUser.get(member2.member.userId) ?? 0) <= 1) {
113103
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).";
113104
113983
  }
113105
- return textResponse(text3);
113984
+ return textResponse2(text3);
113985
+ }
113986
+ async function loadProjectForCleanup(projectId, teamId) {
113987
+ const accessibleTeamIds = await getAccessibleTeamIds(teamId);
113988
+ const [row] = await db.select({
113989
+ id: schema_exports.projects.id,
113990
+ name: schema_exports.projects.name,
113991
+ teamId: schema_exports.projects.teamId,
113992
+ settings: schema_exports.projects.settings
113993
+ }).from(schema_exports.projects).where(eq(schema_exports.projects.id, projectId)).limit(1);
113994
+ if (!row || !row.teamId || !accessibleTeamIds.includes(row.teamId)) {
113995
+ return null;
113996
+ }
113997
+ return { id: row.id, name: row.name, teamId: row.teamId, settings: row.settings };
113998
+ }
113999
+ async function countProjectDependencies(projectId) {
114000
+ const countRows = (table) => db.select({ c: sql`count(*)::int` }).from(table).where(eq(table.projectId, projectId)).then((r6) => r6[0]?.c ?? 0);
114001
+ const [tickets3, timesheetEvents2, timesheetTemplates2, trips2, tripTemplates2] = await Promise.all([
114002
+ countRows(schema_exports.tickets),
114003
+ countRows(schema_exports.timesheetEvents),
114004
+ countRows(schema_exports.timesheetTemplates),
114005
+ countRows(schema_exports.trips),
114006
+ countRows(schema_exports.tripTemplates)
114007
+ ]);
114008
+ return { tickets: tickets3, timesheetEvents: timesheetEvents2, timesheetTemplates: timesheetTemplates2, trips: trips2, tripTemplates: tripTemplates2 };
114009
+ }
114010
+ async function handleArchiveProject(input) {
114011
+ const { projectId, reason } = input;
114012
+ if (!projectId) return textResponse2("Error: `projectId` is required.");
114013
+ const resolved = await resolveTeamId(input.teamId);
114014
+ if (!resolved.ok) return resolved.response;
114015
+ const project = await loadProjectForCleanup(projectId, resolved.teamId);
114016
+ if (!project) {
114017
+ return textResponse2(
114018
+ `Project ${projectId} not found, or it is not owned by this team.`
114019
+ );
114020
+ }
114021
+ const state2 = getProjectArchiveState(project.settings);
114022
+ if (state2.archived) {
114023
+ return textResponse2(
114024
+ `Project "${project.name}" (${project.id}) is already archived${state2.archivedAt ? ` (since ${state2.archivedAt})` : ""}.`
114025
+ );
114026
+ }
114027
+ const archivedAt = (/* @__PURE__ */ new Date()).toISOString();
114028
+ const nextSettings = withArchiveSettings(project.settings, archivedAt, reason);
114029
+ await db.update(schema_exports.projects).set({ settings: nextSettings, updatedAt: sql`now()` }).where(eq(schema_exports.projects.id, project.id));
114030
+ return textResponse2(
114031
+ `\u2705 **Project archived**
114032
+
114033
+ Project: ${project.name}
114034
+ ID: ${project.id}
114035
+ Action: archived (soft, reversible)
114036
+ Status: archived
114037
+ Timestamp: ${archivedAt}
114038
+ ${reason ? `Reason: ${reason}
114039
+ ` : ""}
114040
+ Archived projects are hidden from get-projects by default (pass status: 'archived' or 'all' to see them). No tickets, hours, or other data were touched.
114041
+
114042
+ Note: the archive flag is stored in \`projects.settings.archivedAt\`; the dashboard UI does not yet read it, so the project still appears there.`
114043
+ );
114044
+ }
114045
+ async function handleDeleteProject(input) {
114046
+ const ctx = getAuthContext();
114047
+ const { projectId, confirmEmptyOnly } = input;
114048
+ if (!projectId) return textResponse2("Error: `projectId` is required.");
114049
+ const resolved = await resolveTeamId(input.teamId);
114050
+ if (!resolved.ok) return resolved.response;
114051
+ const ownerError = await requireTeamOwner2(resolved.teamId, ctx.userId);
114052
+ if (ownerError) return ownerError;
114053
+ const project = await loadProjectForCleanup(projectId, resolved.teamId);
114054
+ if (!project) {
114055
+ return textResponse2(
114056
+ `Project ${projectId} not found, or it is not owned by this team.`
114057
+ );
114058
+ }
114059
+ const deps = await countProjectDependencies(project.id);
114060
+ const summary = formatProjectDependencies(deps);
114061
+ if (!isProjectEmpty(deps)) {
114062
+ return textResponse2(
114063
+ `\u{1F6AB} **Delete blocked** \u2014 project "${project.name}" (${project.id}) is not empty.
114064
+
114065
+ Dependencies: ${summary}.
114066
+
114067
+ A hard delete would orphan these records, so it is not allowed. Use archive-project instead to safely retire this project (reversible, keeps all data).`
114068
+ );
114069
+ }
114070
+ if (confirmEmptyOnly !== true) {
114071
+ return textResponse2(
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).`
114073
+ );
114074
+ }
114075
+ await db.delete(schema_exports.projects).where(eq(schema_exports.projects.id, project.id));
114076
+ return textResponse2(
114077
+ `\u2705 **Project deleted**
114078
+
114079
+ Project: ${project.name}
114080
+ ID: ${project.id}
114081
+ Action: hard delete (empty project)
114082
+ Status: deleted
114083
+ Timestamp: ${(/* @__PURE__ */ new Date()).toISOString()}
114084
+
114085
+ The project had no tickets, hours, trips, or templates. Any project-scoped config (member access, tags, slack/github links, team rates) was removed with it.`
114086
+ );
113106
114087
  }
113107
114088
 
113108
114089
  // src/tools/products.ts
@@ -113122,7 +114103,7 @@ var PRODUCT_COLUMNS = {
113122
114103
  createdAt: schema_exports.invoiceProducts.createdAt,
113123
114104
  updatedAt: schema_exports.invoiceProducts.updatedAt
113124
114105
  };
113125
- function textResponse2(text3) {
114106
+ function textResponse3(text3) {
113126
114107
  return { content: [{ type: "text", text: text3 }] };
113127
114108
  }
113128
114109
  function formatPrice(p3) {
@@ -113143,14 +114124,14 @@ async function handleGetProducts(input) {
113143
114124
  const { q: q3, currency, pageSize = 20 } = input;
113144
114125
  const status = input.status ?? "active";
113145
114126
  if (!PRODUCT_STATUSES.includes(status)) {
113146
- return textResponse2(
114127
+ return textResponse3(
113147
114128
  `Error: invalid status "${status}". Allowed: ${PRODUCT_STATUSES.join(", ")}.`
113148
114129
  );
113149
114130
  }
113150
114131
  const scope = await resolveTeamScope(input.teamId);
113151
114132
  if (!scope.ok) return scope.response;
113152
114133
  if (scope.teamIds.length === 0) {
113153
- return textResponse2("No accessible teams found.");
114134
+ return textResponse3("No accessible teams found.");
113154
114135
  }
113155
114136
  const filters = [inArray(schema_exports.invoiceProducts.teamId, scope.teamIds)];
113156
114137
  if (status === "active") {
@@ -113173,11 +114154,11 @@ async function handleGetProducts(input) {
113173
114154
  asc(schema_exports.invoiceProducts.name)
113174
114155
  ).limit(Math.min(pageSize, 100));
113175
114156
  if (rows.length === 0) {
113176
- return textResponse2(
114157
+ return textResponse3(
113177
114158
  `No products found${status !== "all" ? ` (status: ${status})` : ""}.`
113178
114159
  );
113179
114160
  }
113180
- return textResponse2(
114161
+ return textResponse3(
113181
114162
  `Found ${rows.length} product(s):
113182
114163
 
113183
114164
  ${rows.map(formatProduct).join("\n")}`
@@ -113185,11 +114166,11 @@ ${rows.map(formatProduct).join("\n")}`
113185
114166
  }
113186
114167
  async function handleGetProductById(input) {
113187
114168
  const { productId } = input;
113188
- if (!productId) return textResponse2("Error: `productId` is required.");
114169
+ if (!productId) return textResponse3("Error: `productId` is required.");
113189
114170
  const scope = await resolveTeamScope(input.teamId);
113190
114171
  if (!scope.ok) return scope.response;
113191
114172
  if (scope.teamIds.length === 0) {
113192
- return textResponse2("No accessible teams found.");
114173
+ return textResponse3("No accessible teams found.");
113193
114174
  }
113194
114175
  const [row] = await db.select(PRODUCT_COLUMNS).from(schema_exports.invoiceProducts).where(
113195
114176
  and(
@@ -113198,11 +114179,11 @@ async function handleGetProductById(input) {
113198
114179
  )
113199
114180
  ).limit(1);
113200
114181
  if (!row) {
113201
- return textResponse2(
114182
+ return textResponse3(
113202
114183
  `Product ${productId} not found or you don't have access to it.`
113203
114184
  );
113204
114185
  }
113205
- return textResponse2(formatProduct(row));
114186
+ return textResponse3(formatProduct(row));
113206
114187
  }
113207
114188
  async function loadProductInTeam(productId, teamId) {
113208
114189
  const accessibleTeamIds = await getAccessibleTeamIds(teamId);
@@ -113217,7 +114198,7 @@ async function loadProductInTeam(productId, teamId) {
113217
114198
  async function handleCreateProduct(input) {
113218
114199
  const { name: name21, description, price, currency, unit } = input;
113219
114200
  if (!name21 || name21.trim().length === 0) {
113220
- return textResponse2("Error: `name` is required.");
114201
+ return textResponse3("Error: `name` is required.");
113221
114202
  }
113222
114203
  const resolved = await resolveTeamId(input.teamId);
113223
114204
  if (!resolved.ok) return resolved.response;
@@ -113231,8 +114212,8 @@ async function handleCreateProduct(input) {
113231
114212
  isActive: true,
113232
114213
  lastUsedAt: (/* @__PURE__ */ new Date()).toISOString()
113233
114214
  }).returning(PRODUCT_COLUMNS);
113234
- if (!created) return textResponse2("Failed to create product.");
113235
- return textResponse2(
114215
+ if (!created) return textResponse3("Failed to create product.");
114216
+ return textResponse3(
113236
114217
  `\u2705 **Product created**
113237
114218
 
113238
114219
  ${formatProduct(created)}`
@@ -113240,19 +114221,19 @@ ${formatProduct(created)}`
113240
114221
  }
113241
114222
  async function handleUpdateProduct(input) {
113242
114223
  const { productId } = input;
113243
- if (!productId) return textResponse2("Error: `productId` is required.");
114224
+ if (!productId) return textResponse3("Error: `productId` is required.");
113244
114225
  const resolved = await resolveTeamId(input.teamId);
113245
114226
  if (!resolved.ok) return resolved.response;
113246
114227
  const existing = await loadProductInTeam(productId, resolved.teamId);
113247
114228
  if (!existing) {
113248
- return textResponse2(
114229
+ return textResponse3(
113249
114230
  `Product ${productId} not found, or it is not owned by this team.`
113250
114231
  );
113251
114232
  }
113252
114233
  const updates = {};
113253
114234
  if (input.name !== void 0) {
113254
114235
  if (!input.name || input.name.trim().length === 0) {
113255
- return textResponse2("Error: `name` cannot be empty.");
114236
+ return textResponse3("Error: `name` cannot be empty.");
113256
114237
  }
113257
114238
  updates.name = input.name.trim();
113258
114239
  }
@@ -113262,14 +114243,14 @@ async function handleUpdateProduct(input) {
113262
114243
  if (input.unit !== void 0) updates.unit = input.unit;
113263
114244
  if (input.isActive !== void 0) updates.isActive = input.isActive;
113264
114245
  if (Object.keys(updates).length === 0) {
113265
- return textResponse2(
114246
+ return textResponse3(
113266
114247
  "No fields to update. Provide at least one of: name, description, price, currency, unit, isActive."
113267
114248
  );
113268
114249
  }
113269
114250
  updates.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
113270
114251
  const [updated] = await db.update(schema_exports.invoiceProducts).set(updates).where(eq(schema_exports.invoiceProducts.id, existing.id)).returning(PRODUCT_COLUMNS);
113271
- if (!updated) return textResponse2(`Failed to update product ${productId}.`);
113272
- return textResponse2(
114252
+ if (!updated) return textResponse3(`Failed to update product ${productId}.`);
114253
+ return textResponse3(
113273
114254
  `\u2705 **Product updated**
113274
114255
 
113275
114256
  ${formatProduct(updated)}
@@ -113278,23 +114259,23 @@ Note: this only affects future invoices/quotes. Existing documents keep their li
113278
114259
  }
113279
114260
  async function handleArchiveProduct(input) {
113280
114261
  const { productId, reason } = input;
113281
- if (!productId) return textResponse2("Error: `productId` is required.");
114262
+ if (!productId) return textResponse3("Error: `productId` is required.");
113282
114263
  const resolved = await resolveTeamId(input.teamId);
113283
114264
  if (!resolved.ok) return resolved.response;
113284
114265
  const existing = await loadProductInTeam(productId, resolved.teamId);
113285
114266
  if (!existing) {
113286
- return textResponse2(
114267
+ return textResponse3(
113287
114268
  `Product ${productId} not found, or it is not owned by this team.`
113288
114269
  );
113289
114270
  }
113290
114271
  if (!existing.isActive) {
113291
- return textResponse2(
114272
+ return textResponse3(
113292
114273
  `Product "${existing.name}" (${existing.id}) is already archived.`
113293
114274
  );
113294
114275
  }
113295
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);
113296
- if (!archived) return textResponse2(`Failed to archive product ${productId}.`);
113297
- return textResponse2(
114277
+ if (!archived) return textResponse3(`Failed to archive product ${productId}.`);
114278
+ return textResponse3(
113298
114279
  `\u2705 **Product archived** (hidden from new invoices/quotes; existing documents are untouched).
113299
114280
 
113300
114281
  ${formatProduct(archived)}${reason ? `Reason: ${reason}
@@ -113302,6 +114283,514 @@ ${formatProduct(archived)}${reason ? `Reason: ${reason}
113302
114283
  );
113303
114284
  }
113304
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
+ }
114692
+ const resolved = await resolveTeamId(input.teamId);
114693
+ if (!resolved.ok) return resolved.response;
114694
+ const quote = await loadQuoteInTeam(id, resolved.teamId);
114695
+ if (!quote) {
114696
+ return textResponse4(`Quote ${id} not found or not owned by this team.`);
114697
+ }
114698
+ if (quote.status !== "draft") return notDraftResponse(quote);
114699
+ const defaults = templateDefaultsFromStored(quote.template, quote.currency);
114700
+ const updates = {};
114701
+ if (input.title !== void 0) {
114702
+ updates.template = buildQuoteTemplate(defaults, input.title);
114703
+ }
114704
+ if (input.description !== void 0) {
114705
+ updates.noteDetails = input.description ? tiptapNote(input.description) : null;
114706
+ }
114707
+ if (input.validUntil !== void 0) {
114708
+ updates.validUntil = input.validUntil;
114709
+ }
114710
+ if (input.lineItems !== void 0) {
114711
+ const { items, error: error49 } = await resolveLineItems(
114712
+ input.lineItems,
114713
+ defaults,
114714
+ quote.teamId
114715
+ );
114716
+ if (error49) return textResponse4(`Error: ${error49}`);
114717
+ const totals = computeTotals(items, defaults);
114718
+ updates.lineItems = items;
114719
+ updates.subtotal = totals.subtotal;
114720
+ updates.vat = totals.vat;
114721
+ updates.tax = totals.tax;
114722
+ updates.amount = totals.amount;
114723
+ }
114724
+ if (Object.keys(updates).length === 0) {
114725
+ return textResponse4(
114726
+ "No fields to update. Provide at least one of: title, description, validUntil, lineItems."
114727
+ );
114728
+ }
114729
+ updates.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
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**
114733
+
114734
+ ${formatQuote(updated)}`);
114735
+ }
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.");
114740
+ const resolved = await resolveTeamId(input.teamId);
114741
+ if (!resolved.ok) return resolved.response;
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.`
114752
+ );
114753
+ }
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}.`);
114777
+ }
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}**
114785
+
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.`
114791
+ );
114792
+ }
114793
+
113305
114794
  // src/tools/teams.ts
113306
114795
  async function handleGetTeams() {
113307
114796
  const ctx = getAuthContext();
@@ -118793,7 +120282,7 @@ var EXT_MIME = {
118793
120282
  ppt: "application/vnd.ms-powerpoint",
118794
120283
  pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation"
118795
120284
  };
118796
- function textResponse3(text3) {
120285
+ function textResponse5(text3) {
118797
120286
  return { content: [{ type: "text", text: text3 }] };
118798
120287
  }
118799
120288
  function mimeFromName(name21) {
@@ -118874,12 +120363,12 @@ async function handleUploadTicketAttachment(input) {
118874
120363
  (v2) => typeof v2 === "string" && v2.trim().length > 0
118875
120364
  );
118876
120365
  if (sources.length === 0) {
118877
- return textResponse3(
120366
+ return textResponse5(
118878
120367
  "Provide exactly one source: filePath (absolute local path), imageUrl, or base64Data."
118879
120368
  );
118880
120369
  }
118881
120370
  if (sources.length > 1) {
118882
- return textResponse3(
120371
+ return textResponse5(
118883
120372
  "Provide only one source (filePath, imageUrl, or base64Data), not several."
118884
120373
  );
118885
120374
  }
@@ -118899,7 +120388,7 @@ async function handleUploadTicketAttachment(input) {
118899
120388
  } else if (input.imageUrl) {
118900
120389
  const res = await fetch(input.imageUrl);
118901
120390
  if (!res.ok) {
118902
- return textResponse3(
120391
+ return textResponse5(
118903
120392
  `Could not download from URL: HTTP ${res.status}.`
118904
120393
  );
118905
120394
  }
@@ -118927,22 +120416,22 @@ async function handleUploadTicketAttachment(input) {
118927
120416
  }
118928
120417
  }
118929
120418
  } catch (error49) {
118930
- return textResponse3(
120419
+ return textResponse5(
118931
120420
  `Failed to read the file: ${error49 instanceof Error ? error49.message : String(error49)}`
118932
120421
  );
118933
120422
  }
118934
120423
  if (buffer2.byteLength === 0) {
118935
- return textResponse3("The file is empty (0 bytes); nothing to upload.");
120424
+ return textResponse5("The file is empty (0 bytes); nothing to upload.");
118936
120425
  }
118937
120426
  if (buffer2.byteLength > MAX_FILE_SIZE) {
118938
- return textResponse3(
120427
+ return textResponse5(
118939
120428
  `File too large (${(buffer2.byteLength / 1024 / 1024).toFixed(
118940
120429
  1
118941
120430
  )} MB). Max: 25 MB.`
118942
120431
  );
118943
120432
  }
118944
120433
  if (!ALLOWED_MIME_TYPES.has(mimeType)) {
118945
- return textResponse3(
120434
+ return textResponse5(
118946
120435
  `Unsupported file type: ${mimeType}. Allowed: JPEG, PNG, GIF, WebP, PDF, DOC(X), XLS(X), PPT(X), TXT, CSV.`
118947
120436
  );
118948
120437
  }
@@ -118955,7 +120444,7 @@ async function handleUploadTicketAttachment(input) {
118955
120444
  options: { contentType: mimeType, upsert: true }
118956
120445
  });
118957
120446
  } catch (error49) {
118958
- return textResponse3(
120447
+ return textResponse5(
118959
120448
  `Upload failed: ${error49 instanceof Error ? error49.message : String(error49)}`
118960
120449
  );
118961
120450
  }
@@ -118978,7 +120467,7 @@ async function handleUploadTicketAttachment(input) {
118978
120467
  url3 = signed.url;
118979
120468
  } catch {
118980
120469
  }
118981
- return textResponse3(
120470
+ return textResponse5(
118982
120471
  `\u{1F4CE} **Attached to ${ticket.ticketNumber}**
118983
120472
  File: ${fileName}
118984
120473
  Type: ${mimeType}
@@ -119314,11 +120803,11 @@ async function handleCreateTag(input) {
119314
120803
  const resolved = await resolveTeamId(input.teamId);
119315
120804
  if (!resolved.ok) return resolved.response;
119316
120805
  const normalized = normalizeTagName(name21);
119317
- const scopeFilter = input.projectId ? eq(schema_exports.tags.projectId, input.projectId) : isNull(schema_exports.tags.projectId);
120806
+ const scopeFilter2 = input.projectId ? eq(schema_exports.tags.projectId, input.projectId) : isNull(schema_exports.tags.projectId);
119318
120807
  const [existing] = await db.select({ id: schema_exports.tags.id, name: schema_exports.tags.name }).from(schema_exports.tags).where(
119319
120808
  and(
119320
120809
  eq(schema_exports.tags.teamId, resolved.teamId),
119321
- scopeFilter,
120810
+ scopeFilter2,
119322
120811
  sql`lower(${schema_exports.tags.name}) = ${normalized}`
119323
120812
  )
119324
120813
  ).limit(1);
@@ -119364,6 +120853,346 @@ ${created.projectId ? `Project ID: ${created.projectId}
119364
120853
  };
119365
120854
  }
119366
120855
 
120856
+ // src/tools/tag-merge-util.ts
120857
+ function planRelationMerge(sourceRows, targetEntityIds) {
120858
+ const targetSet = new Set(targetEntityIds);
120859
+ const teamByEntity = /* @__PURE__ */ new Map();
120860
+ for (const row of sourceRows) {
120861
+ if (!teamByEntity.has(row.entityId)) {
120862
+ teamByEntity.set(row.entityId, row.teamId);
120863
+ }
120864
+ }
120865
+ const toInsert = [];
120866
+ let skippedDuplicates = 0;
120867
+ for (const [entityId, teamId] of teamByEntity) {
120868
+ if (targetSet.has(entityId)) {
120869
+ skippedDuplicates += 1;
120870
+ } else {
120871
+ toInsert.push({ entityId, teamId });
120872
+ }
120873
+ }
120874
+ return {
120875
+ toInsert,
120876
+ skippedDuplicates,
120877
+ sourceEntityCount: teamByEntity.size
120878
+ };
120879
+ }
120880
+ function isValidTagName(name21) {
120881
+ return typeof name21 === "string" && name21.trim().length > 0;
120882
+ }
120883
+ function totalTagUsage(usage) {
120884
+ return usage.tickets + usage.customers + usage.projects + usage.transactions;
120885
+ }
120886
+ function formatTagUsage(usage) {
120887
+ const parts = [];
120888
+ if (usage.tickets) parts.push(`${usage.tickets} ticket(s)`);
120889
+ if (usage.customers) parts.push(`${usage.customers} customer(s)`);
120890
+ if (usage.projects) parts.push(`${usage.projects} project(s)`);
120891
+ if (usage.transactions) parts.push(`${usage.transactions} transaction(s)`);
120892
+ return parts.length > 0 ? parts.join(", ") : "no entities";
120893
+ }
120894
+
120895
+ // src/tools/tag-management.ts
120896
+ function textResponse6(text3) {
120897
+ return { content: [{ type: "text", text: text3 }] };
120898
+ }
120899
+ var TAG_COLUMNS = {
120900
+ id: schema_exports.tags.id,
120901
+ name: schema_exports.tags.name,
120902
+ teamId: schema_exports.tags.teamId,
120903
+ projectId: schema_exports.tags.projectId,
120904
+ createdAt: schema_exports.tags.createdAt
120905
+ };
120906
+ function describeTag(tag) {
120907
+ return `**${tag.name}** (id: ${tag.id})${tag.projectId ? ` [project-specific: ${tag.projectId}]` : " [general]"}`;
120908
+ }
120909
+ async function loadTagInTeam(tagId, teamId) {
120910
+ const accessibleTeamIds = await getAccessibleTeamIds(teamId);
120911
+ const [row] = await db.select(TAG_COLUMNS).from(schema_exports.tags).where(
120912
+ and(
120913
+ eq(schema_exports.tags.id, tagId),
120914
+ inArray(schema_exports.tags.teamId, accessibleTeamIds)
120915
+ )
120916
+ ).limit(1);
120917
+ return row ?? null;
120918
+ }
120919
+ async function getTagUsage(tagId) {
120920
+ const [tickets3, customers2, projects2, transactions2] = await Promise.all([
120921
+ db.select({ value: count() }).from(schema_exports.ticketTags).where(eq(schema_exports.ticketTags.tagId, tagId)),
120922
+ db.select({ value: count() }).from(schema_exports.customerTags).where(eq(schema_exports.customerTags.tagId, tagId)),
120923
+ db.select({ value: count() }).from(schema_exports.projectTags).where(eq(schema_exports.projectTags.tagId, tagId)),
120924
+ db.select({ value: count() }).from(schema_exports.transactionTags).where(eq(schema_exports.transactionTags.tagId, tagId))
120925
+ ]);
120926
+ return {
120927
+ tickets: Number(tickets3[0]?.value ?? 0),
120928
+ customers: Number(customers2[0]?.value ?? 0),
120929
+ projects: Number(projects2[0]?.value ?? 0),
120930
+ transactions: Number(transactions2[0]?.value ?? 0)
120931
+ };
120932
+ }
120933
+ function scopeFilter(projectId) {
120934
+ return projectId === null ? isNull(schema_exports.tags.projectId) : eq(schema_exports.tags.projectId, projectId);
120935
+ }
120936
+ async function handleUpdateTag(input) {
120937
+ if (!input.tagId) return textResponse6("Error: `tagId` is required.");
120938
+ const resolved = await resolveTeamId(input.teamId);
120939
+ if (!resolved.ok) return resolved.response;
120940
+ const existing = await loadTagInTeam(input.tagId, resolved.teamId);
120941
+ if (!existing) {
120942
+ return textResponse6(
120943
+ `Tag ${input.tagId} not found, or it is not owned by this team.`
120944
+ );
120945
+ }
120946
+ const renaming = input.name !== void 0;
120947
+ const rescoping = input.projectId !== void 0;
120948
+ if (!renaming && !rescoping) {
120949
+ return textResponse6(
120950
+ "No changes requested. Provide `name` to rename and/or `projectId` (string, or null for a general tag) to change scope."
120951
+ );
120952
+ }
120953
+ if (renaming && !isValidTagName(input.name)) {
120954
+ return textResponse6("Error: `name` cannot be empty.");
120955
+ }
120956
+ const nextName = renaming ? input.name.trim() : existing.name;
120957
+ const nextProjectId = rescoping ? input.projectId ?? null : existing.projectId;
120958
+ const [collision] = await db.select({ id: schema_exports.tags.id, name: schema_exports.tags.name }).from(schema_exports.tags).where(
120959
+ and(
120960
+ eq(schema_exports.tags.teamId, existing.teamId),
120961
+ scopeFilter(nextProjectId),
120962
+ sql`lower(${schema_exports.tags.name}) = ${normalizeTagName(nextName)}`,
120963
+ ne(schema_exports.tags.id, existing.id)
120964
+ )
120965
+ ).limit(1);
120966
+ if (collision) {
120967
+ return textResponse6(
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.`
120969
+ );
120970
+ }
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);
120972
+ if (!updated) return textResponse6(`Failed to update tag ${input.tagId}.`);
120973
+ return textResponse6(
120974
+ `\u2705 **Tag updated**
120975
+
120976
+ ${describeTag(updated)}
120977
+
120978
+ Existing ticket/customer/project/transaction tag relations are preserved.`
120979
+ );
120980
+ }
120981
+ async function handleDeleteTag(input) {
120982
+ if (!input.tagId) return textResponse6("Error: `tagId` is required.");
120983
+ const mode = input.mode ?? "delete_if_unused";
120984
+ const resolved = await resolveTeamId(input.teamId);
120985
+ if (!resolved.ok) return resolved.response;
120986
+ const existing = await loadTagInTeam(input.tagId, resolved.teamId);
120987
+ if (!existing) {
120988
+ return textResponse6(
120989
+ `Tag ${input.tagId} not found, or it is not owned by this team.`
120990
+ );
120991
+ }
120992
+ const usage = await getTagUsage(existing.id);
120993
+ const total = totalTagUsage(usage);
120994
+ if (mode === "archive") {
120995
+ return textResponse6(
120996
+ `\u2139\uFE0F Archiving is not supported for team tags: the \`tags\` table has no archived column. ${describeTag(existing)} is used by ${formatTagUsage(usage)}.
120997
+
120998
+ Options: use merge-tags to fold it into another tag, or delete it once it is unused (mode: delete_if_unused).`
120999
+ );
121000
+ }
121001
+ if (total > 0) {
121002
+ return textResponse6(
121003
+ `\u274C Refusing to delete ${describeTag(existing)}: it is still used by ${formatTagUsage(usage)}. Deleting would strip the tag off those entities.
121004
+
121005
+ Use merge-tags to move usage onto another tag first, then delete the (now-empty) tag.`
121006
+ );
121007
+ }
121008
+ await db.delete(schema_exports.tags).where(eq(schema_exports.tags.id, existing.id));
121009
+ return textResponse6(
121010
+ `\u2705 **Tag deleted** (was unused): ${describeTag(existing)}`
121011
+ );
121012
+ }
121013
+ async function resolveMergeTarget(teamId, input) {
121014
+ if (input.targetTagId) {
121015
+ const tag = await loadTagInTeam(input.targetTagId, teamId);
121016
+ if (!tag) {
121017
+ return {
121018
+ ok: false,
121019
+ response: textResponse6(
121020
+ `Target tag ${input.targetTagId} not found, or it is not owned by this team.`
121021
+ )
121022
+ };
121023
+ }
121024
+ return { ok: true, tag, created: false };
121025
+ }
121026
+ if (!isValidTagName(input.targetName)) {
121027
+ return {
121028
+ ok: false,
121029
+ response: textResponse6(
121030
+ "Error: provide either `targetTagId` or a non-empty `targetName`."
121031
+ )
121032
+ };
121033
+ }
121034
+ const normalized = normalizeTagName(input.targetName);
121035
+ const matches = await db.select(TAG_COLUMNS).from(schema_exports.tags).where(
121036
+ and(
121037
+ eq(schema_exports.tags.teamId, teamId),
121038
+ sql`lower(${schema_exports.tags.name}) = ${normalized}`
121039
+ )
121040
+ );
121041
+ if (matches.length > 0) {
121042
+ const general = matches.find((t8) => t8.projectId === null);
121043
+ return { ok: true, tag: general ?? matches[0], created: false };
121044
+ }
121045
+ const [created] = await db.insert(schema_exports.tags).values({ teamId, name: input.targetName.trim(), projectId: null }).returning(TAG_COLUMNS);
121046
+ if (!created) {
121047
+ return { ok: false, response: textResponse6("Failed to create target tag.") };
121048
+ }
121049
+ return { ok: true, tag: created, created: true };
121050
+ }
121051
+ async function handleMergeTags(input) {
121052
+ const rawSourceIds = [...new Set(input.sourceTagIds ?? [])].filter(Boolean);
121053
+ if (rawSourceIds.length === 0) {
121054
+ return textResponse6("Error: `sourceTagIds` must contain at least one tag id.");
121055
+ }
121056
+ const resolved = await resolveTeamId(input.teamId);
121057
+ if (!resolved.ok) return resolved.response;
121058
+ const accessibleTeamIds = await getAccessibleTeamIds(resolved.teamId);
121059
+ const sourceTags = await db.select(TAG_COLUMNS).from(schema_exports.tags).where(
121060
+ and(
121061
+ inArray(schema_exports.tags.id, rawSourceIds),
121062
+ inArray(schema_exports.tags.teamId, accessibleTeamIds)
121063
+ )
121064
+ );
121065
+ const foundIds = new Set(sourceTags.map((t8) => t8.id));
121066
+ const missing = rawSourceIds.filter((id) => !foundIds.has(id));
121067
+ if (missing.length > 0) {
121068
+ return textResponse6(
121069
+ `Error: source tag(s) not found or not owned by this team: ${missing.join(", ")}.`
121070
+ );
121071
+ }
121072
+ const target = await resolveMergeTarget(resolved.teamId, input);
121073
+ if (!target.ok) return target.response;
121074
+ const sourcesToMerge = sourceTags.filter((t8) => t8.id !== target.tag.id);
121075
+ if (sourcesToMerge.length === 0) {
121076
+ return textResponse6(
121077
+ "Error: nothing to merge \u2014 the only source tag is the same as the target tag."
121078
+ );
121079
+ }
121080
+ const sourceIds = sourcesToMerge.map((t8) => t8.id);
121081
+ const targetId = target.tag.id;
121082
+ const deleteSources = input.deleteSources ?? true;
121083
+ const results = await db.transaction(async (tx) => {
121084
+ const ticketSrc = await tx.select({
121085
+ entityId: schema_exports.ticketTags.ticketId,
121086
+ teamId: schema_exports.ticketTags.teamId
121087
+ }).from(schema_exports.ticketTags).where(inArray(schema_exports.ticketTags.tagId, sourceIds));
121088
+ const ticketTgt = await tx.select({ entityId: schema_exports.ticketTags.ticketId }).from(schema_exports.ticketTags).where(eq(schema_exports.ticketTags.tagId, targetId));
121089
+ const ticketPlan = planRelationMerge(
121090
+ ticketSrc,
121091
+ ticketTgt.map((r6) => r6.entityId)
121092
+ );
121093
+ if (ticketPlan.toInsert.length > 0) {
121094
+ await tx.insert(schema_exports.ticketTags).values(
121095
+ ticketPlan.toInsert.map((r6) => ({
121096
+ ticketId: r6.entityId,
121097
+ tagId: targetId,
121098
+ teamId: r6.teamId
121099
+ }))
121100
+ );
121101
+ }
121102
+ await tx.delete(schema_exports.ticketTags).where(inArray(schema_exports.ticketTags.tagId, sourceIds));
121103
+ const customerSrc = await tx.select({
121104
+ entityId: schema_exports.customerTags.customerId,
121105
+ teamId: schema_exports.customerTags.teamId
121106
+ }).from(schema_exports.customerTags).where(inArray(schema_exports.customerTags.tagId, sourceIds));
121107
+ const customerTgt = await tx.select({ entityId: schema_exports.customerTags.customerId }).from(schema_exports.customerTags).where(eq(schema_exports.customerTags.tagId, targetId));
121108
+ const customerPlan = planRelationMerge(
121109
+ customerSrc,
121110
+ customerTgt.map((r6) => r6.entityId)
121111
+ );
121112
+ if (customerPlan.toInsert.length > 0) {
121113
+ await tx.insert(schema_exports.customerTags).values(
121114
+ customerPlan.toInsert.map((r6) => ({
121115
+ customerId: r6.entityId,
121116
+ tagId: targetId,
121117
+ teamId: r6.teamId
121118
+ }))
121119
+ );
121120
+ }
121121
+ await tx.delete(schema_exports.customerTags).where(inArray(schema_exports.customerTags.tagId, sourceIds));
121122
+ const projectSrc = await tx.select({
121123
+ entityId: schema_exports.projectTags.projectId,
121124
+ teamId: schema_exports.projectTags.teamId
121125
+ }).from(schema_exports.projectTags).where(inArray(schema_exports.projectTags.tagId, sourceIds));
121126
+ const projectTgt = await tx.select({ entityId: schema_exports.projectTags.projectId }).from(schema_exports.projectTags).where(eq(schema_exports.projectTags.tagId, targetId));
121127
+ const projectPlan = planRelationMerge(
121128
+ projectSrc,
121129
+ projectTgt.map((r6) => r6.entityId)
121130
+ );
121131
+ if (projectPlan.toInsert.length > 0) {
121132
+ await tx.insert(schema_exports.projectTags).values(
121133
+ projectPlan.toInsert.map((r6) => ({
121134
+ projectId: r6.entityId,
121135
+ tagId: targetId,
121136
+ teamId: r6.teamId
121137
+ }))
121138
+ );
121139
+ }
121140
+ await tx.delete(schema_exports.projectTags).where(inArray(schema_exports.projectTags.tagId, sourceIds));
121141
+ const txnSrc = await tx.select({
121142
+ entityId: schema_exports.transactionTags.transactionId,
121143
+ teamId: schema_exports.transactionTags.teamId
121144
+ }).from(schema_exports.transactionTags).where(inArray(schema_exports.transactionTags.tagId, sourceIds));
121145
+ const txnTgt = await tx.select({ entityId: schema_exports.transactionTags.transactionId }).from(schema_exports.transactionTags).where(eq(schema_exports.transactionTags.tagId, targetId));
121146
+ const txnPlan = planRelationMerge(
121147
+ txnSrc,
121148
+ txnTgt.map((r6) => r6.entityId)
121149
+ );
121150
+ if (txnPlan.toInsert.length > 0) {
121151
+ await tx.insert(schema_exports.transactionTags).values(
121152
+ txnPlan.toInsert.map((r6) => ({
121153
+ transactionId: r6.entityId,
121154
+ tagId: targetId,
121155
+ teamId: r6.teamId
121156
+ }))
121157
+ );
121158
+ }
121159
+ await tx.delete(schema_exports.transactionTags).where(inArray(schema_exports.transactionTags.tagId, sourceIds));
121160
+ if (deleteSources) {
121161
+ await tx.delete(schema_exports.tags).where(inArray(schema_exports.tags.id, sourceIds));
121162
+ }
121163
+ return {
121164
+ tickets: planToResult(ticketPlan),
121165
+ customers: planToResult(customerPlan),
121166
+ projects: planToResult(projectPlan),
121167
+ transactions: planToResult(txnPlan)
121168
+ };
121169
+ });
121170
+ const movedTotal = results.tickets.moved + results.customers.moved + results.projects.moved + results.transactions.moved;
121171
+ const skippedTotal = results.tickets.skipped + results.customers.skipped + results.projects.skipped + results.transactions.skipped;
121172
+ const line2 = (label, r6) => `- ${label}: ${r6.moved} moved, ${r6.skipped} skipped (duplicate)`;
121173
+ return textResponse6(
121174
+ `\u2705 **Tags merged** into ${describeTag(target.tag)}${target.created ? " (newly created)" : ""}
121175
+
121176
+ Sources (${sourcesToMerge.length}): ${sourcesToMerge.map((t8) => `${t8.name} (${t8.id})`).join(", ")}
121177
+
121178
+ Relations moved (duplicates skipped to keep a single tag per entity):
121179
+ ${line2("Tickets", results.tickets)}
121180
+ ${line2("Customers", results.customers)}
121181
+ ${line2("Projects", results.projects)}
121182
+ ${line2("Transactions", results.transactions)}
121183
+
121184
+ Totals: ${movedTotal} relation(s) moved, ${skippedTotal} duplicate(s) skipped.
121185
+ Source tags ${deleteSources ? "deleted" : "kept (now empty)"}: ${sourcesToMerge.map((t8) => t8.id).join(", ")}.`
121186
+ );
121187
+ }
121188
+ function planToResult(plan) {
121189
+ return {
121190
+ moved: plan.toInsert.length,
121191
+ skipped: plan.skippedDuplicates,
121192
+ total: plan.sourceEntityCount
121193
+ };
121194
+ }
121195
+
119367
121196
  // src/utils/ticket-number.ts
119368
121197
  async function isTicketNumberTaken(ticketDb, teamId, ticketNumber, excludeTicketId) {
119369
121198
  const conditions = [
@@ -119688,6 +121517,527 @@ ${changes.map((c6) => ` \u2022 ${c6}`).join("\n")}` : "No field changes were a
119688
121517
  };
119689
121518
  }
119690
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
+
119691
122041
  // src/tools/tickets.ts
119692
122042
  function isImageFile(mimeType) {
119693
122043
  return mimeType.startsWith("image/") && ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"].includes(
@@ -120170,6 +122520,12 @@ function createMcpServer() {
120170
122520
  return await handleGetTags(asToolArgs(toolArgs));
120171
122521
  case "create-tag":
120172
122522
  return await handleCreateTag(asToolArgs(toolArgs));
122523
+ case "update-tag":
122524
+ return await handleUpdateTag(asToolArgs(toolArgs));
122525
+ case "delete-tag":
122526
+ return await handleDeleteTag(asToolArgs(toolArgs));
122527
+ case "merge-tags":
122528
+ return await handleMergeTags(asToolArgs(toolArgs));
120173
122529
  case "get-calendar-items":
120174
122530
  return await handleGetCalendarItems(
120175
122531
  asToolArgs(toolArgs)
@@ -120202,12 +122558,32 @@ function createMcpServer() {
120202
122558
  return await handleGetCustomers(asToolArgs(toolArgs));
120203
122559
  case "create-customer":
120204
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
+ );
120205
122573
  case "get-projects":
120206
122574
  return await handleGetProjects(asToolArgs(toolArgs));
120207
122575
  case "create-project":
120208
122576
  return await handleCreateProject(asToolArgs(toolArgs));
120209
122577
  case "update-project":
120210
122578
  return await handleUpdateProject(asToolArgs(toolArgs));
122579
+ case "archive-project":
122580
+ return await handleArchiveProject(
122581
+ asToolArgs(toolArgs)
122582
+ );
122583
+ case "delete-project":
122584
+ return await handleDeleteProject(
122585
+ asToolArgs(toolArgs)
122586
+ );
120211
122587
  case "get-project-members":
120212
122588
  return await handleGetProjectMembers(
120213
122589
  asToolArgs(toolArgs)
@@ -120264,6 +122640,32 @@ function createMcpServer() {
120264
122640
  return await handleArchiveProduct(
120265
122641
  asToolArgs(toolArgs)
120266
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
+ );
120267
122669
  case "log-hours":
120268
122670
  return await handleLogHours(asToolArgs(toolArgs));
120269
122671
  case "get-github-file":