Rename the supply agent everywhere it is named
Its Keycloak client, audience scope, client-secret key, workflow names and file names, and the A2A card. Kafka moved first, under its own account and consumer group. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,7 +18,7 @@ spec:
|
|||||||
target:
|
target:
|
||||||
name: n8n-agent-secrets
|
name: n8n-agent-secrets
|
||||||
data:
|
data:
|
||||||
- secretKey: APPROVISIONNEMENT_CLIENT_SECRET
|
- secretKey: SUPPLY_CLIENT_SECRET
|
||||||
remoteRef:
|
remoteRef:
|
||||||
key: keycloak/classic-models-realm
|
key: keycloak/classic-models-realm
|
||||||
property: KC_SECRET_CLASSIC_MODELS_AGENT_APPROVISIONNEMENT
|
property: KC_SECRET_CLASSIC_MODELS_AGENT_SUPPLY
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
# every conversation with it went through `kubectl run curl`. An orchestrator
|
# every conversation with it went through `kubectl run curl`. An orchestrator
|
||||||
# nobody can talk to is not finished.
|
# nobody can talk to is not finished.
|
||||||
#
|
#
|
||||||
# Peer traffic does **not** come through here — approvisionnement calls, and is called by,
|
# Peer traffic does **not** come through here — supply calls, and is called by,
|
||||||
# other agents on their in-cluster Service names. This is the human door.
|
# other agents on their in-cluster Service names. This is the human door.
|
||||||
#
|
#
|
||||||
# ## It is not an open door
|
# ## It is not an open door
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# The A2A facade for Approvisionnement. It lives in the `agents` namespace with
|
# The A2A facade for Supply. It lives in the `agents` namespace with
|
||||||
# the other agents, not with n8n: what it is, is an agent — the workflow behind
|
# the other agents, not with n8n: what it is, is an agent — the workflow behind
|
||||||
# it is an implementation detail its callers never see.
|
# it is an implementation detail its callers never see.
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
@@ -31,7 +31,7 @@ spec:
|
|||||||
containerPort: 8080
|
containerPort: 8080
|
||||||
env:
|
env:
|
||||||
- name: ADAPTER_AGENT_NAME
|
- name: ADAPTER_AGENT_NAME
|
||||||
value: approvisionnement
|
value: supply
|
||||||
- name: ADAPTER_AGENT_DESCRIPTION
|
- name: ADAPTER_AGENT_DESCRIPTION
|
||||||
value: >-
|
value: >-
|
||||||
Supply decisions for the Classic Models catalog. Answers whether a
|
Supply decisions for the Classic Models catalog. Answers whether a
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Approvisionnement \u2014 stock watch",
|
|
||||||
"nodes": [
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"topic": "classic-models.catalog",
|
|
||||||
"groupId": "approvisionnement-stock-watch",
|
|
||||||
"resolveOffset": "onCompletion",
|
|
||||||
"allowedStatuses": [
|
|
||||||
"success"
|
|
||||||
],
|
|
||||||
"options": {
|
|
||||||
"jsonParseMessage": true,
|
|
||||||
"onlyMessage": true,
|
|
||||||
"fromBeginning": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.kafkaTrigger",
|
|
||||||
"typeVersion": 2,
|
|
||||||
"position": [
|
|
||||||
0,
|
|
||||||
0
|
|
||||||
],
|
|
||||||
"id": "kafka-trigger-stock-watch",
|
|
||||||
"name": "Catalog events",
|
|
||||||
"credentials": {
|
|
||||||
"kafka": {
|
|
||||||
"id": "8qNZXLzvdx5rWQ0V",
|
|
||||||
"name": "kafka-classic-models"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"jsCode": "// A stock movement, judged the moment it happens.\n//\n// This is the reactive half of Approvisionnement. The 06:00 run proposes\n// purchase orders on a buying cycle, which is genuinely periodic; noticing that\n// a reference is about to run out is not, and a nightly sweep would report at\n// 06:00 what was true at 14:32 the previous afternoon.\n//\n// What it reacts to is a fact the API published, not a row it went looking for.\nconst THRESHOLD = 50;\nconst COVER = 3; // a proposal covers three times the threshold\nconst STOCK_CHANGED = 'com.sttlab.classicmodels.product.stock_changed.v1';\n\nconst event = $json ?? {};\n\n// The topic carries every catalog event, so most messages are not ours. Saying\n// so explicitly keeps the offset moving: a workflow that threw on an\n// uninteresting event would replay it forever.\nif (event.type !== STOCK_CHANGED) {\n return [{ json: { considered: false, type: event.type ?? 'unreadable' } }];\n}\n\nconst data = event.data ?? {};\nconst before = Number(data.before?.quantityinstock);\nconst after = Number(data.after?.quantityinstock);\nif (!Number.isFinite(before) || !Number.isFinite(after)) {\n return [{ json: {\n considered: false,\n escalate: true,\n reason: `${event.subject}: the movement carried no readable quantities.`,\n }}];\n}\n\n// Only a *crossing* is news. Without this, every further order line on an\n// already-low reference raises the same alarm again, and an agent that repeats\n// itself is one a human learns to ignore.\nconst crossed = before >= THRESHOLD && after < THRESHOLD;\n// A stock-out is a different fact from a low stock, even when the reference was\n// already below the threshold \u2014 nothing can be promised at all.\nconst emptied = after === 0 && before > 0;\n\n// `order:` means demand; anything else is someone correcting the books. Both\n// justify a purchase order, but only demand is a signal about the future.\nconst fromDemand = String(data.reason ?? '').startsWith('order:');\n\nconst store = $getWorkflowStaticData('global');\nstore.watchlist = store.watchlist ?? {};\n\nif (!crossed && !emptied) {\n // Not news, but if this reference is already being watched its figures have\n // just moved. Refresh them rather than leaving a human reading the number it\n // held at the moment of detection, which is the number least likely to still\n // be true.\n const watched = store.watchlist[event.subject];\n if (watched) {\n watched.on_hand = after;\n watched.propose_order = THRESHOLD * COVER - after;\n watched.last_movement_at = event.time ?? new Date().toISOString();\n }\n return [{ json: {\n considered: true,\n detected: false,\n watching: Boolean(watched),\n productcode: event.subject,\n on_hand: after,\n }}];\n}\n\nconst state = { detected: true, escalate: true,\n productcode: event.subject,\n on_hand: after,\n was: before,\n threshold: THRESHOLD,\n stock_out: after === 0,\n driver: fromDemand ? 'demand' : 'correction',\n movement: data.reason ?? null,\n propose_order: THRESHOLD * COVER - after,\n observed_at: event.time ?? new Date().toISOString(),\n // Kept so this detection can be joined to the request that caused it. It is\n // the only link back \u2014 the event deliberately names no actor.\n traceparent: event.traceparent ?? null,\n};\n\nstate.reason = state.stock_out\n ? `${state.productcode} is out of stock (was ${before}). Nothing can be promised.`\n : `${state.productcode} fell from ${before} to ${after}, below the threshold of ${THRESHOLD}.`;\n\n// The watchlist is the destination, and it is a real one: the proposal still\n// has nowhere to be written \u2014 the domain has no purchase order and no supplier\n// entity \u2014 so a detection that only logged would be lost between two nightly\n// runs. Here it survives, keyed by reference so a reference cannot appear twice.\nstore.watchlist[state.productcode] = state;\n\nreturn [{ json: state }];\n"
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.code",
|
|
||||||
"typeVersion": 2,
|
|
||||||
"position": [
|
|
||||||
220,
|
|
||||||
0
|
|
||||||
],
|
|
||||||
"id": "detect-crossing",
|
|
||||||
"name": "Judge the movement"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"httpMethod": [
|
|
||||||
"GET",
|
|
||||||
"POST"
|
|
||||||
],
|
|
||||||
"path": "stock-watchlist",
|
|
||||||
"responseMode": "lastNode",
|
|
||||||
"options": {},
|
|
||||||
"multipleMethods": true
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.webhook",
|
|
||||||
"typeVersion": 2,
|
|
||||||
"position": [
|
|
||||||
0,
|
|
||||||
200
|
|
||||||
],
|
|
||||||
"id": "watchlist-read",
|
|
||||||
"name": "Watchlist",
|
|
||||||
"webhookId": "stock-watchlist-get"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"jsCode": "// What has been detected and not yet dealt with.\n//\n// A detection that goes only into an execution log is one nobody reads. This\n// makes the watchlist readable \u2014 by the periodic run, which can start from what\n// was already noticed rather than rediscovering it, and by a human.\n//\n// GET /webhook/stock-watchlist \u2192 the current list\n// POST /webhook/stock-watchlist {\"clear\": [\"S24_2000\"]} \u2192 drop entries\nconst store = $getWorkflowStaticData('global');\nstore.watchlist = store.watchlist ?? {};\n\n// GET carries no body; the node still runs, and clear is then empty.\nconst body = $json.body ?? {};\nconst clear = Array.isArray(body.clear) ? body.clear : [];\nfor (const code of clear) delete store.watchlist[code];\n\nconst entries = Object.values(store.watchlist);\nreturn [{ json: {\n watching: entries.length,\n cleared: clear,\n // Worst first: an out-of-stock reference is not the same problem as one that\n // merely crossed the threshold.\n entries: entries.sort((a, b) => a.on_hand - b.on_hand),\n}}];\n"
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.code",
|
|
||||||
"typeVersion": 2,
|
|
||||||
"position": [
|
|
||||||
220,
|
|
||||||
200
|
|
||||||
],
|
|
||||||
"id": "watchlist-render",
|
|
||||||
"name": "Render the watchlist"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"connections": {
|
|
||||||
"Catalog events": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Judge the movement",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Watchlist": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Render the watchlist",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Render the watchlist",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"settings": {
|
|
||||||
"executionOrder": "v1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+4
-4
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "Approvisionnement \u2014 check_availability",
|
"name": "Supply \u2014 check_availability",
|
||||||
"nodes": [
|
"nodes": [
|
||||||
{
|
{
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -32,11 +32,11 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "client_id",
|
"name": "client_id",
|
||||||
"value": "classic-models-agent-approvisionnement"
|
"value": "classic-models-agent-supply"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "client_secret",
|
"name": "client_secret",
|
||||||
"value": "={{ $env.APPROVISIONNEMENT_CLIENT_SECRET }}"
|
"value": "={{ $env.SUPPLY_CLIENT_SECRET }}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "subject_token",
|
"name": "subject_token",
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
},
|
},
|
||||||
"sendBody": true,
|
"sendBody": true,
|
||||||
"specifyBody": "json",
|
"specifyBody": "json",
|
||||||
"jsonBody": "{\"jsonrpc\": \"2.0\", \"id\": 1, \"method\": \"initialize\", \"params\": {\"protocolVersion\": \"2025-06-18\", \"capabilities\": {}, \"clientInfo\": {\"name\": \"approvisionnement\", \"version\": \"0.1.0\"}}}",
|
"jsonBody": "{\"jsonrpc\": \"2.0\", \"id\": 1, \"method\": \"initialize\", \"params\": {\"protocolVersion\": \"2025-06-18\", \"capabilities\": {}, \"clientInfo\": {\"name\": \"supply\", \"version\": \"0.1.0\"}}}",
|
||||||
"options": {
|
"options": {
|
||||||
"response": {
|
"response": {
|
||||||
"response": {
|
"response": {
|
||||||
+4
-4
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "Approvisionnement \u2014 periodic reorder proposal",
|
"name": "Supply \u2014 periodic reorder proposal",
|
||||||
"nodes": [
|
"nodes": [
|
||||||
{
|
{
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -35,11 +35,11 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "client_id",
|
"name": "client_id",
|
||||||
"value": "classic-models-agent-approvisionnement"
|
"value": "classic-models-agent-supply"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "client_secret",
|
"name": "client_secret",
|
||||||
"value": "={{ $env.APPROVISIONNEMENT_CLIENT_SECRET }}"
|
"value": "={{ $env.SUPPLY_CLIENT_SECRET }}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "scope",
|
"name": "scope",
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
},
|
},
|
||||||
"sendBody": true,
|
"sendBody": true,
|
||||||
"specifyBody": "json",
|
"specifyBody": "json",
|
||||||
"jsonBody": "{\"jsonrpc\": \"2.0\", \"id\": 1, \"method\": \"initialize\", \"params\": {\"protocolVersion\": \"2025-06-18\", \"capabilities\": {}, \"clientInfo\": {\"name\": \"approvisionnement-periodic\", \"version\": \"0.1.0\"}}}",
|
"jsonBody": "{\"jsonrpc\": \"2.0\", \"id\": 1, \"method\": \"initialize\", \"params\": {\"protocolVersion\": \"2025-06-18\", \"capabilities\": {}, \"clientInfo\": {\"name\": \"supply-periodic\", \"version\": \"0.1.0\"}}}",
|
||||||
"options": {
|
"options": {
|
||||||
"response": {
|
"response": {
|
||||||
"response": {
|
"response": {
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
{
|
||||||
|
"name": "Supply \u2014 stock watch",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"topic": "classic-models.catalog",
|
||||||
|
"groupId": "supply-stock-watch",
|
||||||
|
"resolveOffset": "onCompletion",
|
||||||
|
"allowedStatuses": [
|
||||||
|
"success"
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"jsonParseMessage": true,
|
||||||
|
"onlyMessage": true,
|
||||||
|
"fromBeginning": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.kafkaTrigger",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"id": "kafka-trigger-stock-watch",
|
||||||
|
"name": "Catalog events",
|
||||||
|
"credentials": {
|
||||||
|
"kafka": {
|
||||||
|
"id": "8qNZXLzvdx5rWQ0V",
|
||||||
|
"name": "kafka-classic-models"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "// A stock movement, judged the moment it happens.\n//\n// This is the reactive half of Supply. The 06:00 run proposes\n// purchase orders on a buying cycle, which is genuinely periodic; noticing that\n// a reference is about to run out is not, and a nightly sweep would report at\n// 06:00 what was true at 14:32 the previous afternoon.\n//\n// What it reacts to is a fact the API published, not a row it went looking for.\nconst THRESHOLD = 50;\nconst COVER = 3; // a proposal covers three times the threshold\nconst STOCK_CHANGED = 'com.sttlab.classicmodels.product.stock_changed.v1';\n\nconst event = $json ?? {};\n\n// The topic carries every catalog event, so most messages are not ours. Saying\n// so explicitly keeps the offset moving: a workflow that threw on an\n// uninteresting event would replay it forever.\nif (event.type !== STOCK_CHANGED) {\n return [{ json: { considered: false, type: event.type ?? 'unreadable' } }];\n}\n\nconst data = event.data ?? {};\nconst before = Number(data.before?.quantityinstock);\nconst after = Number(data.after?.quantityinstock);\nif (!Number.isFinite(before) || !Number.isFinite(after)) {\n return [{ json: {\n considered: false,\n escalate: true,\n reason: `${event.subject}: the movement carried no readable quantities.`,\n }}];\n}\n\n// Only a *crossing* is news. Without this, every further order line on an\n// already-low reference raises the same alarm again, and an agent that repeats\n// itself is one a human learns to ignore.\nconst crossed = before >= THRESHOLD && after < THRESHOLD;\n// A stock-out is a different fact from a low stock, even when the reference was\n// already below the threshold \u2014 nothing can be promised at all.\nconst emptied = after === 0 && before > 0;\n\n// `order:` means demand; anything else is someone correcting the books. Both\n// justify a purchase order, but only demand is a signal about the future.\nconst fromDemand = String(data.reason ?? '').startsWith('order:');\n\nconst store = $getWorkflowStaticData('global');\nstore.watchlist = store.watchlist ?? {};\n\nif (!crossed && !emptied) {\n // Not news, but if this reference is already being watched its figures have\n // just moved. Refresh them rather than leaving a human reading the number it\n // held at the moment of detection, which is the number least likely to still\n // be true.\n const watched = store.watchlist[event.subject];\n if (watched) {\n watched.on_hand = after;\n watched.propose_order = THRESHOLD * COVER - after;\n watched.last_movement_at = event.time ?? new Date().toISOString();\n }\n return [{ json: {\n considered: true,\n detected: false,\n watching: Boolean(watched),\n productcode: event.subject,\n on_hand: after,\n }}];\n}\n\nconst state = { detected: true, escalate: true,\n productcode: event.subject,\n on_hand: after,\n was: before,\n threshold: THRESHOLD,\n stock_out: after === 0,\n driver: fromDemand ? 'demand' : 'correction',\n movement: data.reason ?? null,\n propose_order: THRESHOLD * COVER - after,\n observed_at: event.time ?? new Date().toISOString(),\n // Kept so this detection can be joined to the request that caused it. It is\n // the only link back \u2014 the event deliberately names no actor.\n traceparent: event.traceparent ?? null,\n};\n\nstate.reason = state.stock_out\n ? `${state.productcode} is out of stock (was ${before}). Nothing can be promised.`\n : `${state.productcode} fell from ${before} to ${after}, below the threshold of ${THRESHOLD}.`;\n\n// The watchlist is the destination, and it is a real one: the proposal still\n// has nowhere to be written \u2014 the domain has no purchase order and no supplier\n// entity \u2014 so a detection that only logged would be lost between two nightly\n// runs. Here it survives, keyed by reference so a reference cannot appear twice.\nstore.watchlist[state.productcode] = state;\n\nreturn [{ json: state }];\n"
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
220,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"id": "detect-crossing",
|
||||||
|
"name": "Judge the movement"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": [
|
||||||
|
"GET",
|
||||||
|
"POST"
|
||||||
|
],
|
||||||
|
"path": "stock-watchlist",
|
||||||
|
"responseMode": "lastNode",
|
||||||
|
"options": {},
|
||||||
|
"multipleMethods": true
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
200
|
||||||
|
],
|
||||||
|
"id": "watchlist-read",
|
||||||
|
"name": "Watchlist",
|
||||||
|
"webhookId": "stock-watchlist-get"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "// What has been detected and not yet dealt with.\n//\n// A detection that goes only into an execution log is one nobody reads. This\n// makes the watchlist readable \u2014 by the periodic run, which can start from what\n// was already noticed rather than rediscovering it, and by a human.\n//\n// GET /webhook/stock-watchlist \u2192 the current list\n// POST /webhook/stock-watchlist {\"clear\": [\"S24_2000\"]} \u2192 drop entries\nconst store = $getWorkflowStaticData('global');\nstore.watchlist = store.watchlist ?? {};\n\n// GET carries no body; the node still runs, and clear is then empty.\nconst body = $json.body ?? {};\nconst clear = Array.isArray(body.clear) ? body.clear : [];\nfor (const code of clear) delete store.watchlist[code];\n\nconst entries = Object.values(store.watchlist);\nreturn [{ json: {\n watching: entries.length,\n cleared: clear,\n // Worst first: an out-of-stock reference is not the same problem as one that\n // merely crossed the threshold.\n entries: entries.sort((a, b) => a.on_hand - b.on_hand),\n}}];\n"
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
220,
|
||||||
|
200
|
||||||
|
],
|
||||||
|
"id": "watchlist-render",
|
||||||
|
"name": "Render the watchlist"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Catalog events": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Judge the movement",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Watchlist": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Render the watchlist",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Render the watchlist",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user