@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 +2525 -123
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
6173
|
+
let count2 = 0;
|
|
6174
6174
|
for (const key in schema) {
|
|
6175
6175
|
if (key === "$ref")
|
|
6176
6176
|
return Infinity;
|
|
6177
|
-
|
|
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) =>
|
|
6181
|
+
(0, util_1.eachItem)(schema[key], (sch) => count2 += countKeys(sch));
|
|
6182
6182
|
}
|
|
6183
|
-
if (
|
|
6183
|
+
if (count2 === Infinity)
|
|
6184
6184
|
return Infinity;
|
|
6185
6185
|
}
|
|
6186
|
-
return
|
|
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
|
|
9232
|
-
validateItems(schValid, () => gen.if(schValid, () => checkLimits(
|
|
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(
|
|
9246
|
-
gen.code((0, codegen_1._)`${
|
|
9245
|
+
function checkLimits(count2) {
|
|
9246
|
+
gen.code((0, codegen_1._)`${count2}++`);
|
|
9247
9247
|
if (max === void 0) {
|
|
9248
|
-
gen.if((0, codegen_1._)`${
|
|
9248
|
+
gen.if((0, codegen_1._)`${count2} >= ${min}`, () => gen.assign(valid, true).break());
|
|
9249
9249
|
} else {
|
|
9250
|
-
gen.if((0, codegen_1._)`${
|
|
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._)`${
|
|
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(
|
|
15022
|
-
const absCount = Math.abs(
|
|
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(
|
|
16937
|
-
return Math.abs(
|
|
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(
|
|
19053
|
-
const absCount = Math.abs(
|
|
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,
|
|
59478
|
+
constructor(delay2, count2, cost, longPoll) {
|
|
59479
59479
|
this.delay = delay2;
|
|
59480
|
-
this.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
|
|
62422
|
-
for (; i6 < xmlData.length; i6++,
|
|
62423
|
-
if (xmlData[i6].match(/\w/) &&
|
|
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
|
|
65063
|
-
position +=
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
${
|
|
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,
|
|
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}
|
|
113476
|
+
text: `Found ${rows.length} project(s)${status !== "all" ? ` (status: ${status})` : ""}:
|
|
112600
113477
|
|
|
112601
|
-
${rows.map(
|
|
112602
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 :
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
114157
|
+
return textResponse3(
|
|
113177
114158
|
`No products found${status !== "all" ? ` (status: ${status})` : ""}.`
|
|
113178
114159
|
);
|
|
113179
114160
|
}
|
|
113180
|
-
return
|
|
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
|
|
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
|
|
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
|
|
114182
|
+
return textResponse3(
|
|
113202
114183
|
`Product ${productId} not found or you don't have access to it.`
|
|
113203
114184
|
);
|
|
113204
114185
|
}
|
|
113205
|
-
return
|
|
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
|
|
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
|
|
113235
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
113272
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
113297
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
120424
|
+
return textResponse5("The file is empty (0 bytes); nothing to upload.");
|
|
118936
120425
|
}
|
|
118937
120426
|
if (buffer2.byteLength > MAX_FILE_SIZE) {
|
|
118938
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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":
|