Compare commits

...

2 Commits

Author SHA256 Message Date
mkt
a121c97cde Add Tags-filter and phase2 ping-MCP instruction docs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:33:37 +02:00
mkt
e61af83d25 Make custom Tags relation filterable on all four entities
Expose the custom many-to-many CTag "Tags" relation as a linkMultiple
field (cTags) so it can be added to the list-view Search Filters, and
build the full Tags setup on Lead from scratch in parity with the others.

Opportunity / Contact / Account (relation already existed):
- add fields.cTags (linkMultiple) to entityDefs
- add cTags to the Search-Filters layout (Opportunity appended, preserving
  cAccount1; Contact/Account get a full filters.json incl. cTags)

Lead (built from scratch):
- create Lead<->CTag many-to-many relation via EntityManager (relationName
  cLeadTag, join table c_lead_tag); link normalized to cTags to mirror the
  other three (API auto-prefix had produced cCTags)
- add cTags field, de_DE/en_US labels, clientDefs panel polish (create:false,
  unlink-only row actions), bottomPanelsDetail entry, and filters.json
- CTag side: links.interessenten label de "Interessenten" / en "Leads"

Verified via API/DB: cTags linkMultiple present on all four scopes; Lead
link/CTag.interessenten resolve; Any of / All of / None of filters execute
and return correct results end-to-end; existing data intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:31:30 +02:00
18 changed files with 751 additions and 327 deletions

View File

@@ -1,15 +1,17 @@
{
"labels": {
"Create CTag": "Tag erstellen"
},
"fields": {
"verkaufschancen": "Verkaufschancen",
"kontakte": "Kontakte",
"firmen": "Firmen"
},
"links": {
"verkaufschancen": "Verkaufschancen",
"kontakte": "Kontakte",
"firmen": "Firmen"
}
}
"labels": {
"Create CTag": "Tag erstellen"
},
"fields": {
"verkaufschancen": "Verkaufschancen",
"kontakte": "Kontakte",
"firmen": "Firmen",
"interessenten": "Interessenten"
},
"links": {
"verkaufschancen": "Verkaufschancen",
"kontakte": "Kontakte",
"firmen": "Firmen",
"interessenten": "Interessenten"
}
}

View File

@@ -0,0 +1,8 @@
{
"fields": {
"cTags": "Tags"
},
"links": {
"cTags": "Tags"
}
}

View File

@@ -1,15 +1,17 @@
{
"labels": {
"Create CTag": "Create Tag"
},
"fields": {
"verkaufschancen": "Verkaufschancen",
"kontakte": "Kontakte",
"firmen": "Firmen"
},
"links": {
"verkaufschancen": "Verkaufschancen",
"kontakte": "Kontakte",
"firmen": "Firmen"
}
}
"labels": {
"Create CTag": "Create Tag"
},
"fields": {
"verkaufschancen": "Verkaufschancen",
"kontakte": "Kontakte",
"firmen": "Firmen",
"interessenten": "Leads"
},
"links": {
"verkaufschancen": "Verkaufschancen",
"kontakte": "Kontakte",
"firmen": "Firmen",
"interessenten": "Leads"
}
}

View File

@@ -0,0 +1,8 @@
{
"fields": {
"cTags": "Tags"
},
"links": {
"cTags": "Tags"
}
}

View File

@@ -0,0 +1,20 @@
[
"assignedUser",
"teams",
"createdAt",
"createdBy",
"modifiedAt",
"streamUpdatedAt",
"type",
"industry",
"description",
"emailAddress",
"phoneNumber",
"targetLists",
"billingAddressCountry",
"billingAddress",
"shippingAddress",
"website",
"campaign",
"cTags"
]

View File

@@ -0,0 +1,16 @@
[
"assignedUser",
"teams",
"createdAt",
"createdBy",
"modifiedAt",
"streamUpdatedAt",
"accounts",
"address",
"emailAddress",
"phoneNumber",
"title",
"targetLists",
"campaign",
"cTags"
]

View File

@@ -0,0 +1,8 @@
{
"targetLists": {
"index": 5
},
"cTags": {
"index": 6
}
}

View File

