Add Slack customer registration notification functionality

This commit is contained in:
Jeppe Bundgaard
2026-06-08 12:42:03 +02:00
parent bedbf21c29
commit 91d3332d4e
11 changed files with 414 additions and 2 deletions
+102
View File
@@ -12958,6 +12958,54 @@
}
}
},
"/slack/config": {
"get": {
"tags": [
"Config"
],
"summary": "Get Slack config",
"operationId": "getSlackConfig",
"responses": {
"200": {
"description": "Slack configuration retrieved successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SlackConfigListResponse"
}
}
}
}
}
},
"post": {
"tags": [
"Config"
],
"summary": "Update Slack config",
"operationId": "updateSlackConfig",
"requestBody": {
"required": false,
"content": {
"application/json": {
"schema": {}
}
}
},
"responses": {
"200": {
"description": "Slack configuration updated successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ModuleConfigUpdateResponse"
}
}
}
}
}
}
},
"/backups/config": {
"get": {
"tags": [
@@ -15499,6 +15547,39 @@
"value"
]
},
"SlackConfigEntry": {
"type": "object",
"properties": {
"module": {
"type": "string",
"enum": [
"Slack"
]
},
"variable": {
"type": "string",
"enum": [
"customer_registration_webhook_url"
]
},
"type": {
"type": "string",
"enum": [
"string"
]
},
"value": {
"type": "string",
"example": "https://hooks.slack.com/services/..."
}
},
"required": [
"module",
"variable",
"type",
"value"
]
},
"BackupsConfigEntry": {
"type": "object",
"properties": {
@@ -16243,6 +16324,27 @@
}
]
},
"SlackConfigListResponse": {
"allOf": [
{
"$ref": "#/components/schemas/ModuleConfigEnvelopeBase"
},
{
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SlackConfigEntry"
}
}
},
"required": [
"data"
]
}
]
},
"BackupsConfigListResponse": {
"allOf": [
{
+48
View File
@@ -11601,6 +11601,35 @@ paths:
schema:
$ref: '#/components/schemas/ModuleConfigTestResponse'
/slack/config:
get:
tags: [Config]
summary: Get Slack config
operationId: getSlackConfig
responses:
'200':
description: Slack configuration retrieved successfully
content:
application/json:
schema:
$ref: '#/components/schemas/SlackConfigListResponse'
post:
tags: [Config]
summary: Update Slack config
operationId: updateSlackConfig
requestBody:
required: false
content:
application/json:
schema: {}
responses:
'200':
description: Slack configuration updated successfully
content:
application/json:
schema:
$ref: '#/components/schemas/ModuleConfigUpdateResponse'
/backups/config:
get:
tags: [Config]
@@ -15115,6 +15144,17 @@ components:
- type: integer
required: [module, variable, type, value]
SlackConfigEntry:
type: object
properties:
module: { type: string, enum: [Slack] }
variable: { type: string, enum: [customer_registration_webhook_url] }
type: { type: string, enum: [string] }
value:
type: string
example: https://hooks.slack.com/services/...
required: [module, variable, type, value]
BackupsConfigEntry:
type: object
properties:
@@ -15383,6 +15423,14 @@ components:
data: { type: array, items: { $ref: '#/components/schemas/EmailConfigEntry' } }
required: [data]
SlackConfigListResponse:
allOf:
- $ref: '#/components/schemas/ModuleConfigEnvelopeBase'
- type: object
properties:
data: { type: array, items: { $ref: '#/components/schemas/SlackConfigEntry' } }
required: [data]
BackupsConfigListResponse:
allOf:
- $ref: '#/components/schemas/ModuleConfigEnvelopeBase'
File diff suppressed because one or more lines are too long
+47 -1
View File
@@ -6,13 +6,25 @@ use GuzzleHttp\Client;
use interfaces\notification_i;
use objects\departments_o;
use objects\users_o;
use slack\slack_c;
use traits\notification_t;
require_once WD . '/modules/slack/slack_c.php';
class slack implements notification_i
{
use notification_t;
private ?slack_c $config = null;
public function getConfig(): slack_c
{
if ($this->config === null) {
$this->config = new slack_c();
}
return $this->config;
}
/**
* @inheritdoc
@@ -132,4 +144,38 @@ class slack implements notification_i
// Send the message to the slack webhook
self::add_log(self::send_webhook_message($string, $SLACK_DEFAULT_WEBHOOK));
}
}
public function send_customer_registration_notification(int $customer_number): self
{
$webhook = trim((string)$this->getConfig()->customer_registration_webhook_url->getVariableValue());
if ($webhook === '') {
return $this;
}
self::add_log(self::send_webhook_message(
$this->format_customer_registration($customer_number),
$webhook
));
return $this;
}
public function format_customer_registration(int $customer_number): string
{
$customer = (new users_o())->getUserByCustomerNumber($customer_number);
$customerName = $customer->exists()
? $customer->getCustomerName((int)$customer->customer_number->value())
: '';
$customerName = trim((string)$customerName);
if ($customerName === '') {
$customerName = 'Unknown customer';
}
$safeCustomerNumber = (int)$customer_number;
$customerUrl = 'https://truckwash.io/superuser/users?search=' . $safeCustomerNumber;
return "*New customer registered on Truck Wash*\n"
. "Customer: $customerName ($safeCustomerNumber)\n"
. "Open in Superuser: $customerUrl";
}
}
@@ -0,0 +1,29 @@
<?php
namespace slack\config;
use Exception;
use traits\module_config_variable;
class slack_customer_registration_webhook_url_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'Slack',
'customer_registration_webhook_url',
'string',
false,
null,
'Slack webhook URL used for successful customer registration notifications',
'https://hooks.slack.com/services/...',
true,
''
);
}
}
@@ -0,0 +1,25 @@
<?php
namespace slack;
require_once WD . '/modules/slack/config/slack_customer_registration_webhook_url_c.php';
use slack\config\slack_customer_registration_webhook_url_c;
use traits\module_config_t;
class slack_c
{
use module_config_t;
public slack_customer_registration_webhook_url_c $customer_registration_webhook_url;
public function __construct()
{
$this->setupConfig('Slack');
$this->allowUpdate([
slack_customer_registration_webhook_url_c::class,
]);
$this->customer_registration_webhook_url = new slack_customer_registration_webhook_url_c();
}
}
+48
View File
@@ -11601,6 +11601,35 @@ paths:
schema:
$ref: '#/components/schemas/ModuleConfigTestResponse'
/slack/config:
get:
tags: [Config]
summary: Get Slack config
operationId: getSlackConfig
responses:
'200':
description: Slack configuration retrieved successfully
content:
application/json:
schema:
$ref: '#/components/schemas/SlackConfigListResponse'
post:
tags: [Config]
summary: Update Slack config
operationId: updateSlackConfig
requestBody:
required: false
content:
application/json:
schema: {}
responses:
'200':
description: Slack configuration updated successfully
content:
application/json:
schema:
$ref: '#/components/schemas/ModuleConfigUpdateResponse'
/backups/config:
get:
tags: [Config]
@@ -15115,6 +15144,17 @@ components:
- type: integer
required: [module, variable, type, value]
SlackConfigEntry:
type: object
properties:
module: { type: string, enum: [Slack] }
variable: { type: string, enum: [customer_registration_webhook_url] }
type: { type: string, enum: [string] }
value:
type: string
example: https://hooks.slack.com/services/...
required: [module, variable, type, value]
BackupsConfigEntry:
type: object
properties:
@@ -15383,6 +15423,14 @@ components:
data: { type: array, items: { $ref: '#/components/schemas/EmailConfigEntry' } }
required: [data]
SlackConfigListResponse:
allOf:
- $ref: '#/components/schemas/ModuleConfigEnvelopeBase'
- type: object
properties:
data: { type: array, items: { $ref: '#/components/schemas/SlackConfigEntry' } }
required: [data]
BackupsConfigListResponse:
allOf:
- $ref: '#/components/schemas/ModuleConfigEnvelopeBase'
+10
View File
@@ -7,6 +7,7 @@ use classes\economic;
use classes\email;
use classes\release_manager;
use classes\recaptcha;
use classes\slack;
use classes\totp;
use classes\virkdata;
use classes\webauthn;
@@ -819,6 +820,15 @@ class authRoute
'message' => $exception->getMessage(),
]);
}
try {
(new slack())->send_customer_registration_notification($customerNumber);
} catch (Exception $exception) {
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_SLACK_NOTIFICATION_FAILED', [
'customerNumber' => $customerNumber,
'message' => $exception->getMessage(),
]);
}
}
private function logRegisterCvrIssue(string $action, array $context): void
@@ -12,6 +12,7 @@ use classes\n8n;
use classes\recaptcha;
use classes\response;
use classes\router;
use classes\slack;
use classes\stripe;
use classes\weatherapi;
use classes\workfeed;
@@ -180,6 +181,46 @@ class moduleConfigRoute
]
);
/** Slack config > GET */
$this->get('/slack/config', function () {
global $response;
$this->requirePermission('slack_config');
$user = (new authentication())->get_user();
if ($user) {
(new logs_o())->add('slack_config', 'global', 1, $user->id, 'SLACK_CONFIG', 'Successfully fetched Slack config');
$response->success(
(new slack())->getConfig()->getConfigRequest()
);
} else {
(new logs_o())->add('slack_config', 'global', 1, 0, 'SLACK_CONFIG', 'No user found, or invalid session');
$response->error('Invalid session', 400);
}
},
[
'slack_config' => 'Get Slack config'
]
);
/** Slack config > POST */
$this->post('/slack/config', function () {
global $response;
$this->requirePermission('slack_config');
$user = (new authentication())->get_user();
if ($user) {
(new logs_o())->add('slack_config', 'global', 1, $user->id, 'SLACK_CONFIG', 'Successfully updated Slack config');
$response->success(
(new slack())->getConfig()->postConfigRequest()
);
} else {
(new logs_o())->add('slack_config', 'global', 1, 0, 'SLACK_CONFIG', 'No user found, or invalid session');
$response->error('Invalid session', 400);
}
},
[
'slack_config' => 'Update Slack config'
]
);
$this->get('/backups/config', function () {
global $response;
$this->requirePermission('backups_config');
@@ -0,0 +1,34 @@
<?php
it('registers Slack module config endpoints and customer registration webhook config', function (): void {
$routeFile = app_path('routes/moduleConfigRoute.php');
$routeContent = file_get_contents($routeFile);
expect($routeContent)->not->toBeFalse()
->and($routeContent)->toContain('/slack/config')
->and($routeContent)->toContain("requirePermission('slack_config')")
->and($routeContent)->toContain("(new slack())->getConfig()->getConfigRequest()")
->and($routeContent)->toContain("(new slack())->getConfig()->postConfigRequest()");
$moduleContent = file_get_contents(app_path('modules/slack/slack_c.php'));
$variableContent = file_get_contents(app_path('modules/slack/config/slack_customer_registration_webhook_url_c.php'));
$slackClassContent = file_get_contents(app_path('classes/slack.php'));
$authRouteContent = file_get_contents(app_path('routes/authRoute.php'));
$openApiContent = file_get_contents(app_path('openapi.yaml'));
expect($moduleContent)->not->toBeFalse()
->and($moduleContent)->toContain("setupConfig('Slack')")
->and($moduleContent)->toContain('slack_customer_registration_webhook_url_c::class')
->and($variableContent)->not->toBeFalse()
->and($variableContent)->toContain("'customer_registration_webhook_url'")
->and($variableContent)->toContain('Slack webhook URL used for successful customer registration notifications')
->and($slackClassContent)->not->toBeFalse()
->and($slackClassContent)->toContain('send_customer_registration_notification')
->and($slackClassContent)->toContain('format_customer_registration')
->and($authRouteContent)->not->toBeFalse()
->and($authRouteContent)->toContain("AUTH_REGISTER_CVR_SLACK_NOTIFICATION_FAILED")
->and($openApiContent)->not->toBeFalse()
->and($openApiContent)->toContain('/slack/config')
->and($openApiContent)->toContain('SlackConfigListResponse')
->and($openApiContent)->toContain('SlackConfigEntry');
});
@@ -180,6 +180,26 @@ namespace classes {
}
}
class slack
{
public static array $customer_registration_notifications = [];
public static function reset(): void
{
self::$customer_registration_notifications = [];
}
public function send_customer_registration_notification($customer_number): self
{
self::$customer_registration_notifications[] = [
'customer_number' => (int)$customer_number,
];
\objects\users_o::$interaction_log[] = 'slack-customer-registration:' . (int)$customer_number;
return $this;
}
}
class authentication
{
public function get_plate_scanner(): bool
@@ -383,6 +403,7 @@ namespace {
assert_true(count(\classes\economic::$create_calls) === 0, 'Fresh create must not run for local duplicates.');
assert_true(count(\classes\email::$sent) === 0, 'Duplicate registrations must not send welcome emails.');
assert_true(count(\classes\email::$superuser_notifications) === 0, 'Duplicate registrations must not send superuser notifications.');
assert_true(count(\classes\slack::$customer_registration_notifications) === 0, 'Duplicate registrations must not send Slack customer registration notifications.');
},
],
[
@@ -410,6 +431,8 @@ namespace {
assert_true(\classes\email::$sent[1]['email'] === 'test@test.com', 'Recovery should notify the invoice email.');
assert_true(count(\classes\email::$superuser_notifications) === 1, 'Recovery must notify opted-in superusers once.');
assert_true(\classes\email::$superuser_notifications[0]['customer_number'] === 12345678, 'Recovery superuser notification must use the recovered customer number.');
assert_true(count(\classes\slack::$customer_registration_notifications) === 1, 'Recovery must notify Slack once.');
assert_true(\classes\slack::$customer_registration_notifications[0]['customer_number'] === 12345678, 'Recovery Slack notification must use the recovered customer number.');
},
],
[
@@ -429,6 +452,7 @@ namespace {
'assert' => static function (): void {
assert_true(count(\classes\email::$sent) === 0, 'Duplicate recovery attempts must not send welcome emails.');
assert_true(count(\classes\email::$superuser_notifications) === 0, 'Duplicate recovery attempts must not send superuser notifications.');
assert_true(count(\classes\slack::$customer_registration_notifications) === 0, 'Duplicate recovery attempts must not send Slack customer registration notifications.');
},
],
[
@@ -448,6 +472,7 @@ namespace {
assert_true(count(\classes\economic::$create_calls) === 0, 'Conflict on existing mismatched customer must not trigger create.');
assert_true(count(\classes\email::$sent) === 0, 'Conflict on existing mismatched customer must not send welcome emails.');
assert_true(count(\classes\email::$superuser_notifications) === 0, 'Conflict on existing mismatched customer must not send superuser notifications.');
assert_true(count(\classes\slack::$customer_registration_notifications) === 0, 'Conflict on existing mismatched customer must not send Slack customer registration notifications.');
assert_true(count(\objects\logs_o::$entries) === 1, 'Conflict should be logged for manual cleanup.');
},
],
@@ -480,6 +505,8 @@ namespace {
assert_true(\classes\email::$sent[0]['email'] === 'jm@truckwash.dk', 'Fresh registration should notify the internal welcome recipient first.');
assert_true(count(\classes\email::$superuser_notifications) === 1, 'Fresh registration must notify opted-in superusers once.');
assert_true(\classes\email::$superuser_notifications[0]['customer_number'] === 12345678, 'Fresh registration superuser notification must use the created customer number.');
assert_true(count(\classes\slack::$customer_registration_notifications) === 1, 'Fresh registration must notify Slack once.');
assert_true(\classes\slack::$customer_registration_notifications[0]['customer_number'] === 12345678, 'Fresh registration Slack notification must use the created customer number.');
},
],
[
@@ -499,6 +526,7 @@ namespace {
assert_true(count(\objects\users_o::$interaction_log) === 0, 'Create mismatch must not bootstrap the wrong local customer.');
assert_true(count(\classes\email::$sent) === 0, 'Create mismatch must not send welcome emails.');
assert_true(count(\classes\email::$superuser_notifications) === 0, 'Create mismatch must not send superuser notifications.');
assert_true(count(\classes\slack::$customer_registration_notifications) === 0, 'Create mismatch must not send Slack customer registration notifications.');
assert_true(count(\objects\logs_o::$entries) === 1, 'Create mismatch should be logged for manual cleanup.');
},
],
@@ -510,6 +538,7 @@ namespace {
\classes\recaptcha::$mock_valid = true;
\classes\economic::reset();
\classes\email::reset();
\classes\slack::reset();
\classes\virkdata::$mock_name = 'Mock Company';
\classes\virkdata::$mock_address = 'Demo Street 1';
\classes\virkdata::$mock_zipcode = 2630;