@mgsoftwarebv/mcp-server-bridge 3.4.0 → 3.5.0

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.",
@@ -105935,13 +106004,19 @@ var TOOLS = [
105935
106004
  },
105936
106005
  {
105937
106006
  name: "get-projects",
105938
- description: "Get projects with optional filtering",
106007
+ 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
106008
  inputSchema: {
105940
106009
  type: "object",
105941
106010
  properties: {
105942
106011
  teamId: teamIdProp,
105943
106012
  customerId: { type: "string", description: "Filter by customer ID" },
105944
106013
  q: { type: "string", description: "Search query for project name" },
106014
+ status: {
106015
+ type: "string",
106016
+ enum: ["active", "archived", "all"],
106017
+ default: "active",
106018
+ description: "Archive filter: 'active' (default, hides archived), 'archived', or 'all'."
106019
+ },
105945
106020
  pageSize: { type: "number", default: 20, maximum: 100 }
105946
106021
  },
105947
106022
  required: []
@@ -105968,7 +106043,7 @@ var TOOLS = [
105968
106043
  },
105969
106044
  {
105970
106045
  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.",
106046
+ 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
106047
  inputSchema: {
105973
106048
  type: "object",
105974
106049
  properties: {
@@ -105995,6 +106070,38 @@ var TOOLS = [
105995
106070
  required: ["id"]
105996
106071
  }
105997
106072
  },
106073
+ {
106074
+ name: "archive-project",
106075
+ 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.",
106076
+ inputSchema: {
106077
+ type: "object",
106078
+ properties: {
106079
+ teamId: teamIdProp,
106080
+ projectId: { type: "string", description: "Project ID to archive" },
106081
+ reason: {
106082
+ type: "string",
106083
+ description: "Optional note explaining why the project is archived"
106084
+ }
106085
+ },
106086
+ required: ["projectId"]
106087
+ }
106088
+ },
106089
+ {
106090
+ name: "delete-project",
106091
+ 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.",
106092
+ inputSchema: {
106093
+ type: "object",
106094
+ properties: {
106095
+ teamId: teamIdProp,
106096
+ projectId: { type: "string", description: "Project ID to delete" },
106097
+ confirmEmptyOnly: {
106098
+ type: "boolean",
106099
+ description: "Must be true to authorise the hard delete of an empty project."
106100
+ }
106101
+ },
106102
+ required: ["projectId"]
106103
+ }
106104
+ },
105998
106105
  {
105999
106106
  name: "get-project-members",
106000
106107
  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.",
@@ -106277,6 +106384,106 @@ var TOOLS = [
106277
106384
  required: ["documentId", "invoiceId"]
106278
106385
  }
106279
106386
  },
106387
+ {
106388
+ name: "get-products",
106389
+ description: "List catalog products used on invoices AND quotes (the shared `invoice_products` catalog). Each entry includes its ID (UUID), name, unit price, currency, unit, active/archived flag, configurable flag, and usage stats. Editing or archiving a catalog product never changes existing invoices/quotes \u2014 those keep an immutable line-item snapshot; catalog changes only affect documents created afterwards.",
106390
+ inputSchema: {
106391
+ type: "object",
106392
+ properties: {
106393
+ teamId: teamIdProp,
106394
+ q: {
106395
+ type: "string",
106396
+ description: "Search query for product name/description"
106397
+ },
106398
+ status: {
106399
+ type: "string",
106400
+ enum: ["active", "archived", "all"],
106401
+ default: "active",
106402
+ description: "Filter by catalog status (archived = isActive false)"
106403
+ },
106404
+ currency: {
106405
+ type: "string",
106406
+ description: "Filter by currency code (e.g. EUR)"
106407
+ },
106408
+ pageSize: { type: "number", default: 20, maximum: 100 }
106409
+ },
106410
+ required: []
106411
+ }
106412
+ },
106413
+ {
106414
+ name: "get-product-by-id",
106415
+ description: "Get a single catalog product by its ID (UUID), including name, unit price, currency, unit, active/archived flag, configurable flag, and usage stats.",
106416
+ inputSchema: {
106417
+ type: "object",
106418
+ properties: {
106419
+ teamId: teamIdProp,
106420
+ productId: { type: "string", description: "Product ID (UUID)" }
106421
+ },
106422
+ required: ["productId"]
106423
+ }
106424
+ },
106425
+ {
106426
+ name: "create-product",
106427
+ description: "Create a catalog product for use on invoices and quotes. Stored in the shared `invoice_products` catalog. This only adds a reusable catalog entry; it does not place the product on any document. Returns the created product with its ID and normalized fields.",
106428
+ inputSchema: {
106429
+ type: "object",
106430
+ properties: {
106431
+ teamId: teamIdProp,
106432
+ name: { type: "string", description: "Product name" },
106433
+ description: { type: "string" },
106434
+ price: {
106435
+ type: "number",
106436
+ description: "Unit price (catalog default, excl. quantity)"
106437
+ },
106438
+ currency: { type: "string", description: "Currency code (e.g. EUR)" },
106439
+ unit: {
106440
+ type: "string",
106441
+ description: "Unit label (e.g. hour, piece, month)"
106442
+ }
106443
+ },
106444
+ required: ["name"]
106445
+ }
106446
+ },
106447
+ {
106448
+ name: "update-product",
106449
+ description: "Update a catalog product's editable fields (name, description, price, currency, unit) or reactivate it (isActive: true). Only provided fields change. IMPORTANT: updates apply only to FUTURE invoices/quotes. Existing/sent/accepted/paid documents keep their immutable line-item snapshot and are never mutated. Find the product id via get-products.",
106450
+ inputSchema: {
106451
+ type: "object",
106452
+ properties: {
106453
+ teamId: teamIdProp,
106454
+ productId: { type: "string", description: "Product ID (UUID)" },
106455
+ name: { type: "string" },
106456
+ description: { type: ["string", "null"] },
106457
+ price: {
106458
+ type: ["number", "null"],
106459
+ description: "Unit price (catalog default)"
106460
+ },
106461
+ currency: { type: ["string", "null"] },
106462
+ unit: { type: ["string", "null"] },
106463
+ isActive: {
106464
+ type: "boolean",
106465
+ description: "Set true to reactivate an archived product"
106466
+ }
106467
+ },
106468
+ required: ["productId"]
106469
+ }
106470
+ },
106471
+ {
106472
+ name: "archive-product",
106473
+ description: "Archive (soft-disable) a catalog product so it no longer appears in invoice/quote product pickers. Preferred over deletion: products referenced historically stay safe because invoices/quotes hold their own line-item snapshots. Reactivate later via update-product (isActive: true).",
106474
+ inputSchema: {
106475
+ type: "object",
106476
+ properties: {
106477
+ teamId: teamIdProp,
106478
+ productId: { type: "string", description: "Product ID (UUID)" },
106479
+ reason: {
106480
+ type: "string",
106481
+ description: "Optional note about why it was archived (not persisted)"
106482
+ }
106483
+ },
106484
+ required: ["productId"]
106485
+ }
106486
+ },
106280
106487
  {
106281
106488
  name: "log-hours",
106282
106489
  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).",
@@ -107885,7 +108092,7 @@ async function applyHumanizer(blocks, mode) {
107885
108092
  for (const change of rules.report) {
107886
108093
  byRule.set(change.rule, (byRule.get(change.rule) ?? 0) + 1);
107887
108094
  }
107888
- const ruleSummary = [...byRule.entries()].map(([rule, count]) => `${rule} \xD7${count}`).join(", ");
108095
+ const ruleSummary = [...byRule.entries()].map(([rule, count2]) => `${rule} \xD7${count2}`).join(", ");
107889
108096
  lines.push(
107890
108097
  `Humanizer (rules): ${rules.report.length} aanpassing(en) \u2014 ${ruleSummary}.`
107891
108098
  );
@@ -112465,10 +112672,59 @@ The document can now be selected as a PDF attachment when sending this invoice f
112465
112672
  };
112466
112673
  }
112467
112674
 
112675
+ // src/tools/project-cleanup-util.ts
112676
+ var PROJECT_STATUS_FILTERS = [
112677
+ "active",
112678
+ "archived",
112679
+ "all"
112680
+ ];
112681
+ var DEPENDENCY_LABELS = {
112682
+ tickets: "ticket(s)",
112683
+ timesheetEvents: "agenda/time entr(ies)",
112684
+ timesheetTemplates: "timesheet template(s)",
112685
+ trips: "trip(s)",
112686
+ tripTemplates: "trip template(s)"
112687
+ };
112688
+ function getProjectArchiveState(settings) {
112689
+ const obj = settings && typeof settings === "object" && !Array.isArray(settings) ? settings : {};
112690
+ const archivedAt = typeof obj.archivedAt === "string" && obj.archivedAt.trim().length > 0 ? obj.archivedAt : null;
112691
+ const archiveReason = typeof obj.archiveReason === "string" && obj.archiveReason.trim().length > 0 ? obj.archiveReason : null;
112692
+ return { archived: archivedAt !== null, archivedAt, archiveReason };
112693
+ }
112694
+ function withArchiveSettings(settings, archivedAt, reason) {
112695
+ const base = settings && typeof settings === "object" && !Array.isArray(settings) ? { ...settings } : {};
112696
+ base.archivedAt = archivedAt;
112697
+ if (reason && reason.trim().length > 0) {
112698
+ base.archiveReason = reason.trim();
112699
+ }
112700
+ return base;
112701
+ }
112702
+ function totalProjectDependencies(counts) {
112703
+ return counts.tickets + counts.timesheetEvents + counts.timesheetTemplates + counts.trips + counts.tripTemplates;
112704
+ }
112705
+ function isProjectEmpty(counts) {
112706
+ return totalProjectDependencies(counts) === 0;
112707
+ }
112708
+ function formatProjectDependencies(counts) {
112709
+ const parts = Object.keys(DEPENDENCY_LABELS).filter((key) => counts[key] > 0).map((key) => `${counts[key]} ${DEPENDENCY_LABELS[key]}`);
112710
+ return parts.length > 0 ? parts.join(", ") : "no dependencies";
112711
+ }
112712
+
112468
112713
  // src/tools/projects.ts
112469
112714
  async function handleGetProjects(input) {
112470
112715
  const ctx = getAuthContext();
112471
112716
  const { customerId, q: q3, pageSize = 20 } = input;
112717
+ const status = input.status ?? "active";
112718
+ if (!PROJECT_STATUS_FILTERS.includes(status)) {
112719
+ return {
112720
+ content: [
112721
+ {
112722
+ type: "text",
112723
+ text: `Error: invalid status "${status}". Allowed: ${PROJECT_STATUS_FILTERS.join(", ")}.`
112724
+ }
112725
+ ]
112726
+ };
112727
+ }
112472
112728
  const resolved = await resolveTeamId(input.teamId);
112473
112729
  if (!resolved.ok) return resolved.response;
112474
112730
  const projectIds = await getAccessibleProjectIds(ctx.userId, resolved.teamId);
@@ -112485,25 +112741,33 @@ async function handleGetProjects(input) {
112485
112741
  const filters = [inArray(schema_exports.projects.id, projectIds)];
112486
112742
  if (customerId) filters.push(eq(schema_exports.projects.customerId, customerId));
112487
112743
  if (q3) filters.push(ilike(schema_exports.projects.name, `%${q3}%`));
112744
+ if (status === "active") {
112745
+ filters.push(sql`${schema_exports.projects.settings} ->> 'archivedAt' IS NULL`);
112746
+ } else if (status === "archived") {
112747
+ filters.push(sql`${schema_exports.projects.settings} ->> 'archivedAt' IS NOT NULL`);
112748
+ }
112488
112749
  const rows = await db.select({
112489
112750
  id: schema_exports.projects.id,
112490
112751
  name: schema_exports.projects.name,
112491
112752
  description: schema_exports.projects.description,
112492
112753
  customerId: schema_exports.projects.customerId,
112493
- createdAt: schema_exports.projects.createdAt
112754
+ createdAt: schema_exports.projects.createdAt,
112755
+ settings: schema_exports.projects.settings
112494
112756
  }).from(schema_exports.projects).where(and(...filters)).orderBy(asc(schema_exports.projects.name)).limit(Math.min(pageSize, 100));
112495
112757
  return {
112496
112758
  content: [
112497
112759
  {
112498
112760
  type: "text",
112499
- text: `Found ${rows.length} projects:
112761
+ text: `Found ${rows.length} project(s)${status !== "all" ? ` (status: ${status})` : ""}:
112500
112762
 
112501
- ${rows.map(
112502
- (p3) => `**${p3.name}** (ID: ${p3.id})
112763
+ ${rows.map((p3) => {
112764
+ const archive = getProjectArchiveState(p3.settings);
112765
+ return `**${p3.name}** (ID: ${p3.id})${archive.archived ? " \u2014 ARCHIVED" : ""}
112503
112766
  ${p3.description ? `Description: ${p3.description}
112504
112767
  ` : ""}Created: ${new Date(p3.createdAt).toLocaleDateString()}
112505
- `
112506
- ).join("\n") || "No projects found."}`
112768
+ ${archive.archived ? `Archived: ${archive.archivedAt}${archive.archiveReason ? ` (${archive.archiveReason})` : ""}
112769
+ ` : ""}`;
112770
+ }).join("\n") || "No projects found."}`
112507
112771
  }
112508
112772
  ]
112509
112773
  };
@@ -113004,6 +113268,305 @@ async function handleRemoveProjectMember(input) {
113004
113268
  }
113005
113269
  return textResponse(text3);
113006
113270
  }
113271
+ async function loadProjectForCleanup(projectId, teamId) {
113272
+ const accessibleTeamIds = await getAccessibleTeamIds(teamId);
113273
+ const [row] = await db.select({
113274
+ id: schema_exports.projects.id,
113275
+ name: schema_exports.projects.name,
113276
+ teamId: schema_exports.projects.teamId,
113277
+ settings: schema_exports.projects.settings
113278
+ }).from(schema_exports.projects).where(eq(schema_exports.projects.id, projectId)).limit(1);
113279
+ if (!row || !row.teamId || !accessibleTeamIds.includes(row.teamId)) {
113280
+ return null;
113281
+ }
113282
+ return { id: row.id, name: row.name, teamId: row.teamId, settings: row.settings };
113283
+ }
113284
+ async function countProjectDependencies(projectId) {
113285
+ const countRows = (table) => db.select({ c: sql`count(*)::int` }).from(table).where(eq(table.projectId, projectId)).then((r6) => r6[0]?.c ?? 0);
113286
+ const [tickets3, timesheetEvents2, timesheetTemplates2, trips2, tripTemplates2] = await Promise.all([
113287
+ countRows(schema_exports.tickets),
113288
+ countRows(schema_exports.timesheetEvents),
113289
+ countRows(schema_exports.timesheetTemplates),
113290
+ countRows(schema_exports.trips),
113291
+ countRows(schema_exports.tripTemplates)
113292
+ ]);
113293
+ return { tickets: tickets3, timesheetEvents: timesheetEvents2, timesheetTemplates: timesheetTemplates2, trips: trips2, tripTemplates: tripTemplates2 };
113294
+ }
113295
+ async function handleArchiveProject(input) {
113296
+ const { projectId, reason } = input;
113297
+ if (!projectId) return textResponse("Error: `projectId` is required.");
113298
+ const resolved = await resolveTeamId(input.teamId);
113299
+ if (!resolved.ok) return resolved.response;
113300
+ const project = await loadProjectForCleanup(projectId, resolved.teamId);
113301
+ if (!project) {
113302
+ return textResponse(
113303
+ `Project ${projectId} not found, or it is not owned by this team.`
113304
+ );
113305
+ }
113306
+ const state2 = getProjectArchiveState(project.settings);
113307
+ if (state2.archived) {
113308
+ return textResponse(
113309
+ `Project "${project.name}" (${project.id}) is already archived${state2.archivedAt ? ` (since ${state2.archivedAt})` : ""}.`
113310
+ );
113311
+ }
113312
+ const archivedAt = (/* @__PURE__ */ new Date()).toISOString();
113313
+ const nextSettings = withArchiveSettings(project.settings, archivedAt, reason);
113314
+ await db.update(schema_exports.projects).set({ settings: nextSettings, updatedAt: sql`now()` }).where(eq(schema_exports.projects.id, project.id));
113315
+ return textResponse(
113316
+ `\u2705 **Project archived**
113317
+
113318
+ Project: ${project.name}
113319
+ ID: ${project.id}
113320
+ Action: archived (soft, reversible)
113321
+ Status: archived
113322
+ Timestamp: ${archivedAt}
113323
+ ${reason ? `Reason: ${reason}
113324
+ ` : ""}
113325
+ 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.
113326
+
113327
+ Note: the archive flag is stored in \`projects.settings.archivedAt\`; the dashboard UI does not yet read it, so the project still appears there.`
113328
+ );
113329
+ }
113330
+ async function handleDeleteProject(input) {
113331
+ const ctx = getAuthContext();
113332
+ const { projectId, confirmEmptyOnly } = input;
113333
+ if (!projectId) return textResponse("Error: `projectId` is required.");
113334
+ const resolved = await resolveTeamId(input.teamId);
113335
+ if (!resolved.ok) return resolved.response;
113336
+ const ownerError = await requireTeamOwner(resolved.teamId, ctx.userId);
113337
+ if (ownerError) return ownerError;
113338
+ const project = await loadProjectForCleanup(projectId, resolved.teamId);
113339
+ if (!project) {
113340
+ return textResponse(
113341
+ `Project ${projectId} not found, or it is not owned by this team.`
113342
+ );
113343
+ }
113344
+ const deps = await countProjectDependencies(project.id);
113345
+ const summary = formatProjectDependencies(deps);
113346
+ if (!isProjectEmpty(deps)) {
113347
+ return textResponse(
113348
+ `\u{1F6AB} **Delete blocked** \u2014 project "${project.name}" (${project.id}) is not empty.
113349
+
113350
+ Dependencies: ${summary}.
113351
+
113352
+ 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).`
113353
+ );
113354
+ }
113355
+ if (confirmEmptyOnly !== true) {
113356
+ return textResponse(
113357
+ `Project "${project.name}" (${project.id}) has no dependencies and can be safely deleted. This is a permanent hard delete. Re-run delete-project with confirmEmptyOnly: true to proceed (or use archive-project to keep the record).`
113358
+ );
113359
+ }
113360
+ await db.delete(schema_exports.projects).where(eq(schema_exports.projects.id, project.id));
113361
+ return textResponse(
113362
+ `\u2705 **Project deleted**
113363
+
113364
+ Project: ${project.name}
113365
+ ID: ${project.id}
113366
+ Action: hard delete (empty project)
113367
+ Status: deleted
113368
+ Timestamp: ${(/* @__PURE__ */ new Date()).toISOString()}
113369
+
113370
+ 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.`
113371
+ );
113372
+ }
113373
+
113374
+ // src/tools/products.ts
113375
+ var PRODUCT_STATUSES = ["active", "archived", "all"];
113376
+ var PRODUCT_COLUMNS = {
113377
+ id: schema_exports.invoiceProducts.id,
113378
+ teamId: schema_exports.invoiceProducts.teamId,
113379
+ name: schema_exports.invoiceProducts.name,
113380
+ description: schema_exports.invoiceProducts.description,
113381
+ price: schema_exports.invoiceProducts.price,
113382
+ currency: schema_exports.invoiceProducts.currency,
113383
+ unit: schema_exports.invoiceProducts.unit,
113384
+ isConfigurable: schema_exports.invoiceProducts.isConfigurable,
113385
+ isActive: schema_exports.invoiceProducts.isActive,
113386
+ usageCount: schema_exports.invoiceProducts.usageCount,
113387
+ lastUsedAt: schema_exports.invoiceProducts.lastUsedAt,
113388
+ createdAt: schema_exports.invoiceProducts.createdAt,
113389
+ updatedAt: schema_exports.invoiceProducts.updatedAt
113390
+ };
113391
+ function textResponse2(text3) {
113392
+ return { content: [{ type: "text", text: text3 }] };
113393
+ }
113394
+ function formatPrice(p3) {
113395
+ if (p3.price == null) return "(no price)";
113396
+ return `${p3.price}${p3.currency ? ` ${p3.currency}` : ""}${p3.unit ? ` / ${p3.unit}` : ""}`;
113397
+ }
113398
+ function formatProduct(p3) {
113399
+ const flags = [p3.isActive ? "active" : "archived"];
113400
+ if (p3.isConfigurable) flags.push("configurable");
113401
+ return `**${p3.name}** (${flags.join(", ")})
113402
+ ID: ${p3.id}
113403
+ Price: ${formatPrice(p3)}
113404
+ ${p3.description ? `Description: ${p3.description}
113405
+ ` : ""}Used: ${p3.usageCount}x${p3.lastUsedAt ? ` (last ${new Date(p3.lastUsedAt).toLocaleDateString()})` : ""}
113406
+ `;
113407
+ }
113408
+ async function handleGetProducts(input) {
113409
+ const { q: q3, currency, pageSize = 20 } = input;
113410
+ const status = input.status ?? "active";
113411
+ if (!PRODUCT_STATUSES.includes(status)) {
113412
+ return textResponse2(
113413
+ `Error: invalid status "${status}". Allowed: ${PRODUCT_STATUSES.join(", ")}.`
113414
+ );
113415
+ }
113416
+ const scope = await resolveTeamScope(input.teamId);
113417
+ if (!scope.ok) return scope.response;
113418
+ if (scope.teamIds.length === 0) {
113419
+ return textResponse2("No accessible teams found.");
113420
+ }
113421
+ const filters = [inArray(schema_exports.invoiceProducts.teamId, scope.teamIds)];
113422
+ if (status === "active") {
113423
+ filters.push(eq(schema_exports.invoiceProducts.isActive, true));
113424
+ } else if (status === "archived") {
113425
+ filters.push(eq(schema_exports.invoiceProducts.isActive, false));
113426
+ }
113427
+ if (currency) filters.push(eq(schema_exports.invoiceProducts.currency, currency));
113428
+ if (q3) {
113429
+ filters.push(
113430
+ or(
113431
+ sql`${schema_exports.invoiceProducts.fts} @@ plainto_tsquery('english', ${q3})`,
113432
+ ilike(schema_exports.invoiceProducts.name, `%${q3}%`)
113433
+ )
113434
+ );
113435
+ }
113436
+ const rows = await db.select(PRODUCT_COLUMNS).from(schema_exports.invoiceProducts).where(and(...filters)).orderBy(
113437
+ desc(schema_exports.invoiceProducts.usageCount),
113438
+ desc(schema_exports.invoiceProducts.lastUsedAt),
113439
+ asc(schema_exports.invoiceProducts.name)
113440
+ ).limit(Math.min(pageSize, 100));
113441
+ if (rows.length === 0) {
113442
+ return textResponse2(
113443
+ `No products found${status !== "all" ? ` (status: ${status})` : ""}.`
113444
+ );
113445
+ }
113446
+ return textResponse2(
113447
+ `Found ${rows.length} product(s):
113448
+
113449
+ ${rows.map(formatProduct).join("\n")}`
113450
+ );
113451
+ }
113452
+ async function handleGetProductById(input) {
113453
+ const { productId } = input;
113454
+ if (!productId) return textResponse2("Error: `productId` is required.");
113455
+ const scope = await resolveTeamScope(input.teamId);
113456
+ if (!scope.ok) return scope.response;
113457
+ if (scope.teamIds.length === 0) {
113458
+ return textResponse2("No accessible teams found.");
113459
+ }
113460
+ const [row] = await db.select(PRODUCT_COLUMNS).from(schema_exports.invoiceProducts).where(
113461
+ and(
113462
+ eq(schema_exports.invoiceProducts.id, productId),
113463
+ inArray(schema_exports.invoiceProducts.teamId, scope.teamIds)
113464
+ )
113465
+ ).limit(1);
113466
+ if (!row) {
113467
+ return textResponse2(
113468
+ `Product ${productId} not found or you don't have access to it.`
113469
+ );
113470
+ }
113471
+ return textResponse2(formatProduct(row));
113472
+ }
113473
+ async function loadProductInTeam(productId, teamId) {
113474
+ const accessibleTeamIds = await getAccessibleTeamIds(teamId);
113475
+ const [row] = await db.select(PRODUCT_COLUMNS).from(schema_exports.invoiceProducts).where(
113476
+ and(
113477
+ eq(schema_exports.invoiceProducts.id, productId),
113478
+ inArray(schema_exports.invoiceProducts.teamId, accessibleTeamIds)
113479
+ )
113480
+ ).limit(1);
113481
+ return row ?? null;
113482
+ }
113483
+ async function handleCreateProduct(input) {
113484
+ const { name: name21, description, price, currency, unit } = input;
113485
+ if (!name21 || name21.trim().length === 0) {
113486
+ return textResponse2("Error: `name` is required.");
113487
+ }
113488
+ const resolved = await resolveTeamId(input.teamId);
113489
+ if (!resolved.ok) return resolved.response;
113490
+ const [created] = await db.insert(schema_exports.invoiceProducts).values({
113491
+ teamId: resolved.teamId,
113492
+ name: name21.trim(),
113493
+ description: description ?? null,
113494
+ price: price ?? null,
113495
+ currency: currency ?? null,
113496
+ unit: unit ?? null,
113497
+ isActive: true,
113498
+ lastUsedAt: (/* @__PURE__ */ new Date()).toISOString()
113499
+ }).returning(PRODUCT_COLUMNS);
113500
+ if (!created) return textResponse2("Failed to create product.");
113501
+ return textResponse2(
113502
+ `\u2705 **Product created**
113503
+
113504
+ ${formatProduct(created)}`
113505
+ );
113506
+ }
113507
+ async function handleUpdateProduct(input) {
113508
+ const { productId } = input;
113509
+ if (!productId) return textResponse2("Error: `productId` is required.");
113510
+ const resolved = await resolveTeamId(input.teamId);
113511
+ if (!resolved.ok) return resolved.response;
113512
+ const existing = await loadProductInTeam(productId, resolved.teamId);
113513
+ if (!existing) {
113514
+ return textResponse2(
113515
+ `Product ${productId} not found, or it is not owned by this team.`
113516
+ );
113517
+ }
113518
+ const updates = {};
113519
+ if (input.name !== void 0) {
113520
+ if (!input.name || input.name.trim().length === 0) {
113521
+ return textResponse2("Error: `name` cannot be empty.");
113522
+ }
113523
+ updates.name = input.name.trim();
113524
+ }
113525
+ if (input.description !== void 0) updates.description = input.description;
113526
+ if (input.price !== void 0) updates.price = input.price;
113527
+ if (input.currency !== void 0) updates.currency = input.currency;
113528
+ if (input.unit !== void 0) updates.unit = input.unit;
113529
+ if (input.isActive !== void 0) updates.isActive = input.isActive;
113530
+ if (Object.keys(updates).length === 0) {
113531
+ return textResponse2(
113532
+ "No fields to update. Provide at least one of: name, description, price, currency, unit, isActive."
113533
+ );
113534
+ }
113535
+ updates.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
113536
+ const [updated] = await db.update(schema_exports.invoiceProducts).set(updates).where(eq(schema_exports.invoiceProducts.id, existing.id)).returning(PRODUCT_COLUMNS);
113537
+ if (!updated) return textResponse2(`Failed to update product ${productId}.`);
113538
+ return textResponse2(
113539
+ `\u2705 **Product updated**
113540
+
113541
+ ${formatProduct(updated)}
113542
+ Note: this only affects future invoices/quotes. Existing documents keep their line-item snapshots.`
113543
+ );
113544
+ }
113545
+ async function handleArchiveProduct(input) {
113546
+ const { productId, reason } = input;
113547
+ if (!productId) return textResponse2("Error: `productId` is required.");
113548
+ const resolved = await resolveTeamId(input.teamId);
113549
+ if (!resolved.ok) return resolved.response;
113550
+ const existing = await loadProductInTeam(productId, resolved.teamId);
113551
+ if (!existing) {
113552
+ return textResponse2(
113553
+ `Product ${productId} not found, or it is not owned by this team.`
113554
+ );
113555
+ }
113556
+ if (!existing.isActive) {
113557
+ return textResponse2(
113558
+ `Product "${existing.name}" (${existing.id}) is already archived.`
113559
+ );
113560
+ }
113561
+ const [archived] = await db.update(schema_exports.invoiceProducts).set({ isActive: false, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq(schema_exports.invoiceProducts.id, existing.id)).returning(PRODUCT_COLUMNS);
113562
+ if (!archived) return textResponse2(`Failed to archive product ${productId}.`);
113563
+ return textResponse2(
113564
+ `\u2705 **Product archived** (hidden from new invoices/quotes; existing documents are untouched).
113565
+
113566
+ ${formatProduct(archived)}${reason ? `Reason: ${reason}
113567
+ ` : ""}Reactivate it with update-product (isActive: true).`
113568
+ );
113569
+ }
113007
113570
 
113008
113571
  // src/tools/teams.ts
113009
113572
  async function handleGetTeams() {
@@ -118496,7 +119059,7 @@ var EXT_MIME = {
118496
119059
  ppt: "application/vnd.ms-powerpoint",
118497
119060
  pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation"
118498
119061
  };
118499
- function textResponse2(text3) {
119062
+ function textResponse3(text3) {
118500
119063
  return { content: [{ type: "text", text: text3 }] };
118501
119064
  }
118502
119065
  function mimeFromName(name21) {
@@ -118577,12 +119140,12 @@ async function handleUploadTicketAttachment(input) {
118577
119140
  (v2) => typeof v2 === "string" && v2.trim().length > 0
118578
119141
  );
118579
119142
  if (sources.length === 0) {
118580
- return textResponse2(
119143
+ return textResponse3(
118581
119144
  "Provide exactly one source: filePath (absolute local path), imageUrl, or base64Data."
118582
119145
  );
118583
119146
  }
118584
119147
  if (sources.length > 1) {
118585
- return textResponse2(
119148
+ return textResponse3(
118586
119149
  "Provide only one source (filePath, imageUrl, or base64Data), not several."
118587
119150
  );
118588
119151
  }
@@ -118602,7 +119165,7 @@ async function handleUploadTicketAttachment(input) {
118602
119165
  } else if (input.imageUrl) {
118603
119166
  const res = await fetch(input.imageUrl);
118604
119167
  if (!res.ok) {
118605
- return textResponse2(
119168
+ return textResponse3(
118606
119169
  `Could not download from URL: HTTP ${res.status}.`
118607
119170
  );
118608
119171
  }
@@ -118630,22 +119193,22 @@ async function handleUploadTicketAttachment(input) {
118630
119193
  }
118631
119194
  }
118632
119195
  } catch (error49) {
118633
- return textResponse2(
119196
+ return textResponse3(
118634
119197
  `Failed to read the file: ${error49 instanceof Error ? error49.message : String(error49)}`
118635
119198
  );
118636
119199
  }
118637
119200
  if (buffer2.byteLength === 0) {
118638
- return textResponse2("The file is empty (0 bytes); nothing to upload.");
119201
+ return textResponse3("The file is empty (0 bytes); nothing to upload.");
118639
119202
  }
118640
119203
  if (buffer2.byteLength > MAX_FILE_SIZE) {
118641
- return textResponse2(
119204
+ return textResponse3(
118642
119205
  `File too large (${(buffer2.byteLength / 1024 / 1024).toFixed(
118643
119206
  1
118644
119207
  )} MB). Max: 25 MB.`
118645
119208
  );
118646
119209
  }
118647
119210
  if (!ALLOWED_MIME_TYPES.has(mimeType)) {
118648
- return textResponse2(
119211
+ return textResponse3(
118649
119212
  `Unsupported file type: ${mimeType}. Allowed: JPEG, PNG, GIF, WebP, PDF, DOC(X), XLS(X), PPT(X), TXT, CSV.`
118650
119213
  );
118651
119214
  }
@@ -118658,7 +119221,7 @@ async function handleUploadTicketAttachment(input) {
118658
119221
  options: { contentType: mimeType, upsert: true }
118659
119222
  });
118660
119223
  } catch (error49) {
118661
- return textResponse2(
119224
+ return textResponse3(
118662
119225
  `Upload failed: ${error49 instanceof Error ? error49.message : String(error49)}`
118663
119226
  );
118664
119227
  }
@@ -118681,7 +119244,7 @@ async function handleUploadTicketAttachment(input) {
118681
119244
  url3 = signed.url;
118682
119245
  } catch {
118683
119246
  }
118684
- return textResponse2(
119247
+ return textResponse3(
118685
119248
  `\u{1F4CE} **Attached to ${ticket.ticketNumber}**
118686
119249
  File: ${fileName}
118687
119250
  Type: ${mimeType}
@@ -119017,11 +119580,11 @@ async function handleCreateTag(input) {
119017
119580
  const resolved = await resolveTeamId(input.teamId);
119018
119581
  if (!resolved.ok) return resolved.response;
119019
119582
  const normalized = normalizeTagName(name21);
119020
- const scopeFilter = input.projectId ? eq(schema_exports.tags.projectId, input.projectId) : isNull(schema_exports.tags.projectId);
119583
+ const scopeFilter2 = input.projectId ? eq(schema_exports.tags.projectId, input.projectId) : isNull(schema_exports.tags.projectId);
119021
119584
  const [existing] = await db.select({ id: schema_exports.tags.id, name: schema_exports.tags.name }).from(schema_exports.tags).where(
119022
119585
  and(
119023
119586
  eq(schema_exports.tags.teamId, resolved.teamId),
119024
- scopeFilter,
119587
+ scopeFilter2,
119025
119588
  sql`lower(${schema_exports.tags.name}) = ${normalized}`
119026
119589
  )
119027
119590
  ).limit(1);
@@ -119067,6 +119630,346 @@ ${created.projectId ? `Project ID: ${created.projectId}
119067
119630
  };
119068
119631
  }
119069
119632
 
119633
+ // src/tools/tag-merge-util.ts
119634
+ function planRelationMerge(sourceRows, targetEntityIds) {
119635
+ const targetSet = new Set(targetEntityIds);
119636
+ const teamByEntity = /* @__PURE__ */ new Map();
119637
+ for (const row of sourceRows) {
119638
+ if (!teamByEntity.has(row.entityId)) {
119639
+ teamByEntity.set(row.entityId, row.teamId);
119640
+ }
119641
+ }
119642
+ const toInsert = [];
119643
+ let skippedDuplicates = 0;
119644
+ for (const [entityId, teamId] of teamByEntity) {
119645
+ if (targetSet.has(entityId)) {
119646
+ skippedDuplicates += 1;
119647
+ } else {
119648
+ toInsert.push({ entityId, teamId });
119649
+ }
119650
+ }
119651
+ return {
119652
+ toInsert,
119653
+ skippedDuplicates,
119654
+ sourceEntityCount: teamByEntity.size
119655
+ };
119656
+ }
119657
+ function isValidTagName(name21) {
119658
+ return typeof name21 === "string" && name21.trim().length > 0;
119659
+ }
119660
+ function totalTagUsage(usage) {
119661
+ return usage.tickets + usage.customers + usage.projects + usage.transactions;
119662
+ }
119663
+ function formatTagUsage(usage) {
119664
+ const parts = [];
119665
+ if (usage.tickets) parts.push(`${usage.tickets} ticket(s)`);
119666
+ if (usage.customers) parts.push(`${usage.customers} customer(s)`);
119667
+ if (usage.projects) parts.push(`${usage.projects} project(s)`);
119668
+ if (usage.transactions) parts.push(`${usage.transactions} transaction(s)`);
119669
+ return parts.length > 0 ? parts.join(", ") : "no entities";
119670
+ }
119671
+
119672
+ // src/tools/tag-management.ts
119673
+ function textResponse4(text3) {
119674
+ return { content: [{ type: "text", text: text3 }] };
119675
+ }
119676
+ var TAG_COLUMNS = {
119677
+ id: schema_exports.tags.id,
119678
+ name: schema_exports.tags.name,
119679
+ teamId: schema_exports.tags.teamId,
119680
+ projectId: schema_exports.tags.projectId,
119681
+ createdAt: schema_exports.tags.createdAt
119682
+ };
119683
+ function describeTag(tag) {
119684
+ return `**${tag.name}** (id: ${tag.id})${tag.projectId ? ` [project-specific: ${tag.projectId}]` : " [general]"}`;
119685
+ }
119686
+ async function loadTagInTeam(tagId, teamId) {
119687
+ const accessibleTeamIds = await getAccessibleTeamIds(teamId);
119688
+ const [row] = await db.select(TAG_COLUMNS).from(schema_exports.tags).where(
119689
+ and(
119690
+ eq(schema_exports.tags.id, tagId),
119691
+ inArray(schema_exports.tags.teamId, accessibleTeamIds)
119692
+ )
119693
+ ).limit(1);
119694
+ return row ?? null;
119695
+ }
119696
+ async function getTagUsage(tagId) {
119697
+ const [tickets3, customers2, projects2, transactions2] = await Promise.all([
119698
+ db.select({ value: count() }).from(schema_exports.ticketTags).where(eq(schema_exports.ticketTags.tagId, tagId)),
119699
+ db.select({ value: count() }).from(schema_exports.customerTags).where(eq(schema_exports.customerTags.tagId, tagId)),
119700
+ db.select({ value: count() }).from(schema_exports.projectTags).where(eq(schema_exports.projectTags.tagId, tagId)),
119701
+ db.select({ value: count() }).from(schema_exports.transactionTags).where(eq(schema_exports.transactionTags.tagId, tagId))
119702
+ ]);
119703
+ return {
119704
+ tickets: Number(tickets3[0]?.value ?? 0),
119705
+ customers: Number(customers2[0]?.value ?? 0),
119706
+ projects: Number(projects2[0]?.value ?? 0),
119707
+ transactions: Number(transactions2[0]?.value ?? 0)
119708
+ };
119709
+ }
119710
+ function scopeFilter(projectId) {
119711
+ return projectId === null ? isNull(schema_exports.tags.projectId) : eq(schema_exports.tags.projectId, projectId);
119712
+ }
119713
+ async function handleUpdateTag(input) {
119714
+ if (!input.tagId) return textResponse4("Error: `tagId` is required.");
119715
+ const resolved = await resolveTeamId(input.teamId);
119716
+ if (!resolved.ok) return resolved.response;
119717
+ const existing = await loadTagInTeam(input.tagId, resolved.teamId);
119718
+ if (!existing) {
119719
+ return textResponse4(
119720
+ `Tag ${input.tagId} not found, or it is not owned by this team.`
119721
+ );
119722
+ }
119723
+ const renaming = input.name !== void 0;
119724
+ const rescoping = input.projectId !== void 0;
119725
+ if (!renaming && !rescoping) {
119726
+ return textResponse4(
119727
+ "No changes requested. Provide `name` to rename and/or `projectId` (string, or null for a general tag) to change scope."
119728
+ );
119729
+ }
119730
+ if (renaming && !isValidTagName(input.name)) {
119731
+ return textResponse4("Error: `name` cannot be empty.");
119732
+ }
119733
+ const nextName = renaming ? input.name.trim() : existing.name;
119734
+ const nextProjectId = rescoping ? input.projectId ?? null : existing.projectId;
119735
+ const [collision] = await db.select({ id: schema_exports.tags.id, name: schema_exports.tags.name }).from(schema_exports.tags).where(
119736
+ and(
119737
+ eq(schema_exports.tags.teamId, existing.teamId),
119738
+ scopeFilter(nextProjectId),
119739
+ sql`lower(${schema_exports.tags.name}) = ${normalizeTagName(nextName)}`,
119740
+ ne(schema_exports.tags.id, existing.id)
119741
+ )
119742
+ ).limit(1);
119743
+ if (collision) {
119744
+ return textResponse4(
119745
+ `\u274C Cannot update: another tag already uses the name "${collision.name}" (id: ${collision.id}) in this scope. Use merge-tags to combine them instead of renaming.`
119746
+ );
119747
+ }
119748
+ const [updated] = await db.update(schema_exports.tags).set({ name: nextName, projectId: nextProjectId }).where(eq(schema_exports.tags.id, existing.id)).returning(TAG_COLUMNS);
119749
+ if (!updated) return textResponse4(`Failed to update tag ${input.tagId}.`);
119750
+ return textResponse4(
119751
+ `\u2705 **Tag updated**
119752
+
119753
+ ${describeTag(updated)}
119754
+
119755
+ Existing ticket/customer/project/transaction tag relations are preserved.`
119756
+ );
119757
+ }
119758
+ async function handleDeleteTag(input) {
119759
+ if (!input.tagId) return textResponse4("Error: `tagId` is required.");
119760
+ const mode = input.mode ?? "delete_if_unused";
119761
+ const resolved = await resolveTeamId(input.teamId);
119762
+ if (!resolved.ok) return resolved.response;
119763
+ const existing = await loadTagInTeam(input.tagId, resolved.teamId);
119764
+ if (!existing) {
119765
+ return textResponse4(
119766
+ `Tag ${input.tagId} not found, or it is not owned by this team.`
119767
+ );
119768
+ }
119769
+ const usage = await getTagUsage(existing.id);
119770
+ const total = totalTagUsage(usage);
119771
+ if (mode === "archive") {
119772
+ return textResponse4(
119773
+ `\u2139\uFE0F Archiving is not supported for team tags: the \`tags\` table has no archived column. ${describeTag(existing)} is used by ${formatTagUsage(usage)}.
119774
+
119775
+ Options: use merge-tags to fold it into another tag, or delete it once it is unused (mode: delete_if_unused).`
119776
+ );
119777
+ }
119778
+ if (total > 0) {
119779
+ return textResponse4(
119780
+ `\u274C Refusing to delete ${describeTag(existing)}: it is still used by ${formatTagUsage(usage)}. Deleting would strip the tag off those entities.
119781
+
119782
+ Use merge-tags to move usage onto another tag first, then delete the (now-empty) tag.`
119783
+ );
119784
+ }
119785
+ await db.delete(schema_exports.tags).where(eq(schema_exports.tags.id, existing.id));
119786
+ return textResponse4(
119787
+ `\u2705 **Tag deleted** (was unused): ${describeTag(existing)}`
119788
+ );
119789
+ }
119790
+ async function resolveMergeTarget(teamId, input) {
119791
+ if (input.targetTagId) {
119792
+ const tag = await loadTagInTeam(input.targetTagId, teamId);
119793
+ if (!tag) {
119794
+ return {
119795
+ ok: false,
119796
+ response: textResponse4(
119797
+ `Target tag ${input.targetTagId} not found, or it is not owned by this team.`
119798
+ )
119799
+ };
119800
+ }
119801
+ return { ok: true, tag, created: false };
119802
+ }
119803
+ if (!isValidTagName(input.targetName)) {
119804
+ return {
119805
+ ok: false,
119806
+ response: textResponse4(
119807
+ "Error: provide either `targetTagId` or a non-empty `targetName`."
119808
+ )
119809
+ };
119810
+ }
119811
+ const normalized = normalizeTagName(input.targetName);
119812
+ const matches = await db.select(TAG_COLUMNS).from(schema_exports.tags).where(
119813
+ and(
119814
+ eq(schema_exports.tags.teamId, teamId),
119815
+ sql`lower(${schema_exports.tags.name}) = ${normalized}`
119816
+ )
119817
+ );
119818
+ if (matches.length > 0) {
119819
+ const general = matches.find((t8) => t8.projectId === null);
119820
+ return { ok: true, tag: general ?? matches[0], created: false };
119821
+ }
119822
+ const [created] = await db.insert(schema_exports.tags).values({ teamId, name: input.targetName.trim(), projectId: null }).returning(TAG_COLUMNS);
119823
+ if (!created) {
119824
+ return { ok: false, response: textResponse4("Failed to create target tag.") };
119825
+ }
119826
+ return { ok: true, tag: created, created: true };
119827
+ }
119828
+ async function handleMergeTags(input) {
119829
+ const rawSourceIds = [...new Set(input.sourceTagIds ?? [])].filter(Boolean);
119830
+ if (rawSourceIds.length === 0) {
119831
+ return textResponse4("Error: `sourceTagIds` must contain at least one tag id.");
119832
+ }
119833
+ const resolved = await resolveTeamId(input.teamId);
119834
+ if (!resolved.ok) return resolved.response;
119835
+ const accessibleTeamIds = await getAccessibleTeamIds(resolved.teamId);
119836
+ const sourceTags = await db.select(TAG_COLUMNS).from(schema_exports.tags).where(
119837
+ and(
119838
+ inArray(schema_exports.tags.id, rawSourceIds),
119839
+ inArray(schema_exports.tags.teamId, accessibleTeamIds)
119840
+ )
119841
+ );
119842
+ const foundIds = new Set(sourceTags.map((t8) => t8.id));
119843
+ const missing = rawSourceIds.filter((id) => !foundIds.has(id));
119844
+ if (missing.length > 0) {
119845
+ return textResponse4(
119846
+ `Error: source tag(s) not found or not owned by this team: ${missing.join(", ")}.`
119847
+ );
119848
+ }
119849
+ const target = await resolveMergeTarget(resolved.teamId, input);
119850
+ if (!target.ok) return target.response;
119851
+ const sourcesToMerge = sourceTags.filter((t8) => t8.id !== target.tag.id);
119852
+ if (sourcesToMerge.length === 0) {
119853
+ return textResponse4(
119854
+ "Error: nothing to merge \u2014 the only source tag is the same as the target tag."
119855
+ );
119856
+ }
119857
+ const sourceIds = sourcesToMerge.map((t8) => t8.id);
119858
+ const targetId = target.tag.id;
119859
+ const deleteSources = input.deleteSources ?? true;
119860
+ const results = await db.transaction(async (tx) => {
119861
+ const ticketSrc = await tx.select({
119862
+ entityId: schema_exports.ticketTags.ticketId,
119863
+ teamId: schema_exports.ticketTags.teamId
119864
+ }).from(schema_exports.ticketTags).where(inArray(schema_exports.ticketTags.tagId, sourceIds));
119865
+ const ticketTgt = await tx.select({ entityId: schema_exports.ticketTags.ticketId }).from(schema_exports.ticketTags).where(eq(schema_exports.ticketTags.tagId, targetId));
119866
+ const ticketPlan = planRelationMerge(
119867
+ ticketSrc,
119868
+ ticketTgt.map((r6) => r6.entityId)
119869
+ );
119870
+ if (ticketPlan.toInsert.length > 0) {
119871
+ await tx.insert(schema_exports.ticketTags).values(
119872
+ ticketPlan.toInsert.map((r6) => ({
119873
+ ticketId: r6.entityId,
119874
+ tagId: targetId,
119875
+ teamId: r6.teamId
119876
+ }))
119877
+ );
119878
+ }
119879
+ await tx.delete(schema_exports.ticketTags).where(inArray(schema_exports.ticketTags.tagId, sourceIds));
119880
+ const customerSrc = await tx.select({
119881
+ entityId: schema_exports.customerTags.customerId,
119882
+ teamId: schema_exports.customerTags.teamId
119883
+ }).from(schema_exports.customerTags).where(inArray(schema_exports.customerTags.tagId, sourceIds));
119884
+ const customerTgt = await tx.select({ entityId: schema_exports.customerTags.customerId }).from(schema_exports.customerTags).where(eq(schema_exports.customerTags.tagId, targetId));
119885
+ const customerPlan = planRelationMerge(
119886
+ customerSrc,
119887
+ customerTgt.map((r6) => r6.entityId)
119888
+ );
119889
+ if (customerPlan.toInsert.length > 0) {
119890
+ await tx.insert(schema_exports.customerTags).values(
119891
+ customerPlan.toInsert.map((r6) => ({
119892
+ customerId: r6.entityId,
119893
+ tagId: targetId,
119894
+ teamId: r6.teamId
119895
+ }))
119896
+ );
119897
+ }
119898
+ await tx.delete(schema_exports.customerTags).where(inArray(schema_exports.customerTags.tagId, sourceIds));
119899
+ const projectSrc = await tx.select({
119900
+ entityId: schema_exports.projectTags.projectId,
119901
+ teamId: schema_exports.projectTags.teamId
119902
+ }).from(schema_exports.projectTags).where(inArray(schema_exports.projectTags.tagId, sourceIds));
119903
+ const projectTgt = await tx.select({ entityId: schema_exports.projectTags.projectId }).from(schema_exports.projectTags).where(eq(schema_exports.projectTags.tagId, targetId));
119904
+ const projectPlan = planRelationMerge(
119905
+ projectSrc,
119906
+ projectTgt.map((r6) => r6.entityId)
119907
+ );
119908
+ if (projectPlan.toInsert.length > 0) {
119909
+ await tx.insert(schema_exports.projectTags).values(
119910
+ projectPlan.toInsert.map((r6) => ({
119911
+ projectId: r6.entityId,
119912
+ tagId: targetId,
119913
+ teamId: r6.teamId
119914
+ }))
119915
+ );
119916
+ }
119917
+ await tx.delete(schema_exports.projectTags).where(inArray(schema_exports.projectTags.tagId, sourceIds));
119918
+ const txnSrc = await tx.select({
119919
+ entityId: schema_exports.transactionTags.transactionId,
119920
+ teamId: schema_exports.transactionTags.teamId
119921
+ }).from(schema_exports.transactionTags).where(inArray(schema_exports.transactionTags.tagId, sourceIds));
119922
+ const txnTgt = await tx.select({ entityId: schema_exports.transactionTags.transactionId }).from(schema_exports.transactionTags).where(eq(schema_exports.transactionTags.tagId, targetId));
119923
+ const txnPlan = planRelationMerge(
119924
+ txnSrc,
119925
+ txnTgt.map((r6) => r6.entityId)
119926
+ );
119927
+ if (txnPlan.toInsert.length > 0) {
119928
+ await tx.insert(schema_exports.transactionTags).values(
119929
+ txnPlan.toInsert.map((r6) => ({
119930
+ transactionId: r6.entityId,
119931
+ tagId: targetId,
119932
+ teamId: r6.teamId
119933
+ }))
119934
+ );
119935
+ }
119936
+ await tx.delete(schema_exports.transactionTags).where(inArray(schema_exports.transactionTags.tagId, sourceIds));
119937
+ if (deleteSources) {
119938
+ await tx.delete(schema_exports.tags).where(inArray(schema_exports.tags.id, sourceIds));
119939
+ }
119940
+ return {
119941
+ tickets: planToResult(ticketPlan),
119942
+ customers: planToResult(customerPlan),
119943
+ projects: planToResult(projectPlan),
119944
+ transactions: planToResult(txnPlan)
119945
+ };
119946
+ });
119947
+ const movedTotal = results.tickets.moved + results.customers.moved + results.projects.moved + results.transactions.moved;
119948
+ const skippedTotal = results.tickets.skipped + results.customers.skipped + results.projects.skipped + results.transactions.skipped;
119949
+ const line2 = (label, r6) => `- ${label}: ${r6.moved} moved, ${r6.skipped} skipped (duplicate)`;
119950
+ return textResponse4(
119951
+ `\u2705 **Tags merged** into ${describeTag(target.tag)}${target.created ? " (newly created)" : ""}
119952
+
119953
+ Sources (${sourcesToMerge.length}): ${sourcesToMerge.map((t8) => `${t8.name} (${t8.id})`).join(", ")}
119954
+
119955
+ Relations moved (duplicates skipped to keep a single tag per entity):
119956
+ ${line2("Tickets", results.tickets)}
119957
+ ${line2("Customers", results.customers)}
119958
+ ${line2("Projects", results.projects)}
119959
+ ${line2("Transactions", results.transactions)}
119960
+
119961
+ Totals: ${movedTotal} relation(s) moved, ${skippedTotal} duplicate(s) skipped.
119962
+ Source tags ${deleteSources ? "deleted" : "kept (now empty)"}: ${sourcesToMerge.map((t8) => t8.id).join(", ")}.`
119963
+ );
119964
+ }
119965
+ function planToResult(plan) {
119966
+ return {
119967
+ moved: plan.toInsert.length,
119968
+ skipped: plan.skippedDuplicates,
119969
+ total: plan.sourceEntityCount
119970
+ };
119971
+ }
119972
+
119070
119973
  // src/utils/ticket-number.ts
119071
119974
  async function isTicketNumberTaken(ticketDb, teamId, ticketNumber, excludeTicketId) {
119072
119975
  const conditions = [
@@ -119825,7 +120728,7 @@ ${tagErrors.map((e6) => ` \u2022 ${e6}`).join("\n")}
119825
120728
  }
119826
120729
 
119827
120730
  // src/server.ts
119828
- var SERVER_VERSION = "3.4.0";
120731
+ var SERVER_VERSION = "3.5.0";
119829
120732
  function createMcpServer() {
119830
120733
  const server = new Server(
119831
120734
  {
@@ -119873,6 +120776,12 @@ function createMcpServer() {
119873
120776
  return await handleGetTags(asToolArgs(toolArgs));
119874
120777
  case "create-tag":
119875
120778
  return await handleCreateTag(asToolArgs(toolArgs));
120779
+ case "update-tag":
120780
+ return await handleUpdateTag(asToolArgs(toolArgs));
120781
+ case "delete-tag":
120782
+ return await handleDeleteTag(asToolArgs(toolArgs));
120783
+ case "merge-tags":
120784
+ return await handleMergeTags(asToolArgs(toolArgs));
119876
120785
  case "get-calendar-items":
119877
120786
  return await handleGetCalendarItems(
119878
120787
  asToolArgs(toolArgs)
@@ -119911,6 +120820,14 @@ function createMcpServer() {
119911
120820
  return await handleCreateProject(asToolArgs(toolArgs));
119912
120821
  case "update-project":
119913
120822
  return await handleUpdateProject(asToolArgs(toolArgs));
120823
+ case "archive-project":
120824
+ return await handleArchiveProject(
120825
+ asToolArgs(toolArgs)
120826
+ );
120827
+ case "delete-project":
120828
+ return await handleDeleteProject(
120829
+ asToolArgs(toolArgs)
120830
+ );
119914
120831
  case "get-project-members":
119915
120832
  return await handleGetProjectMembers(
119916
120833
  asToolArgs(toolArgs)
@@ -119949,6 +120866,24 @@ function createMcpServer() {
119949
120866
  return await handleLinkDocumentToInvoice(
119950
120867
  asToolArgs(toolArgs)
119951
120868
  );
120869
+ case "get-products":
120870
+ return await handleGetProducts(asToolArgs(toolArgs));
120871
+ case "get-product-by-id":
120872
+ return await handleGetProductById(
120873
+ asToolArgs(toolArgs)
120874
+ );
120875
+ case "create-product":
120876
+ return await handleCreateProduct(
120877
+ asToolArgs(toolArgs)
120878
+ );
120879
+ case "update-product":
120880
+ return await handleUpdateProduct(
120881
+ asToolArgs(toolArgs)
120882
+ );
120883
+ case "archive-product":
120884
+ return await handleArchiveProduct(
120885
+ asToolArgs(toolArgs)
120886
+ );
119952
120887
  case "log-hours":
119953
120888
  return await handleLogHours(asToolArgs(toolArgs));
119954
120889
  case "get-github-file":