@@ -0,0 +1,20 @@
[
"assignedUser",
"teams",
"createdAt",
"createdBy",
"modifiedAt",
"streamUpdatedAt",
"address",
"accountName",
"campaign",
"emailAddress",
"phoneNumber",
"status",
"source",
"opportunityAmountConverted",
"industry",
"targetLists",
"createdAccount",
"cTags"
]

View File

@@ -0,0 +1,20 @@
[
"assignedUser",
"teams",
"createdAt",
"createdBy",
"modifiedAt",
"streamUpdatedAt",
"account",
"cAccount1",
"amountConverted",
"amountWeightedConverted",
"campaign",
"closeDate",
"contacts",
"leadSource",
"probability",
"stage",
"description",
"cTags"
]

View File

@@ -15,6 +15,10 @@
"firmen": {
"layout": null,
"selectPrimaryFilterName": null
},
"interessenten": {
"layout": null,
"selectPrimaryFilterName": null
}
}
}

View File

@@ -0,0 +1,8 @@
{
"relationshipPanels": {
"cTags": {
"create": false,
"rowActionsView": "custom:views/record/row-actions/tags-unlink-only"
}
}
}

View File

@@ -1,19 +1,26 @@
{
"links": {
"cAgentur": {
"type": "hasMany",
"foreign": "cAccount1",
"entity": "Opportunity",
"audited": false,
"isCustom": true
},
"cTags": {
"type": "hasMany",
"relationName": "cAccountTag",
"foreign": "firmen",
"entity": "CTag",
"audited": false,
"isCustom": true
}
"links": {
"cAgentur": {
"type": "hasMany",
"foreign": "cAccount1",
"entity": "Opportunity",
"audited": false,
"isCustom": true
},
"cTags": {
"type": "hasMany",
"relationName": "cAccountTag",
"foreign": "firmen",
"entity": "CTag",
"audited": false,
"isCustom": true
}
}
},
"fields": {
"cTags": {
"type": "linkMultiple",
"view": "views/fields/link-multiple",
"isCustom": true
}
}
}

View File

@@ -1,113 +1,121 @@
{
"fields": {
"name": {
"type": "varchar",
"required": true,
"pattern": "$noBadCharacters"
},
"description": {
"type": "text"
},
"createdAt": {
"type": "datetime",
"readOnly": true
},
"modifiedAt": {
"type": "datetime",
"readOnly": true
},
"createdBy": {
"type": "link",
"readOnly": true,
"view": "views/fields/user"
},
"modifiedBy": {
"type": "link",
"readOnly": true,
"view": "views/fields/user"
},
"assignedUser": {
"type": "link",
"required": false,
"view": "views/fields/assigned-user"
},
"teams": {
"type": "linkMultiple",
"view": "views/fields/teams"
}
"fields": {
"name": {
"type": "varchar",
"required": true,
"pattern": "$noBadCharacters"
},
"links": {
"createdBy": {
"type": "belongsTo",
"entity": "User"
},
"modifiedBy": {
"type": "belongsTo",
"entity": "User"
},
"assignedUser": {
"type": "belongsTo",
"entity": "User"
},
"teams": {
"type": "hasMany",
"entity": "Team",
"relationName": "entityTeam",
"layoutRelationshipsDisabled": true
},
"verkaufschancen": {
"type": "hasMany",
"relationName": "cOpportunityTag",
"foreign": "cTags",
"entity": "Opportunity",
"audited": false,
"isCustom": true
},
"kontakte": {
"type": "hasMany",
"relationName": "cContactTag",
"foreign": "cTags",
"entity": "Contact",
"audited": false,
"isCustom": true
},
"firmen": {
"type": "hasMany",
"relationName": "cAccountTag",
"foreign": "cTags",
"entity": "Account",
"audited": false,
"isCustom": true
}
"description": {
"type": "text"
},
"collection": {
"orderBy": "createdAt",
"order": "desc"
"createdAt": {
"type": "datetime",
"readOnly": true
},
"indexes": {
"name": {
"columns": [
"name",
"deleted"
]
},
"assignedUser": {
"columns": [
"assignedUserId",
"deleted"
]
},
"createdAt": {
"columns": [
"createdAt"
]
},
"createdAtId": {
"unique": true,
"columns": [
"createdAt",
"id"
]
}
"modifiedAt": {
"type": "datetime",
"readOnly": true
},
"createdBy": {
"type": "link",
"readOnly": true,
"view": "views/fields/user"
},
"modifiedBy": {
"type": "link",
"readOnly": true,
"view": "views/fields/user"
},
"assignedUser": {
"type": "link",
"required": false,
"view": "views/fields/assigned-user"
},
"teams": {
"type": "linkMultiple",
"view": "views/fields/teams"
}
}
},
"links": {
"createdBy": {
"type": "belongsTo",
"entity": "User"
},
"modifiedBy": {
"type": "belongsTo",
"entity": "User"
},
"assignedUser": {
"type": "belongsTo",
"entity": "User"
},
"teams": {
"type": "hasMany",
"entity": "Team",
"relationName": "entityTeam",
"layoutRelationshipsDisabled": true
},
"verkaufschancen": {
"type": "hasMany",
"relationName": "cOpportunityTag",
"foreign": "cTags",
"entity": "Opportunity",
"audited": false,
"isCustom": true
},
"kontakte": {
"type": "hasMany",
"relationName": "cContactTag",
"foreign": "cTags",
"entity": "Contact",
"audited": false,
"isCustom": true
},
"firmen": {
"type": "hasMany",
"relationName": "cAccountTag",
"foreign": "cTags",
"entity": "Account",
"audited": false,
"isCustom": true
},
"interessenten": {
"type": "hasMany",
"relationName": "cLeadTag",
"foreign": "cTags",
"entity": "Lead",
"audited": false,
"isCustom": true
}
},
"collection": {
"orderBy": "createdAt",
"order": "desc"
},
"indexes": {
"name": {
"columns": [
"name",
"deleted"
]
},
"assignedUser": {
"columns": [
"assignedUserId",
"deleted"
]
},
"createdAt": {
"columns": [
"createdAt"
]
},
"createdAtId": {
"unique": true,
"columns": [
"createdAt",
"id"
]
}
}
}

View File

@@ -1,12 +1,19 @@
{
"links": {
"cTags": {
"type": "hasMany",
"relationName": "cContactTag",
"foreign": "kontakte",
"entity": "CTag",
"audited": false,
"isCustom": true
}
"links": {
"cTags": {
"type": "hasMany",
"relationName": "cContactTag",
"foreign": "kontakte",
"entity": "CTag",
"audited": false,
"isCustom": true
}
}
},
"fields": {
"cTags": {
"type": "linkMultiple",
"view": "views/fields/link-multiple",
"isCustom": true
}
}
}

View File

@@ -0,0 +1,19 @@
{
"fields": {
"cTags": {
"type": "linkMultiple",
"view": "views/fields/link-multiple",
"isCustom": true
}
},
"links": {
"cTags": {
"type": "hasMany",
"relationName": "cLeadTag",
"foreign": "interessenten",
"entity": "CTag",
"audited": false,
"isCustom": true
}
}
}

View File

@@ -1,167 +1,172 @@
{
"fields": {
"stage": {
"options": [
"Interessant",
"Abgelehnt",
"Angefragt",
"Agentur prüft",
"Kunde prüft",
"Identifiziert",
"Brief versendet",
"Anruf-Wiedervorlage",
"Angerufen",
"Ersttermin vereinbart",
"Ersttermin durchgeführt",
"Angebot",
"Verhandlung",
"Unterschriftsreif",
"Gewonnen",
"In Durchführung",
"Abgeschl. / Bestandsk.",
"Verloren",
"Ghosted"
],
"default": "Interessant",
"probabilityMap": {
"Interessant": 5,
"Abgelehnt": 0,
"Angefragt": 15,
"Agentur prüft": 20,
"Kunde prüft": 30,
"Identifiziert": 5,
"Brief versendet": 8,
"Anruf-Wiedervorlage": 10,
"Angerufen": 12,
"Ersttermin vereinbart": 20,
"Ersttermin durchgeführt": 30,
"Angebot": 40,
"Verhandlung": 60,
"Unterschriftsreif": 80,
"Gewonnen": 100,
"In Durchführung": 100,
"Abgeschl. / Bestandsk.": 100,
"Verloren": 0,
"Ghosted": 0
},
"style": {
"Interessant": null,
"Abgelehnt": "info",
"Angefragt": null,
"Agentur prüft": null,
"Kunde prüft": null,
"Identifiziert": null,
"Brief versendet": "warning",
"Anruf-Wiedervorlage": "warning",
"Angerufen": "warning",
"Ersttermin vereinbart": "warning",
"Ersttermin durchgeführt": "warning",
"Angebot": null,
"Verhandlung": null,
"Unterschriftsreif": null,
"Gewonnen": "success",
"In Durchführung": "primary",
"Abgeschl. / Bestandsk.": "info",
"Verloren": "danger",
"Ghosted": "info"
}
},
"cLeadQuelle": {
"type": "enum",
"required": true,
"options": [
"Inbound Agentur",
"Inbound Plattform-Lead",
"Aktive Plattform-Suche",
"Aktive Akquise",
"Empfehlung / Netzwerk",
"Sonstiges"
],
"style": {
"Inbound Agentur": null,
"Inbound Plattform-Lead": null,
"Aktive Plattform-Suche": null,
"Aktive Akquise": null,
"Empfehlung / Netzwerk": null,
"Sonstiges": null
},
"default": "Inbound Agentur",
"maxLength": 100,
"isCustom": true,
"displayAsLabel": true
},
"cVerlustgrund": {
"type": "enum",
"options": [
"Agentur Absage",
"Kunde Absage",
"Keine Einigung",
"Eigene Absage",
"Sonstiges",
"Agentur Ghosted",
"Kunde Ghosted"
],
"style": {
"Agentur Absage": null,
"Kunde Absage": null,
"Keine Einigung": null,
"Eigene Absage": null,
"Sonstiges": null,
"Agentur Ghosted": null,
"Kunde Ghosted": null
},
"default": "Kunde Absage",
"maxLength": 100,
"isCustom": true
},
"cProjektlink": {
"type": "url",
"isCustom": true
},
"cVerguetungsmodell": {
"type": "enum",
"options": [
"Agentur Honorar",
"Direkt Festpreis",
"Direkt Honorar",
"Direkt Nutzenbasiert"
],
"style": {
"Agentur Honorar": null,
"Direkt Festpreis": null,
"Direkt Honorar": null,
"Direkt Nutzenbasiert": null
},
"default": "Agentur Honorar",
"displayAsLabel": true,
"maxLength": 100,
"isCustom": true
},
"cAccount1": {
"type": "link"
},
"amount": {
"required": false
},
"closeDate": {
"required": false
}
"fields": {
"stage": {
"options": [
"Interessant",
"Abgelehnt",
"Angefragt",
"Agentur prüft",
"Kunde prüft",
"Identifiziert",
"Brief versendet",
"Anruf-Wiedervorlage",
"Angerufen",
"Ersttermin vereinbart",
"Ersttermin durchgeführt",
"Angebot",
"Verhandlung",
"Unterschriftsreif",
"Gewonnen",
"In Durchführung",
"Abgeschl. / Bestandsk.",
"Verloren",
"Ghosted"
],
"default": "Interessant",
"probabilityMap": {
"Interessant": 5,
"Abgelehnt": 0,
"Angefragt": 15,
"Agentur prüft": 20,
"Kunde prüft": 30,
"Identifiziert": 5,
"Brief versendet": 8,
"Anruf-Wiedervorlage": 10,
"Angerufen": 12,
"Ersttermin vereinbart": 20,
"Ersttermin durchgeführt": 30,
"Angebot": 40,
"Verhandlung": 60,
"Unterschriftsreif": 80,
"Gewonnen": 100,
"In Durchführung": 100,
"Abgeschl. / Bestandsk.": 100,
"Verloren": 0,
"Ghosted": 0
},
"style": {
"Interessant": null,
"Abgelehnt": "info",
"Angefragt": null,
"Agentur prüft": null,
"Kunde prüft": null,
"Identifiziert": null,
"Brief versendet": "warning",
"Anruf-Wiedervorlage": "warning",
"Angerufen": "warning",
"Ersttermin vereinbart": "warning",
"Ersttermin durchgeführt": "warning",
"Angebot": null,
"Verhandlung": null,
"Unterschriftsreif": null,
"Gewonnen": "success",
"In Durchführung": "primary",
"Abgeschl. / Bestandsk.": "info",
"Verloren": "danger",
"Ghosted": "info"
}
},
"links": {
"cAccount1": {
"type": "belongsTo",
"foreign": "cAgentur",
"entity": "Account",
"audited": false,
"isCustom": true
},
"cTags": {
"type": "hasMany",
"relationName": "cOpportunityTag",
"foreign": "verkaufschancen",
"entity": "CTag",
"audited": false,
"isCustom": true
}
"cLeadQuelle": {
"type": "enum",
"required": true,
"options": [
"Inbound Agentur",
"Inbound Plattform-Lead",
"Aktive Plattform-Suche",
"Aktive Akquise",
"Empfehlung / Netzwerk",
"Sonstiges"
],
"style": {
"Inbound Agentur": null,
"Inbound Plattform-Lead": null,
"Aktive Plattform-Suche": null,
"Aktive Akquise": null,
"Empfehlung / Netzwerk": null,
"Sonstiges": null
},
"default": "Inbound Agentur",
"maxLength": 100,
"isCustom": true,
"displayAsLabel": true
},
"cVerlustgrund": {
"type": "enum",
"options": [
"Agentur Absage",
"Kunde Absage",
"Keine Einigung",
"Eigene Absage",
"Sonstiges",
"Agentur Ghosted",
"Kunde Ghosted"
],
"style": {
"Agentur Absage": null,
"Kunde Absage": null,
"Keine Einigung": null,
"Eigene Absage": null,
"Sonstiges": null,
"Agentur Ghosted": null,
"Kunde Ghosted": null
},
"default": "Kunde Absage",
"maxLength": 100,
"isCustom": true
},
"cProjektlink": {
"type": "url",
"isCustom": true
},
"cVerguetungsmodell": {
"type": "enum",
"options": [
"Agentur Honorar",
"Direkt Festpreis",
"Direkt Honorar",
"Direkt Nutzenbasiert"
],
"style": {
"Agentur Honorar": null,
"Direkt Festpreis": null,
"Direkt Honorar": null,
"Direkt Nutzenbasiert": null
},
"default": "Agentur Honorar",
"displayAsLabel": true,
"maxLength": 100,
"isCustom": true
},
"cAccount1": {
"type": "link"
},
"amount": {
"required": false
},
"closeDate": {
"required": false
},
"cTags": {
"type": "linkMultiple",
"view": "views/fields/link-multiple",
"isCustom": true
}
}
},
"links": {
"cAccount1": {
"type": "belongsTo",
"foreign": "cAgentur",
"entity": "Account",
"audited": false,
"isCustom": true
},
"cTags": {
"type": "hasMany",
"relationName": "cOpportunityTag",
"foreign": "verkaufschancen",
"entity": "CTag",
"audited": false,
"isCustom": true
}
}
}

View File

@@ -0,0 +1,97 @@
# EspoCRM 9.3.8 — Make the custom "Tags" relation filterable on all four entities
Self-contained instruction for Claude Code on the EspoCRM host (sv006). It assumes nothing about any other repository or plan.
## Goal
This EspoCRM instance has a custom many-to-many "Tags" relation to a custom tag entity **`CTag`**. It currently exists on **Opportunity**, **Contact**, and **Account** (as a relationship / bottom panel only — *not* as a field), and **does not exist at all on `Lead`** yet.
Two things to achieve:
1. **On Opportunity, Contact, Account:** expose the existing relation as a **`linkMultiple` field** named `cTags`, so it can be added to the "Search Filters" layout (the list-view filter dropdown, the "three dots", only offers *fields*).
2. **On Lead (Interessent):** build the **full** Tags setup from scratch in parity with the other three — create the relation, then add the field, label, bottom panel, the same panel polish, and the search filter.
End state: on all four entities you can tag records and filter the list by tags (Any of / All of / None of).
**Constraints:** For the existing three this must **not** create a new relationship or change data — only add a field bound (by name) to the relation that already exists. linkMultiple fields are `notCreatable` in the Field Manager, so the field is added via custom metadata + rebuild. For Lead, the relation *is* created (a new join table is expected). Mirror the existing three exactly.
## Verified facts (from live `/api/v1/Metadata`)
The relation is a symmetric custom many-to-many (each side `type: hasMany` with `relationName`, `isCustom: true`):
| Scope | owner link | foreign entity | foreign (reverse) link | relationName | status |
|---|---|---|---|---|---|
| Opportunity | `cTags` | `CTag` | `verkaufschancen` | `cOpportunityTag` | relation exists; field missing |
| Contact | `cTags` | `CTag` | `kontakte` | `cContactTag` | relation exists; field missing |
| Account | `cTags` | `CTag` | `firmen` | `cAccountTag` | relation exists; field missing |
| **Lead** | `cTags` | `CTag` | `interessenten` | `cLeadTag` | **relation does NOT exist — must be created** |
Reference configs from the existing three (to mirror for Lead):
- A known-good linkMultiple field: `Opportunity.fields.teams = { "type": "linkMultiple", "view": "views/fields/teams" }`.
- `clientDefs.<Scope>.relationshipPanels.cTags = { "create": false, "rowActionsView": "custom:views/record/row-actions/tags-unlink-only" }` (identical on Opportunity/Contact/Account). The referenced row-actions view already exists in this instance.
Also note: the custom link field **`cAccount1`** ("Über Agentur") was added to **Opportunity's** Search-Filters layout earlier today via the layout API — preserve it.
## Part A — Opportunity, Contact, Account (relation already exists)
For each of the three scopes:
**A1. Field def** — merge into `custom/Espo/Custom/Resources/metadata/entityDefs/<Scope>.json`, key `fields`:
```json
"cTags": { "type": "linkMultiple", "view": "views/fields/link-multiple", "isCustom": true }
```
Binds to the existing `cTags` link by name. Do **not** add `relationName`/`midKeys`/`entity` to the field.
**A2. Label** — merge `fields.cTags = "Tags"` into `custom/Espo/Custom/Resources/i18n/de_DE/<Scope>.json` and `.../en_US/<Scope>.json`:
```json
{ "fields": { "cTags": "Tags" } }
```
**A3. Search-Filters layout**`custom/Espo/Custom/Resources/layouts/<Scope>/filters.json` is a flat JSON array of field names. **Read the current file and append `cTags`** (e.g. after `teams`); do not remove anything.
- For **Opportunity** specifically: ensure the array also still contains `cAccount1` (added today); if absent, insert it right after `account`.
## Part B — Lead / Interessent (build full parity)
`Lead` is enabled (`scopes.Lead.disabled = false`, customizable). It has no Tags relation today. Replicate the exact setup the other three have.
**B1. Create the relationship.** Create a many-to-many relation between `Lead` and `CTag` using the same mechanism the existing tag relations were created with (Entity Manager / relationship creation), so the join table and mid keys are generated correctly — do not hand-roll if the proper tool is available. Target names (mirror the pattern):
- Lead-side link: `cTags` (entity `CTag`)
- CTag-side reverse link: `interessenten` (entity `Lead`)
- relationName: `cLeadTag`
- A new join table (e.g. `c_lead_tag`) is expected to be created on rebuild.
**B2. Field** — add the same field def as A1 to `Lead` entityDefs.
**B3. Labels** — mirror the others:
- `Lead` i18n (de_DE + en_US): `fields.cTags = "Tags"` and `links.cTags = "Tags"`.
- `CTag` i18n (de_DE + en_US): `links.interessenten = "Interessenten"` (en: "Leads").
**B4. Panel polish** — set in `custom/Espo/Custom/Resources/metadata/clientDefs/Lead.json`:
```json
"relationshipPanels": { "cTags": { "create": false, "rowActionsView": "custom:views/record/row-actions/tags-unlink-only" } }
```
(identical to the other three; the view already exists).
**B5. Tags bottom panel visible on Lead detail** — ensure the Tags panel appears on the Lead detail view exactly as on Opportunity/Contact/Account. Inspect how the panel is enabled on those three and reproduce it for Lead; verify visually in the UI.
**B6. Search-Filters layout** — append `cTags` to `custom/Espo/Custom/Resources/layouts/Lead/filters.json` (read + append).
## Rebuild
Run EspoCRM's rebuild so the new field(s) and (for Lead) the relation become active (e.g. `php command.php rebuild` inside the app container). For A this is metadata/ORM-cache only (relation tables already exist); for B the rebuild creates the new `c_lead_tag` join table.
## Verify
- `GET /api/v1/Metadata` (admin): `entityDefs.<Scope>.fields.cTags` exists with `type: "linkMultiple"` for **all four** scopes; `entityDefs.Lead.links.cTags` and `entityDefs.CTag.links.interessenten` exist.
- UI, each of the four lists: filter ("three dots") → **"Tags"** appears; choosing it offers **Any of / All of / None of** with a tag autocomplete; filtering returns correct records.
- The **Tags bottom panel** works on all four detail views (incl. Lead); the "+"/inline-create is gone and the row menu only offers "remove link" (parity).
- No data changed on the existing three; **`cAccount1` ("Über Agentur")** still present in the Opportunity filter list.
## After applying
GUI/metadata customization → run **`snapshot_espocrm_custom.sh`** (+ commit in the `bin` repo) so the next `create_pod_espocrm.sh` run does not revert it. This single snapshot also captures today's earlier `cAccount1` filter-layout change.
## Notes / limits (context, no action)
- linkMultiple filter operators: `anyOf` (linkedWith), `allOf` (linkedWithAll = AND), `noneOf` (notLinkedWith), `isEmpty`, `isNotEmpty`.
- *Include one tag AND exclude another in the same view* (e.g. "has `Sommerkampagne` but not `15 % Preisnachlass`") is **not** possible with the standard field filter (one filter per field, one operator). That needs a separate named preset filter and is out of scope here.

View File

@@ -0,0 +1,165 @@
# Claude Code task — minimal "ping" MCP server on sv006 (reachability test)
**Audience:** Claude Code, run by user `mkt` on host **sv006**.
**Goal:** Stand up a tiny, self-contained Model Context Protocol (MCP) server that exposes a single `ping` tool over the **Streamable HTTP** transport. It is a throwaway reachability probe — it does **not** talk to EspoCRM and needs no secrets. A separate reverse-proxy route will publish it as `https://mcp-crm.creature-go.com/mcp`; you only build the local container.
This is intentionally a stand-in. The port and container layout are chosen so the public hostname and proxy route can be reused **unchanged** for the real CRM-MCP server later.
## Fixed parameters (do not change — other components depend on them)
| Parameter | Value |
|---|---|
| Host loopback bind | `127.0.0.1:8095` (reserved "MCP port") |
| Container internal port | `8095` |
| MCP endpoint path | `/mcp` (Streamable HTTP, POST) |
| Health path | `/health` (GET, returns `ok`) |
| Container name | `ping_mcp_ctr` |
| Image tag | `ping-mcp:1.0.0` |
| Runtime | rootless Podman, user `mkt` |
The container must publish **only on host loopback** (`127.0.0.1:8095`). The rootless Traefik pod reaches it via the slirp4netns host-loopback address `10.0.2.2:8095` (same mechanism the EspoCRM pod already uses) — do not publish on `0.0.0.0`.
## Step 1 — Create the app directory `~/ping_mcp/`
**`~/ping_mcp/package.json`**
```json
{
"name": "ping-mcp",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.0",
"express": "^4.21.0"
}
}
```
Use the current published versions if these are outdated; the only requirement is a Streamable-HTTP-capable `@modelcontextprotocol/sdk`.
**`~/ping_mcp/server.js`**
```js
import express from "express";
import { randomUUID } from "node:crypto";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
const PORT = 8095;
const app = express();
app.use(express.json());
// Plain HTTP health check (NOT part of MCP) — for humans / curl.
app.get("/health", (_req, res) => res.status(200).send("ok"));
function buildServer() {
const server = new McpServer({ name: "ping-mcp", version: "1.0.0" });
server.tool(
"ping",
"Reachability health-check. Returns pong with a timestamp and a server identity string.",
{},
async () => ({
content: [{
type: "text",
text: `pong @ ${new Date().toISOString()} — ping-mcp on sv006 (nonce ${randomUUID()})`,
}],
}),
);
return server;
}
// Stateless Streamable HTTP: a fresh server + transport per request.
app.post("/mcp", async (req, res) => {
try {
const server = buildServer();
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on("close", () => { transport.close(); server.close(); });
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (err) {
console.error("MCP request error:", err);
if (!res.headersSent) {
res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message: "Internal server error" }, id: null });
}
}
});
// Stateless mode has no server-initiated stream / session teardown.
const notAllowed = (_req, res) =>
res.status(405).json({ jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed (stateless)" }, id: null });
app.get("/mcp", notAllowed);
app.delete("/mcp", notAllowed);
app.listen(PORT, "0.0.0.0", () => console.log(`ping-mcp listening on 0.0.0.0:${PORT} (/mcp, /health)`));
```
> If the installed SDK's API differs (e.g. `registerTool` instead of `tool`, or a changed `StreamableHTTPServerTransport` constructor), adapt the code. The contract that matters: a working **stateless Streamable HTTP** MCP endpoint at `POST /mcp` exposing exactly one tool named `ping`, plus a `GET /health` returning `ok`. Binding `0.0.0.0` inside the container is correct; the container is only published to host loopback.
**`~/ping_mcp/Containerfile`**
```dockerfile
FROM docker.io/library/node:22-bookworm-slim
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
COPY server.js ./
EXPOSE 8095
USER node
CMD ["node", "server.js"]
```
Pin the base image to a concrete patch tag (e.g. `node:22.x.y-bookworm-slim`) consistent with the house pinning discipline used for the EspoCRM/MariaDB images.
## Step 2 — Build and run (rootless Podman, user `mkt`)
```bash
cd ~/ping_mcp
podman build -t ping-mcp:1.0.0 .
podman run -d --name ping_mcp_ctr -p 127.0.0.1:8095:8095 ping-mcp:1.0.0
```
## Step 3 — Autostart via user systemd + linger
```bash
mkdir -p ~/.config/systemd/user
cd ~/.config/systemd/user
podman generate systemd --new --name ping_mcp_ctr --files
systemctl --user daemon-reload
systemctl --user enable --now container-ping_mcp_ctr.service
loginctl enable-linger mkt # likely already enabled for the EspoCRM pod; harmless if so
```
## Step 4 — Local verification (before handing off)
```bash
# 1) Health endpoint
curl -s http://127.0.0.1:8095/health # expect: ok
# 2) MCP initialize handshake (Streamable HTTP requires this Accept header)
curl -s -X POST http://127.0.0.1:8095/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'
# expect: a JSON-RPC result containing serverInfo {name:"ping-mcp", ...} and capabilities.
# 3) tools/list
curl -s -X POST http://127.0.0.1:8095/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
# expect: a tools array containing one tool named "ping".
```
The response body may be framed as `text/event-stream` (a `data:` line) rather than plain JSON — that is expected for Streamable HTTP. As long as the JSON-RPC result is present and well-formed, the server is good.
## Definition of done
- `curl http://127.0.0.1:8095/health` returns `ok`.
- The `initialize` and `tools/list` calls return valid JSON-RPC results, and `tools/list` shows the `ping` tool.
- `systemctl --user status container-ping_mcp_ctr.service` is active and enabled.
- Report back: confirmation of the above, plus the exact base-image tag you pinned.
## Teardown (after the reachability test concludes)
```bash
systemctl --user disable --now container-ping_mcp_ctr.service
rm -f ~/.config/systemd/user/container-ping_mcp_ctr.service && systemctl --user daemon-reload
podman rm -f ping_mcp_ctr
podman rmi ping-mcp:1.0.0
# ~/ping_mcp/ can be kept as a template or removed.
```