diff --git a/samples/EDocument/DirectionsEMEA2025/DirectionsEMEA2025.code-workspace b/samples/EDocument/DirectionsEMEA2025/DirectionsEMEA2025.code-workspace
new file mode 100644
index 00000000..538ad65a
--- /dev/null
+++ b/samples/EDocument/DirectionsEMEA2025/DirectionsEMEA2025.code-workspace
@@ -0,0 +1,21 @@
+{
+ "folders": [
+ {
+ "name": "SimpleJson",
+ "path": "./application/simple_json"
+ },
+ {
+ "name": "Connector",
+ "path": "./application/directions_connector"
+ },
+ {
+ "name": "Server",
+ "path": "./server"
+ },
+ {
+ "name": "Workshop",
+ "path": "workshop"
+ }
+ ],
+ "settings": {}
+}
\ No newline at end of file
diff --git a/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorAuth.Codeunit.al b/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorAuth.Codeunit.al
new file mode 100644
index 00000000..f4f2a1d4
--- /dev/null
+++ b/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorAuth.Codeunit.al
@@ -0,0 +1,128 @@
+// ------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+// ------------------------------------------------------------------------------------------------
+namespace Microsoft.EServices.EDocument.Integration;
+
+using System.Utilities;
+
+///
+/// Helper codeunit for authentication and registration with the Connector API.
+/// Pre-written to save time during the workshop.
+///
+codeunit 50121 "Connector Auth"
+{
+ Access = Internal;
+
+ ///
+ /// Registers a new user with the Connector API and stores the API key.
+ ///
+ procedure RegisterUser(var ConnectorSetup: Record "Connector Connection Setup")
+ var
+ HttpClient: HttpClient;
+ HttpRequest: HttpRequestMessage;
+ HttpResponse: HttpResponseMessage;
+ HttpContent: HttpContent;
+ HttpHeaders: HttpHeaders;
+ JsonObject: JsonObject;
+ JsonToken: JsonToken;
+ ResponseText: Text;
+ RequestBody: Text;
+ begin
+ if ConnectorSetup."API Base URL" = '' then
+ Error('Please specify the API Base URL before registering.');
+
+ if ConnectorSetup."User Name" = '' then
+ Error('Please specify a User Name before registering.');
+
+ // Create request body
+ JsonObject.Add('name', ConnectorSetup."User Name");
+ JsonObject.WriteTo(RequestBody);
+
+ // Prepare HTTP request
+ HttpRequest.Content.WriteFrom(RequestBody);
+ HttpRequest.Content.GetHeaders(HttpHeaders);
+ if HttpHeaders.Contains('Content-Type') then
+ HttpHeaders.Remove('Content-Type');
+ HttpHeaders.Add('Content-Type', 'application/json');
+
+ HttpRequest.Method := 'POST';
+ HttpRequest.SetRequestUri(ConnectorSetup."API Base URL" + 'register');
+
+ // Send request
+ if not HttpClient.Send(HttpRequest, HttpResponse) then
+ Error('Failed to connect to the API server.');
+
+ // Parse response
+ HttpResponse.Content.ReadAs(ResponseText);
+
+ if not HttpResponse.IsSuccessStatusCode() then
+ Error('Registration failed: %1', ResponseText);
+
+ // Extract API key from response
+ if JsonObject.ReadFrom(ResponseText) then begin
+ if JsonObject.Get('key', JsonToken) then begin
+ ConnectorSetup.SetAPIKey(JsonToken.AsValue().AsText());
+ ConnectorSetup.Registered := true;
+ ConnectorSetup.Modify();
+ end else
+ Error('API key not found in response.');
+ end else
+ Error('Invalid response format from API.');
+ end;
+
+ ///
+ /// Tests the connection to the API by calling the /peek endpoint.
+ ///
+ procedure TestConnection(ConnectorSetup: Record "Connector Connection Setup")
+ var
+ HttpClient: HttpClient;
+ HttpRequest: HttpRequestMessage;
+ HttpResponse: HttpResponseMessage;
+ begin
+ if ConnectorSetup."API Base URL" = '' then
+ Error('Please specify the API Base URL.');
+
+ if not ConnectorSetup.Registered then
+ Error('Please register first to get an API key.');
+
+ // Prepare HTTP request
+ HttpRequest.Method := 'GET';
+ HttpRequest.SetRequestUri(ConnectorSetup."API Base URL" + 'peek');
+ AddAuthHeader(HttpRequest, ConnectorSetup);
+
+ // Send request
+ if not HttpClient.Send(HttpRequest, HttpResponse) then
+ Error('Failed to connect to the API server.');
+
+ if not HttpResponse.IsSuccessStatusCode() then
+ Error('Connection test failed with status: %1', HttpResponse.HttpStatusCode());
+ end;
+
+ ///
+ /// Adds the authentication header to an HTTP request.
+ ///
+ procedure AddAuthHeader(var HttpRequest: HttpRequestMessage; ConnectorSetup: Record "Connector Connection Setup")
+ var
+ HttpHeaders: HttpHeaders;
+ begin
+ // Prepare HTTP request
+ HttpRequest.GetHeaders(HttpHeaders);
+ HttpHeaders.Add('X-Service-Key', ConnectorSetup.GetAPIKeyText());
+ end;
+
+ ///
+ /// Gets the connection setup record, ensuring it exists.
+ ///
+ procedure GetConnectionSetup(var ConnectorSetup: Record "Connector Connection Setup")
+ begin
+ if not ConnectorSetup.Get() then
+ Error('Connector is not configured. Please open the Connector Connection Setup page.');
+
+ if ConnectorSetup."API Base URL" = '' then
+ Error('API Base URL is not configured.');
+
+ if not ConnectorSetup.Registered then
+ Error('Not registered with the API. Please register first.');
+ end;
+}
diff --git a/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorConnectionSetup.Page.al b/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorConnectionSetup.Page.al
new file mode 100644
index 00000000..aa002144
--- /dev/null
+++ b/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorConnectionSetup.Page.al
@@ -0,0 +1,136 @@
+// ------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+// ------------------------------------------------------------------------------------------------
+namespace Microsoft.EServices.EDocument.Integration;
+
+///
+/// Setup page for Connector API connection.
+/// Allows users to configure the API URL and register to get an API key.
+///
+page 50122 "Connector Connection Setup"
+{
+ Caption = 'Connector Setup';
+ PageType = Card;
+ SourceTable = "Connector Connection Setup";
+ InsertAllowed = false;
+ DeleteAllowed = false;
+ ApplicationArea = All;
+ UsageCategory = Administration;
+
+ layout
+ {
+ area(content)
+ {
+ group(General)
+ {
+ Caption = 'Connection Settings';
+
+ field("API Base URL"; Rec."API Base URL")
+ {
+ ApplicationArea = All;
+ ToolTip = 'Specifies the base URL for the Connector API server (e.g., https://workshop-server.azurewebsites.net/)';
+ ShowMandatory = true;
+ }
+ field("User Name"; Rec."User Name")
+ {
+ ApplicationArea = All;
+ ToolTip = 'Specifies the user name to register with the API';
+ ShowMandatory = true;
+ }
+ field(Registered; Rec.Registered)
+ {
+ ApplicationArea = All;
+ ToolTip = 'Indicates whether you have successfully registered with the API';
+ Style = Favorable;
+ StyleExpr = Rec.Registered;
+ }
+ field("API Key"; APIKeyText)
+ {
+ ApplicationArea = All;
+ Caption = 'API Key';
+ ToolTip = 'Specifies the API key received after registration';
+
+ trigger OnAssistEdit()
+ var
+ NewAPIKey: Text;
+ begin
+ NewAPIKey := APIKeyText;
+ if NewAPIKey <> '' then begin
+ Rec.SetAPIKey(NewAPIKey);
+ Rec.Modify();
+ CurrPage.Update();
+ end;
+ end;
+ }
+ }
+ }
+ }
+
+ actions
+ {
+ area(Processing)
+ {
+ action(Register)
+ {
+ ApplicationArea = All;
+ Caption = 'Register';
+ ToolTip = 'Register with the Connector API to get an API key';
+ Image = Approve;
+ Promoted = true;
+ PromotedCategory = Process;
+ PromotedOnly = true;
+
+ trigger OnAction()
+ var
+ ConnectorAuth: Codeunit "Connector Auth";
+ begin
+ ConnectorAuth.RegisterUser(Rec);
+ CurrPage.Update();
+ UpdateAPIKeyText();
+ Message('Registration successful! Your API key has been saved.');
+ end;
+ }
+ action(TestConnection)
+ {
+ ApplicationArea = All;
+ Caption = 'Test Connection';
+ ToolTip = 'Test the connection to the Connector API';
+ Image = ValidateEmailLoggingSetup;
+ Promoted = true;
+ PromotedCategory = Process;
+ PromotedOnly = true;
+
+ trigger OnAction()
+ var
+ ConnectorAuth: Codeunit "Connector Auth";
+ begin
+ ConnectorAuth.TestConnection(Rec);
+ Message('Connection test successful!');
+ end;
+ }
+ }
+ }
+
+ trigger OnOpenPage()
+ begin
+ Rec.GetOrCreate();
+ UpdateAPIKeyText();
+ end;
+
+ trigger OnAfterGetCurrRecord()
+ begin
+ UpdateAPIKeyText();
+ end;
+
+ local procedure UpdateAPIKeyText()
+ begin
+ if not IsNullGuid(Rec."API Key") then
+ APIKeyText := Rec.GetAPIKeyText()
+ else
+ APIKeyText := '';
+ end;
+
+ var
+ APIKeyText: Text;
+}
diff --git a/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorConnectionSetup.Table.al b/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorConnectionSetup.Table.al
new file mode 100644
index 00000000..36953bb7
--- /dev/null
+++ b/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorConnectionSetup.Table.al
@@ -0,0 +1,94 @@
+// ------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+// ------------------------------------------------------------------------------------------------
+namespace Microsoft.EServices.EDocument.Integration;
+
+///
+/// Stores connection settings for Connector API.
+/// This is a singleton table that holds the API URL and authentication key.
+///
+table 50122 "Connector Connection Setup"
+{
+ Caption = 'Connector Connection Setup';
+ DataClassification = CustomerContent;
+
+ fields
+ {
+ field(1; "Primary Key"; Code[10])
+ {
+ Caption = 'Primary Key';
+ DataClassification = SystemMetadata;
+ }
+ field(10; "API Base URL"; Text[250])
+ {
+ Caption = 'API Base URL';
+ DataClassification = CustomerContent;
+
+ trigger OnValidate()
+ begin
+ if "API Base URL" <> '' then
+ if not "API Base URL".EndsWith('/') then
+ "API Base URL" := "API Base URL" + '/';
+ end;
+ }
+ field(11; "API Key"; Guid)
+ {
+ Caption = 'API Key';
+ DataClassification = EndUserIdentifiableInformation;
+ }
+ field(20; "User Name"; Text[100])
+ {
+ Caption = 'User Name';
+ DataClassification = EndUserIdentifiableInformation;
+ }
+ field(21; Registered; Boolean)
+ {
+ Caption = 'Registered';
+ DataClassification = CustomerContent;
+ Editable = false;
+ }
+ }
+
+ keys
+ {
+ key(PK; "Primary Key")
+ {
+ Clustered = true;
+ }
+ }
+
+ ///
+ /// Gets or creates the singleton setup record.
+ ///
+ procedure GetOrCreate(): Boolean
+ begin
+ if not Get() then begin
+ Init();
+ "Primary Key" := '';
+ Insert();
+ end;
+ exit(true);
+ end;
+
+ ///
+ /// Sets the API key from text value.
+ ///
+ procedure SetAPIKey(NewAPIKey: Text)
+ var
+ APIKeyGuid: Guid;
+ begin
+ if Evaluate(APIKeyGuid, NewAPIKey) then
+ "API Key" := APIKeyGuid
+ else
+ Error('Invalid API Key format. Must be a valid GUID.');
+ end;
+
+ ///
+ /// Gets the API key as text.
+ ///
+ procedure GetAPIKeyText(): Text
+ begin
+ exit(Format("API Key").Replace('{', '').Replace('}', '').ToLower());
+ end;
+}
diff --git a/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorIntegration.Codeunit.al b/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorIntegration.Codeunit.al
new file mode 100644
index 00000000..1fb5c48f
--- /dev/null
+++ b/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorIntegration.Codeunit.al
@@ -0,0 +1,206 @@
+// ------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+// ------------------------------------------------------------------------------------------------
+namespace Microsoft.EServices.EDocument.Integration;
+
+using Microsoft.eServices.EDocument;
+using Microsoft.eServices.EDocument.Integration.Send;
+using Microsoft.eServices.EDocument.Integration.Receive;
+using Microsoft.eServices.EDocument.Integration.Interfaces;
+using System.Utilities;
+
+///
+/// Implements the IDocumentSender and IDocumentReceiver interfaces for Connector.
+/// This codeunit handles sending and receiving E-Documents via the Connector API.
+///
+codeunit 50123 "Connector Integration" implements IDocumentSender, IDocumentReceiver
+{
+ Access = Internal;
+
+ // ============================================================================
+ // SENDING DOCUMENTS
+ // ============================================================================
+
+ // ============================================================================
+ // TODO: Exercise 2 (10 minutes)
+ // Now send the SimpleJSON to the endpoint, using the Connector API.
+ //
+ // TASK: Do the TODOs in the Send procedure below
+ // ============================================================================
+ procedure Send(var EDocument: Record "E-Document"; var EDocumentService: Record "E-Document Service"; SendContext: Codeunit SendContext)
+ var
+ ConnectorSetup: Record "Connector Connection Setup";
+ ConnectorAuth: Codeunit "Connector Auth";
+ ConnectorRequests: Codeunit "Connector Requests";
+ HttpClient: HttpClient;
+ HttpRequest: HttpRequestMessage;
+ HttpResponse: HttpResponseMessage;
+ TempBlob: Codeunit "Temp Blob";
+ JsonContent, APIEndpoint : Text;
+ begin
+ ConnectorAuth.GetConnectionSetup(ConnectorSetup);
+
+ // TODO: Get temp blob with json from SendContext
+ // - Tips: Use SendContext.GetTempBlob()
+ // - Tips: Use ConnectorRequests.ReadJsonFromBlob(TempBlob) to read json text from blob
+
+
+ TempBlob := SendContext.GetTempBlob();
+ JsonContent := ConnectorRequests.ReadJsonFromBlob(TempBlob);
+ //
+
+ // TODO: Create POST request to 'enqueue' endpoint
+ // - Tips: Add enqueue to the base URL from ConnectorSetup
+
+ //
+ APIEndpoint := ConnectorSetup."API Base URL" + 'enqueue';
+
+ ConnectorRequests.CreatePostRequest(APIEndpoint, JsonContent, HttpRequest);
+ ConnectorAuth.AddAuthHeader(HttpRequest, ConnectorSetup);
+
+ // TODO: Send the HTTP request and handle the response using HttpClient
+
+ if not HttpClient.Send(HttpRequest, HttpResponse) then
+ Error('Failed to connect to the API server.');
+ //
+
+ SendContext.Http().SetHttpRequestMessage(HttpRequest);
+ SendContext.Http().SetHttpResponseMessage(HttpResponse);
+ ConnectorRequests.CheckResponseSuccess(HttpResponse);
+ end;
+
+ // ============================================================================
+ // RECEIVING DOCUMENTS
+ // ============================================================================
+
+ // ============================================================================
+ // TODO: Exercise 3,A (10 minutes)
+ // Receive a list of documents from the Connector API.
+ //
+ // TASK: Do the todos
+ // ============================================================================
+ procedure ReceiveDocuments(var EDocumentService: Record "E-Document Service"; DocumentsMetadata: Codeunit "Temp Blob List"; ReceiveContext: Codeunit ReceiveContext)
+ var
+ ConnectorSetup: Record "Connector Connection Setup";
+ ConnectorAuth: Codeunit "Connector Auth";
+ ConnectorRequests: Codeunit "Connector Requests";
+ HttpClient: HttpClient;
+ HttpRequest: HttpRequestMessage;
+ HttpResponse: HttpResponseMessage;
+ JsonObject: JsonObject;
+ JsonToken: JsonToken;
+ JsonArray: JsonArray;
+ TempBlob: Codeunit "Temp Blob";
+ ResponseText: Text;
+ DocumentJson: Text;
+ APIEndpoint: Text;
+ begin
+ ConnectorAuth.GetConnectionSetup(ConnectorSetup);
+
+
+ // TODO: Create Get request to 'peek' endpoint
+ // - Tips: Add peek to the base URL from ConnectorSetup
+ APIEndpoint := ConnectorSetup."API Base URL" + 'peek';
+
+ //
+
+ ConnectorRequests.CreateGetRequest(APIEndpoint, HttpRequest);
+ ConnectorAuth.AddAuthHeader(HttpRequest, ConnectorSetup);
+
+
+ // TODO: Send the HTTP request and handle the response using HttpClient
+
+ if not HttpClient.Send(HttpRequest, HttpResponse) then
+ Error('Failed to connect to the API server.');
+ //
+
+ ReceiveContext.Http().SetHttpRequestMessage(HttpRequest);
+ ReceiveContext.Http().SetHttpResponseMessage(HttpResponse);
+ ConnectorRequests.CheckResponseSuccess(HttpResponse);
+
+ ResponseText := ConnectorRequests.GetResponseText(HttpResponse);
+
+ if JsonObject.ReadFrom(ResponseText) then begin
+ if JsonObject.Get('items', JsonToken) then begin
+ JsonArray := JsonToken.AsArray();
+ foreach JsonToken in JsonArray do begin
+ Clear(TempBlob);
+ JsonToken.WriteTo(DocumentJson);
+ ConnectorRequests.WriteTextToBlob(DocumentJson, TempBlob);
+
+ // TODO: Add TempBlob to DocumentsMetadata so we can process it later in DownloadDocument
+ DocumentsMetadata.Add(TempBlob);
+ //
+ end;
+ end;
+ end;
+ end;
+
+ // ============================================================================
+ // TODO: Exercise 3.B (5 minutes)
+ // Download a single document from the Connector API (dequeue).
+ //
+ // TASK: Do the todos
+ // ============================================================================
+ procedure DownloadDocument(var EDocument: Record "E-Document"; var EDocumentService: Record "E-Document Service"; DocumentMetadata: codeunit "Temp Blob"; ReceiveContext: Codeunit ReceiveContext)
+ var
+ ConnectorSetup: Record "Connector Connection Setup";
+ ConnectorAuth: Codeunit "Connector Auth";
+ ConnectorRequests: Codeunit "Connector Requests";
+ HttpClient: HttpClient;
+ HttpRequest: HttpRequestMessage;
+ HttpResponse: HttpResponseMessage;
+ JsonObject: JsonObject;
+ JsonToken: JsonToken;
+ TempBlob: Codeunit "Temp Blob";
+ ResponseText: Text;
+ DocumentJson: Text;
+ APIEndpoint: Text;
+ begin
+ ConnectorAuth.GetConnectionSetup(ConnectorSetup);
+
+ // TODO: Create Get request to 'dequeue' endpoint
+ // - Tips: Add dequeue to the base URL from ConnectorSetup
+
+ APIEndpoint := ConnectorSetup."API Base URL" + 'dequeue';
+ //
+
+ ConnectorRequests.CreateGetRequest(APIEndpoint, HttpRequest);
+ ConnectorAuth.AddAuthHeader(HttpRequest, ConnectorSetup);
+
+ // TODO: Send the HTTP request and handle the response using HttpClient
+ //
+ if not HttpClient.Send(HttpRequest, HttpResponse) then
+ Error('Failed to connect to the API server.');
+
+ ReceiveContext.Http().SetHttpRequestMessage(HttpRequest);
+ ReceiveContext.Http().SetHttpResponseMessage(HttpResponse);
+ ConnectorRequests.CheckResponseSuccess(HttpResponse);
+
+ ResponseText := ConnectorRequests.GetResponseText(HttpResponse);
+ if JsonObject.ReadFrom(ResponseText) then begin
+ if JsonObject.Get('document', JsonToken) then begin
+ JsonToken.WriteTo(DocumentJson);
+ TempBlob := ReceiveContext.GetTempBlob();
+ ConnectorRequests.WriteTextToBlob(DocumentJson, TempBlob);
+ end else
+ Error('No document found in response.');
+ end;
+ end;
+
+ // ============================================================================
+ // Event Subscriber - Opens setup page when configuring the service
+ // ============================================================================
+ [EventSubscriber(ObjectType::Page, Page::"E-Document Service", OnBeforeOpenServiceIntegrationSetupPage, '', false, false)]
+ local procedure OnBeforeOpenServiceIntegrationSetupPage(EDocumentService: Record "E-Document Service"; var IsServiceIntegrationSetupRun: Boolean)
+ var
+ ConnectorSetup: Page "Connector Connection Setup";
+ begin
+ if EDocumentService."Service Integration V2" <> EDocumentService."Service Integration V2"::Connector then
+ exit;
+
+ ConnectorSetup.RunModal();
+ IsServiceIntegrationSetupRun := true;
+ end;
+}
diff --git a/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorIntegration.EnumExt.al b/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorIntegration.EnumExt.al
new file mode 100644
index 00000000..5d91f226
--- /dev/null
+++ b/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorIntegration.EnumExt.al
@@ -0,0 +1,22 @@
+// ------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+// ------------------------------------------------------------------------------------------------
+namespace Microsoft.EServices.EDocument.Integration;
+
+using Microsoft.eServices.EDocument.Integration.Interfaces;
+
+///
+/// Extends the Service Integration V2 enum to add Connector integration.
+/// This enum value is used to identify which integration implementation to use.
+///
+enumextension 50124 "Connector Integration" extends "Service Integration"
+{
+ value(50124; "Connector")
+ {
+ Caption = 'Connector';
+ Implementation = IDocumentSender = "Connector Integration",
+ IDocumentReceiver = "Connector Integration";
+ }
+
+}
diff --git a/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorRequests.Codeunit.al b/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorRequests.Codeunit.al
new file mode 100644
index 00000000..d11adc14
--- /dev/null
+++ b/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorRequests.Codeunit.al
@@ -0,0 +1,92 @@
+// ------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+// ------------------------------------------------------------------------------------------------
+namespace Microsoft.EServices.EDocument.Integration;
+
+using System.Utilities;
+
+///
+/// Helper codeunit for building HTTP requests for the Connector API.
+/// Pre-written to save time during the workshop.
+///
+codeunit 50125 "Connector Requests"
+{
+ Access = Internal;
+
+ ///
+ /// Creates an HTTP POST request with JSON content.
+ ///
+ procedure CreatePostRequest(Url: Text; JsonContent: Text; var HttpRequest: HttpRequestMessage)
+ var
+ HttpHeaders: HttpHeaders;
+ HttpContent: HttpContent;
+ begin
+ HttpRequest.Content.WriteFrom(JsonContent);
+ HttpRequest.Content.GetHeaders(HttpHeaders);
+ // Prepare HTTP request
+ if HttpHeaders.Contains('Content-Type') then
+ HttpHeaders.Remove('Content-Type');
+ HttpHeaders.Add('Content-Type', 'application/json');
+
+ HttpRequest.Method := 'POST';
+ HttpRequest.SetRequestUri(Url);
+ end;
+
+ ///
+ /// Creates an HTTP GET request.
+ ///
+ procedure CreateGetRequest(Url: Text; var HttpRequest: HttpRequestMessage)
+ begin
+ HttpRequest.Method := 'GET';
+ HttpRequest.SetRequestUri(Url);
+ end;
+
+ ///
+ /// Reads the response content as text.
+ ///
+ procedure GetResponseText(HttpResponse: HttpResponseMessage): Text
+ var
+ ResponseText: Text;
+ begin
+ HttpResponse.Content.ReadAs(ResponseText);
+ exit(ResponseText);
+ end;
+
+ ///
+ /// Checks if the response is successful and throws an error if not.
+ ///
+ procedure CheckResponseSuccess(HttpResponse: HttpResponseMessage)
+ var
+ ResponseText: Text;
+ begin
+ if not HttpResponse.IsSuccessStatusCode() then begin
+ ResponseText := GetResponseText(HttpResponse);
+ Error('API request failed with status %1: %2', HttpResponse.HttpStatusCode(), ResponseText);
+ end;
+ end;
+
+ ///
+ /// Reads JSON content from a TempBlob.
+ ///
+ procedure ReadJsonFromBlob(var TempBlob: Codeunit "Temp Blob"): Text
+ var
+ InStr: InStream;
+ JsonText: Text;
+ begin
+ TempBlob.CreateInStream(InStr, TextEncoding::UTF8);
+ InStr.ReadText(JsonText);
+ exit(JsonText);
+ end;
+
+ ///
+ /// Writes text content to a TempBlob.
+ ///
+ procedure WriteTextToBlob(TextContent: Text; var TempBlob: Codeunit "Temp Blob")
+ var
+ OutStr: OutStream;
+ begin
+ TempBlob.CreateOutStream(OutStr, TextEncoding::UTF8);
+ OutStr.WriteText(TextContent);
+ end;
+}
diff --git a/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorTests.Codeunit.al b/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorTests.Codeunit.al
new file mode 100644
index 00000000..21d6c39b
--- /dev/null
+++ b/samples/EDocument/DirectionsEMEA2025/application/directions_connector/ConnectorTests.Codeunit.al
@@ -0,0 +1,239 @@
+// ------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+// ------------------------------------------------------------------------------------------------
+namespace Microsoft.EServices.EDocument.Format;
+
+using System.TestLibraries.Utilities;
+using Microsoft.eServices.EDocument;
+using Microsoft.EServices.EDocument.Integration;
+using Microsoft.eServices.EDocument.Integration.Send;
+using Microsoft.eServices.EDocument.Integration.Receive;
+using System.Utilities;
+
+///
+/// Test suite for Connector Integration exercises.
+/// Tests verify that the three main exercises are implemented correctly:
+/// - Exercise 2.A: Send (enqueue documents)
+/// - Exercise 2.B: ReceiveDocuments (peek at documents)
+/// - Exercise 2.C: DownloadDocument (dequeue documents)
+///
+codeunit 50129 "Connector Tests"
+{
+ Subtype = Test;
+
+ var
+ Assert: Codeunit "Library Assert";
+ LibraryLowerPermission: Codeunit "Library - Lower Permissions";
+ TestCustomerNo: Code[20];
+ TestVendorNo: Code[20];
+
+ local procedure Initialize()
+ begin
+ LibraryLowerPermission.SetOutsideO365Scope();
+ TestCustomerNo := 'CUST1';
+ TestVendorNo := 'VEND1';
+ end;
+
+ // ============================================================================
+ // EXERCISE 2.A: Test Send (Enqueue) functionality
+ // Verifies that the Send procedure correctly:
+ // - Gets the TempBlob from SendContext
+ // - Reads JSON content from the blob
+ // - Creates POST request to /enqueue endpoint
+ // - Sends the request using HttpClient
+ // ============================================================================
+ [Test]
+ procedure TestExercise2A_Send()
+ var
+ EDocument: Record "E-Document";
+ EDocumentService: Record "E-Document Service";
+ ConnectorIntegration: Codeunit "Connector Integration";
+ SendContext: Codeunit SendContext;
+ TempBlob: Codeunit "Temp Blob";
+ HttpRequest: HttpRequestMessage;
+ HttpResponse: HttpResponseMessage;
+ HttpHeaders: HttpHeaders;
+ JsonText: Text;
+ RequestUri: Text;
+ begin
+ Initialize();
+ SetupConnectionSetup();
+
+ // [GIVEN] A JSON document to send
+ JsonText := GetTestSalesInvoiceJson();
+ WriteJsonToBlob(JsonText, TempBlob);
+ SendContext.SetTempBlob(TempBlob);
+
+ // [WHEN] Calling Send to enqueue the document
+ asserterror ConnectorIntegration.Send(EDocument, EDocumentService, SendContext);
+
+ // [THEN] The HTTP request should be created correctly
+ HttpRequest := SendContext.Http().GetHttpRequestMessage();
+ HttpResponse := SendContext.Http().GetHttpResponseMessage();
+
+ // Verify POST method was used
+ Assert.AreEqual('POST', HttpRequest.Method(), 'Should use POST method');
+
+ // Verify the endpoint is /enqueue
+ RequestUri := HttpRequest.GetRequestUri();
+ Assert.IsTrue(RequestUri.EndsWith('/enqueue'), 'Should call /enqueue endpoint');
+
+ // Verify the request has content
+ HttpRequest.Content.GetHeaders(HttpHeaders);
+ Assert.IsTrue(HttpHeaders.Contains('Content-Type'), 'Should have Content-Type header');
+ end;
+
+ // ============================================================================
+ // EXERCISE 2.B: Test ReceiveDocuments (Peek) functionality
+ // Verifies that the ReceiveDocuments procedure correctly:
+ // - Creates GET request to /peek endpoint
+ // - Sends the request using HttpClient
+ // - Parses the JSON response array
+ // - Adds each document to DocumentsMetadata
+ // ============================================================================
+ [Test]
+ procedure TestExercise2B_ReceiveDocuments()
+ var
+ EDocumentService: Record "E-Document Service";
+ ConnectorIntegration: Codeunit "Connector Integration";
+ ReceiveContext: Codeunit ReceiveContext;
+ DocumentsMetadata: Codeunit "Temp Blob List";
+ HttpRequest: HttpRequestMessage;
+ HttpResponse: HttpResponseMessage;
+ RequestUri: Text;
+ begin
+ Initialize();
+ SetupConnectionSetup();
+
+ // [WHEN] Calling ReceiveDocuments to peek at available documents
+ asserterror ConnectorIntegration.ReceiveDocuments(EDocumentService, DocumentsMetadata, ReceiveContext);
+
+ // [THEN] The HTTP request should be created correctly
+ HttpRequest := ReceiveContext.Http().GetHttpRequestMessage();
+ HttpResponse := ReceiveContext.Http().GetHttpResponseMessage();
+
+ // Verify GET method was used
+ Assert.AreEqual('GET', HttpRequest.Method(), 'Should use GET method');
+
+ // Verify the endpoint is /peek
+ RequestUri := HttpRequest.GetRequestUri();
+ Assert.IsTrue(RequestUri.EndsWith('/peek'), 'Should call /peek endpoint');
+ end;
+
+ // ============================================================================
+ // EXERCISE 2.C: Test DownloadDocument (Dequeue) functionality
+ // Verifies that the DownloadDocument procedure correctly:
+ // - Creates GET request to /dequeue endpoint
+ // - Sends the request using HttpClient
+ // - Parses the document from JSON response
+ // - Writes the document to TempBlob in ReceiveContext
+ // ============================================================================
+ [Test]
+ procedure TestExercise2C_DownloadDocument()
+ var
+ EDocument: Record "E-Document";
+ EDocumentService: Record "E-Document Service";
+ ConnectorIntegration: Codeunit "Connector Integration";
+ ReceiveContext: Codeunit ReceiveContext;
+ DocumentMetadata: Codeunit "Temp Blob";
+ HttpRequest: HttpRequestMessage;
+ HttpResponse: HttpResponseMessage;
+ RequestUri: Text;
+ begin
+ Initialize();
+ SetupConnectionSetup();
+
+ // [WHEN] Calling DownloadDocument to dequeue a document
+ asserterror ConnectorIntegration.DownloadDocument(EDocument, EDocumentService, DocumentMetadata, ReceiveContext);
+
+ // [THEN] The HTTP request should be created correctly
+ HttpRequest := ReceiveContext.Http().GetHttpRequestMessage();
+ HttpResponse := ReceiveContext.Http().GetHttpResponseMessage();
+
+ // Verify GET method was used
+ Assert.AreEqual('GET', HttpRequest.Method(), 'Should use GET method');
+
+ // Verify the endpoint is /dequeue
+ RequestUri := HttpRequest.GetRequestUri();
+ Assert.IsTrue(RequestUri.EndsWith('/dequeue'), 'Should call /dequeue endpoint');
+ end;
+
+ // ============================================================================
+ // HELPER: Verify JSON reading and writing helpers work
+ // ============================================================================
+ [Test]
+ procedure TestJsonHelpers()
+ var
+ ConnectorRequests: Codeunit "Connector Requests";
+ TempBlob: Codeunit "Temp Blob";
+ JsonText: Text;
+ ReadText: Text;
+ begin
+ Initialize();
+ // [GIVEN] Test JSON content
+ JsonText := GetTestSalesInvoiceJson();
+
+ // [WHEN] Writing JSON to blob and reading it back
+ ConnectorRequests.WriteTextToBlob(JsonText, TempBlob);
+ ReadText := ConnectorRequests.ReadJsonFromBlob(TempBlob);
+
+ // [THEN] Content should match
+ Assert.AreEqual(JsonText, ReadText, 'JSON content should be preserved');
+ end;
+
+ // ============================================================================
+ // HELPER METHODS
+ // ============================================================================
+
+ local procedure SetupConnectionSetup()
+ var
+ ConnectorSetup: Record "Connector Connection Setup";
+ begin
+ if not ConnectorSetup.Get() then begin
+ ConnectorSetup.Init();
+ ConnectorSetup."API Base URL" := 'https://test.azurewebsites.net/';
+ ConnectorSetup.SetAPIKey('12345678-1234-1234-1234-123456789abc');
+ ConnectorSetup.Insert();
+ end;
+ end;
+
+ local procedure GetTestSalesInvoiceJson(): Text
+ var
+ JsonObject: JsonObject;
+ LinesArray: JsonArray;
+ LineObject: JsonObject;
+ JsonText: Text;
+ begin
+ // Create test JSON that matches the expected format for sales
+ JsonObject.Add('documentType', 'Invoice');
+ JsonObject.Add('documentNo', 'TEST-SI-001');
+ JsonObject.Add('customerNo', TestCustomerNo);
+ JsonObject.Add('customerName', 'Test Customer');
+ JsonObject.Add('postingDate', Format(Today(), 0, '--'));
+ JsonObject.Add('currencyCode', 'USD');
+ JsonObject.Add('totalAmount', 1000);
+
+ // Add line
+ LineObject.Add('lineNo', 10000);
+ LineObject.Add('type', 'Item');
+ LineObject.Add('no', 'ITEM-001');
+ LineObject.Add('description', 'Test Item');
+ LineObject.Add('quantity', 5);
+ LineObject.Add('unitPrice', 200);
+ LineObject.Add('lineAmount', 1000);
+ LinesArray.Add(LineObject);
+ JsonObject.Add('lines', LinesArray);
+
+ JsonObject.WriteTo(JsonText);
+ exit(JsonText);
+ end;
+
+ local procedure WriteJsonToBlob(JsonText: Text; var TempBlob: Codeunit "Temp Blob")
+ var
+ OutStr: OutStream;
+ begin
+ TempBlob.CreateOutStream(OutStr, TextEncoding::UTF8);
+ OutStr.WriteText(JsonText);
+ end;
+}
diff --git a/samples/EDocument/DirectionsEMEA2025/application/directions_connector/app.json b/samples/EDocument/DirectionsEMEA2025/application/directions_connector/app.json
new file mode 100644
index 00000000..8974ea04
--- /dev/null
+++ b/samples/EDocument/DirectionsEMEA2025/application/directions_connector/app.json
@@ -0,0 +1,48 @@
+{
+ "id": "12345678-0002-0002-0002-000000000002",
+ "name": "Directions Connector",
+ "publisher": "Directions EMEA Workshop",
+ "version": "1.0.0.0",
+ "application": "27.1.0.0",
+ "brief": "Connector integration for E-Document workshop",
+ "description": "Workshop extension that implements the E-Document integration interfaces to send/receive documents via HTTP API",
+ "privacyStatement": "https://privacy.microsoft.com",
+ "EULA": "https://www.microsoft.com/en-us/legal/intellectualproperty/copyright",
+ "help": "https://learn.microsoft.com/dynamics365/business-central/",
+ "url": "https://github.com/microsoft/BCTech",
+ "logo": "",
+ "dependencies": [
+ {
+ "id": "e1d97edc-c239-46b4-8d84-6368bdf67c8b",
+ "name": "E-Document Core",
+ "publisher": "Microsoft",
+ "version": "27.1.0.0"
+ },
+ {
+ "id": "dd0be2ea-f733-4d65-bb34-a28f4624fb14",
+ "name": "Library Assert",
+ "publisher": "Microsoft",
+ "version": "27.1.0.0"
+ },
+ {
+ "id": "5d86850b-0d76-4eca-bd7b-951ad998e997",
+ "name": "Tests-TestLibraries",
+ "publisher": "Microsoft",
+ "version": "27.1.0.0"
+ }
+ ],
+ "screenshots": [],
+ "platform": "27.0.0.0",
+ "idRanges": [
+ {
+ "from": 50121,
+ "to": 50140
+ }
+ ],
+ "features": [
+ "NoImplicitWith",
+ "TranslationFile"
+ ],
+ "target": "Cloud",
+ "runtime": "16.0"
+}
diff --git a/samples/EDocument/DirectionsEMEA2025/application/simple_json/SimpleJsonFormat.Codeunit.al b/samples/EDocument/DirectionsEMEA2025/application/simple_json/SimpleJsonFormat.Codeunit.al
new file mode 100644
index 00000000..b0ba7bfa
--- /dev/null
+++ b/samples/EDocument/DirectionsEMEA2025/application/simple_json/SimpleJsonFormat.Codeunit.al
@@ -0,0 +1,235 @@
+// ------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+// ------------------------------------------------------------------------------------------------
+namespace Microsoft.EServices.EDocument.Format;
+
+using Microsoft.eServices.EDocument;
+using Microsoft.Sales.History;
+using Microsoft.Purchases.Document;
+using System.Utilities;
+
+///
+/// Simple JSON Format
+///
+codeunit 50102 "SimpleJson Format" implements "E-Document"
+{
+ Access = Internal;
+
+ // ============================================================================
+ // OUTGOING DOCUMENTS
+ // Exercise 1
+ // Validate that required fields are filled before creating the document.
+ // ============================================================================
+
+ procedure Check(var SourceDocumentHeader: RecordRef; EDocumentService: Record "E-Document Service"; EDocumentProcessingPhase: Enum "E-Document Processing Phase")
+ var
+ SalesInvoiceHeader: Record "Sales Invoice Header";
+ begin
+ case SourceDocumentHeader.Number of
+ Database::"Sales Invoice Header":
+ begin
+ // Validation complete
+ SourceDocumentHeader.Field(SalesInvoiceHeader.FieldNo("Sell-to Customer No.")).TestField();
+
+ // TODO: Exercise 1.A: Validate Posting Date
+ SourceDocumentHeader.Field(SalesInvoiceHeader.FieldNo("Posting Date")).TestField();
+ end;
+ end;
+ end;
+
+ procedure Create(EDocumentService: Record "E-Document Service"; var EDocument: Record "E-Document"; var SourceDocumentHeader: RecordRef; var SourceDocumentLines: RecordRef; var TempBlob: Codeunit "Temp Blob")
+ var
+ OutStr: OutStream;
+ begin
+ TempBlob.CreateOutStream(OutStr, TextEncoding::UTF8);
+
+ case EDocument."Document Type" of
+ EDocument."Document Type"::"Sales Invoice":
+ CreateSalesInvoiceJson(SourceDocumentHeader, SourceDocumentLines, OutStr);
+ else
+ Error('Document type %1 is not supported', EDocument."Document Type");
+ end;
+ end;
+
+ local procedure CreateSalesInvoiceJson(var SourceDocumentHeader: RecordRef; var SourceDocumentLines: RecordRef; var OutStr: OutStream)
+ var
+ SalesInvoiceHeader: Record "Sales Invoice Header";
+ SalesInvoiceLine: Record "Sales Invoice Line";
+ RootObject: JsonObject;
+ LinesArray: JsonArray;
+ LineObject: JsonObject;
+ JsonText: Text;
+ begin
+ // Get the actual records from RecordRef
+ SourceDocumentHeader.SetTable(SalesInvoiceHeader);
+ SourceDocumentLines.SetTable(SalesInvoiceLine);
+
+ // Fields
+ RootObject.Add('documentType', 'Invoice');
+ RootObject.Add('documentNo', SalesInvoiceHeader."No.");
+ RootObject.Add('postingDate', Format(SalesInvoiceHeader."Posting Date", 0, '--'));
+ RootObject.Add('currencyCode', SalesInvoiceHeader."Currency Code");
+ RootObject.Add('totalAmount', Format(SalesInvoiceHeader."Amount Including VAT", 0, 9));
+
+ // TODO: Exercise 1.B - Fill in customerNo and customerName, vendorNo and VendorName for header
+ // It is important that you have a vendor with the same number in your system, since when you receive the data you will have to pick the vendor based on the VendorNo
+ RootObject.Add('customerNo', SalesInvoiceHeader."Sell-to Customer No.");
+ RootObject.Add('customerName', SalesInvoiceHeader."Sell-to Customer Name");
+
+ // Hardcoded for simplicity. Normally this would be company information
+ RootObject.Add('vendorNo', '10000');
+ RootObject.Add('vendorName', 'Adatum Corporation');
+
+ // Create lines array
+ if SalesInvoiceLine.FindSet() then
+ repeat
+ Clear(LineObject);
+ LineObject.Add('lineNo', SalesInvoiceLine."Line No.");
+ LineObject.Add('type', Format(SalesInvoiceLine.Type));
+ LineObject.Add('no', SalesInvoiceLine."No.");
+ LineObject.Add('unitPrice', SalesInvoiceLine."Unit Price");
+ LineObject.Add('lineAmount', SalesInvoiceLine."Amount Including VAT");
+
+ // TODO: Exercise 1.B - Fill in description and quantity for line
+ LineObject.Add('description', SalesInvoiceLine.Description);
+ LineObject.Add('quantity', SalesInvoiceLine.Quantity);
+
+ LinesArray.Add(LineObject);
+ until SalesInvoiceLine.Next() = 0;
+
+ RootObject.Add('lines', LinesArray);
+
+ RootObject.WriteTo(JsonText);
+ OutStr.WriteText(JsonText);
+ end;
+
+ procedure CreateBatch(EDocumentService: Record "E-Document Service"; var EDocuments: Record "E-Document"; var SourceDocumentHeaders: RecordRef; var SourceDocumentsLines: RecordRef; var TempBlob: Codeunit "Temp Blob")
+ begin
+ Error('Batch creation is not implemented in this workshop version');
+ end;
+
+ // ============================================================================
+ // INCOMING DOCUMENTS
+ // Exercise 4
+ // Parse information from received JSON document.
+ // ============================================================================
+
+ procedure GetBasicInfoFromReceivedDocument(var EDocument: Record "E-Document"; var TempBlob: Codeunit "Temp Blob")
+ var
+ JsonObject: JsonObject;
+ JsonToken: JsonToken;
+ SimpleJsonHelper: Codeunit "SimpleJson Helper";
+ begin
+ if not SimpleJsonHelper.ReadJsonFromBlob(TempBlob, JsonObject) then
+ Error('Failed to parse JSON document');
+
+ // Set document type to Purchase Invoice
+ EDocument."Document Type" := EDocument."Document Type"::"Purchase Invoice";
+
+ // Extract document number
+ if SimpleJsonHelper.SelectJsonToken(JsonObject, 'documentNo', JsonToken) then
+ EDocument."Incoming E-Document No." := CopyStr(SimpleJsonHelper.GetJsonTokenValue(JsonToken), 1, MaxStrLen(EDocument."Incoming E-Document No."));
+
+ // Extract posting date
+ if SimpleJsonHelper.SelectJsonToken(JsonObject, 'postingDate', JsonToken) then
+ EDocument."Document Date" := SimpleJsonHelper.GetJsonTokenDate(JsonToken);
+
+ // Extract currency code
+ if SimpleJsonHelper.SelectJsonToken(JsonObject, 'currencyCode', JsonToken) then
+ EDocument."Currency Code" := CopyStr(SimpleJsonHelper.GetJsonTokenValue(JsonToken), 1, MaxStrLen(EDocument."Currency Code"));
+
+
+ // TODO: Exercise 2.A - Fill in the vendor information and total amount
+
+ // TODO: Extract vendor number (from "vendorNo" in JSON)
+ if SimpleJsonHelper.SelectJsonToken(JsonObject, 'vendorNo', JsonToken) then
+ EDocument."Bill-to/Pay-to No." := SimpleJsonHelper.getJsonTokenValue(JsonToken);
+
+ // TODO: Extract vendor name
+ if SimpleJsonHelper.SelectJsonToken(JsonObject, 'vendorName', JsonToken) then
+ EDocument."Bill-to/Pay-to Name" := SimpleJsonHelper.getJsonTokenValue(JsonToken);
+
+ // TODO: Extract total amount
+ if SimpleJsonHelper.SelectJsonToken(JsonObject, 'totalAmount', JsonToken) then
+ EDocument."Amount Incl. VAT" := SimpleJsonHelper.GetJsonTokenDecimal(JsonToken);
+ end;
+
+ procedure GetCompleteInfoFromReceivedDocument(var EDocument: Record "E-Document"; var CreatedDocumentHeader: RecordRef; var CreatedDocumentLines: RecordRef; var TempBlob: Codeunit "Temp Blob")
+ var
+ PurchaseHeader: Record "Purchase Header" temporary;
+ PurchaseLine: Record "Purchase Line" temporary;
+ JsonObject: JsonObject;
+ JsonToken: JsonToken;
+ JsonArray: JsonArray;
+ JsonLineToken: JsonToken;
+ SimpleJsonHelper: Codeunit "SimpleJson Helper";
+ LineNo: Integer;
+ begin
+ if not SimpleJsonHelper.ReadJsonFromBlob(TempBlob, JsonObject) then
+ Error('Failed to parse JSON document');
+
+ // Create Purchase Header
+ PurchaseHeader."Document Type" := PurchaseHeader."Document Type"::Invoice;
+ PurchaseHeader.InitRecord();
+ PurchaseHeader.Insert(true);
+
+ // Set vendor from JSON
+ if SimpleJsonHelper.SelectJsonToken(JsonObject, 'vendorNo', JsonToken) then
+ PurchaseHeader.Validate("Buy-from Vendor No.", SimpleJsonHelper.GetJsonTokenValue(JsonToken));
+
+ // Set posting date
+ if SimpleJsonHelper.SelectJsonToken(JsonObject, 'postingDate', JsonToken) then
+ PurchaseHeader.Validate("Posting Date", SimpleJsonHelper.GetJsonTokenDate(JsonToken));
+
+ // Set currency code (if not blank)
+ if SimpleJsonHelper.SelectJsonToken(JsonObject, 'currencyCode', JsonToken) then begin
+ if SimpleJsonHelper.GetJsonTokenValue(JsonToken) <> '' then
+ PurchaseHeader.Validate("Currency Code", SimpleJsonHelper.GetJsonTokenValue(JsonToken));
+ end;
+
+ PurchaseHeader.Modify(true);
+
+ // Create Purchase Lines from JSON array
+ if JsonObject.Get('lines', JsonToken) then begin
+ JsonArray := JsonToken.AsArray();
+ LineNo := 10000;
+
+ foreach JsonLineToken in JsonArray do begin
+ JsonObject := JsonLineToken.AsObject();
+
+ PurchaseLine.Init();
+ PurchaseLine."Document Type" := PurchaseHeader."Document Type";
+ PurchaseLine."Document No." := PurchaseHeader."No.";
+ PurchaseLine."Line No." := LineNo;
+ PurchaseLine.Type := PurchaseLine.Type::Item;
+
+ if SimpleJsonHelper.SelectJsonToken(JsonObject, 'no', JsonToken) then
+ PurchaseLine."No." := SimpleJsonHelper.GetJsonTokenValue(JsonToken);
+
+ // TODO: Set description
+ if SimpleJsonHelper.SelectJsonToken(JsonObject, 'description', JsonToken) then
+ PurchaseLine.Description := SimpleJsonHelper.GetJsonTokenValue(JsonToken);
+
+ // TODO: Set quantity
+ if SimpleJsonHelper.SelectJsonToken(JsonObject, 'quantity', JsonToken) then
+ PurchaseLine.Quantity := SimpleJsonHelper.GetJsonTokenDecimal(JsonToken);
+
+ // TODO: Set line amount
+ if SimpleJsonHelper.SelectJsonToken(JsonObject, 'lineAmount', JsonToken) then
+ PurchaseLine.Amount := SimpleJsonHelper.GetJsonTokenDecimal(JsonToken);
+
+ // Set unit cost
+ if SimpleJsonHelper.SelectJsonToken(JsonObject, 'unitCost', JsonToken) then
+ PurchaseLine."Direct Unit Cost" := SimpleJsonHelper.GetJsonTokenDecimal(JsonToken);
+
+ PurchaseLine.Insert(true);
+ LineNo += 10000;
+ end;
+ end;
+
+ // Return via RecordRef
+ CreatedDocumentHeader.GetTable(PurchaseHeader);
+ CreatedDocumentLines.GetTable(PurchaseLine);
+ end;
+}
diff --git a/samples/EDocument/DirectionsEMEA2025/application/simple_json/SimpleJsonFormat.EnumExt.al b/samples/EDocument/DirectionsEMEA2025/application/simple_json/SimpleJsonFormat.EnumExt.al
new file mode 100644
index 00000000..86f1c9fe
--- /dev/null
+++ b/samples/EDocument/DirectionsEMEA2025/application/simple_json/SimpleJsonFormat.EnumExt.al
@@ -0,0 +1,20 @@
+// ------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+// ------------------------------------------------------------------------------------------------
+namespace Microsoft.EServices.EDocument.Format;
+
+using Microsoft.eServices.EDocument;
+
+///
+/// Extends the E-Document Format enum to add SimpleJson format.
+/// This enum value is used to identify which format implementation to use.
+///
+enumextension 50100 "SimpleJson Format" extends "E-Document Format"
+{
+ value(50100; "SimpleJson")
+ {
+ Caption = 'Simple JSON';
+ Implementation = "E-Document" = "SimpleJson Format";
+ }
+}
diff --git a/samples/EDocument/DirectionsEMEA2025/application/simple_json/SimpleJsonHelper.Codeunit.al b/samples/EDocument/DirectionsEMEA2025/application/simple_json/SimpleJsonHelper.Codeunit.al
new file mode 100644
index 00000000..c678680e
--- /dev/null
+++ b/samples/EDocument/DirectionsEMEA2025/application/simple_json/SimpleJsonHelper.Codeunit.al
@@ -0,0 +1,116 @@
+// ------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+// ------------------------------------------------------------------------------------------------
+namespace Microsoft.EServices.EDocument.Format;
+
+using System.Utilities;
+using Microsoft.eServices.EDocument;
+
+///
+/// Helper codeunit with pre-written methods for JSON operations.
+/// These methods are provided to save time during the workshop.
+///
+codeunit 50104 "SimpleJson Helper"
+{
+ Access = Internal;
+
+ ///
+ /// Writes a JSON property to the output stream.
+ ///
+ /// The output stream to write to.
+ /// The name of the JSON property.
+ /// The value of the JSON property.
+ procedure WriteJsonProperty(var OutStr: OutStream; PropertyName: Text; PropertyValue: Text)
+ begin
+ OutStr.WriteText('"' + PropertyName + '": "' + PropertyValue + '"');
+ end;
+
+ ///
+ /// Writes a JSON numeric property to the output stream.
+ ///
+ procedure WriteJsonNumericProperty(var OutStr: OutStream; PropertyName: Text; PropertyValue: Decimal)
+ begin
+ OutStr.WriteText('"' + PropertyName + '": ' + Format(PropertyValue, 0, 9));
+ end;
+
+ ///
+ /// Gets a text value from a JSON token.
+ ///
+ procedure GetJsonTokenValue(JsonToken: JsonToken): Text
+ var
+ JsonValue: JsonValue;
+ begin
+ if JsonToken.IsValue then begin
+ JsonValue := JsonToken.AsValue();
+ exit(JsonValue.AsText());
+ end;
+ exit('');
+ end;
+
+ ///
+ /// Gets a decimal value from a JSON token.
+ ///
+ procedure GetJsonTokenDecimal(JsonToken: JsonToken): Decimal
+ var
+ JsonValue: JsonValue;
+ DecimalValue: Decimal;
+ begin
+ if JsonToken.IsValue then begin
+ JsonValue := JsonToken.AsValue();
+ exit(JsonValue.AsDecimal());
+ end;
+ exit(0);
+ end;
+
+ ///
+ /// Gets a date value from a JSON token.
+ ///
+ procedure GetJsonTokenDate(JsonToken: JsonToken): Date
+ var
+ JsonValue: JsonValue;
+ DateValue: Date;
+ begin
+ if JsonToken.IsValue then begin
+ JsonValue := JsonToken.AsValue();
+ if Evaluate(DateValue, JsonValue.AsText()) then
+ exit(DateValue);
+ end;
+ exit(0D);
+ end;
+
+ ///
+ /// Selects a JSON token from a JSON object by path.
+ ///
+ procedure SelectJsonToken(JsonObject: JsonObject; Path: Text; var JsonToken: JsonToken): Boolean
+ begin
+ exit(JsonObject.SelectToken(Path, JsonToken));
+ end;
+
+ ///
+ /// Reads JSON content from a TempBlob into a JsonObject.
+ ///
+ procedure ReadJsonFromBlob(var TempBlob: Codeunit "Temp Blob"; var JsonObject: JsonObject): Boolean
+ var
+ InStr: InStream;
+ JsonText: Text;
+ begin
+ TempBlob.CreateInStream(InStr, TextEncoding::UTF8);
+ InStr.ReadText(JsonText);
+ exit(JsonObject.ReadFrom(JsonText));
+ end;
+
+
+ [EventSubscriber(ObjectType::Table, Database::"E-Document Log", OnBeforeExportDataStorage, '', false, false)]
+ local procedure OnBeforeExportDataStorage(EDocumentLog: Record "E-Document Log"; var FileName: Text)
+ var
+ EDocumentService: Record "E-Document Service";
+ begin
+ EDocumentService.Get(EDocumentLog."Service Code");
+ if EDocumentService."Document Format" <> EDocumentService."Document Format"::"SimpleJson" then
+ exit;
+
+ FileName := StrSubstNo('%1_%2.json', EDocumentLog."Service Code", EDocumentLog."Document No.");
+ end;
+
+}
diff --git a/samples/EDocument/DirectionsEMEA2025/application/simple_json/SimpleJsonTest.Codeunit.al b/samples/EDocument/DirectionsEMEA2025/application/simple_json/SimpleJsonTest.Codeunit.al
new file mode 100644
index 00000000..35ee3a45
--- /dev/null
+++ b/samples/EDocument/DirectionsEMEA2025/application/simple_json/SimpleJsonTest.Codeunit.al
@@ -0,0 +1,283 @@
+// ------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+// ------------------------------------------------------------------------------------------------
+namespace Microsoft.EServices.EDocument.Format;
+
+using System.TestLibraries.Utilities;
+using Microsoft.eServices.EDocument;
+using Microsoft.Sales.History;
+using System.Utilities;
+using Microsoft.Purchases.Document;
+
+///
+/// Simple test runner for workshop participants.
+/// Run this codeunit to validate your exercise implementations.
+///
+codeunit 50113 "SimpleJson Test"
+{
+
+ Subtype = Test;
+
+ var
+ Assert: Codeunit "Library Assert";
+ LibraryLowerPermission: Codeunit "Library - Lower Permissions";
+ TestCustomerNo: Code[20];
+ TestVendorNo: Code[20];
+
+ local procedure Initialize()
+ begin
+ LibraryLowerPermission.SetOutsideO365Scope();
+ TestCustomerNo := 'CUST1';
+ TestVendorNo := 'VEND1';
+ end;
+
+
+ [Test]
+ procedure TestExercise1_CheckValidation()
+ var
+ EDocumentService: Record "E-Document Service";
+ SimpleJsonFormat: Codeunit "SimpleJson Format";
+ SalesInvoiceHeader: Record "Sales Invoice Header";
+ SalesInvoiceLine: Record "Sales Invoice Line";
+ SourceDocumentHeader: RecordRef;
+ EDocProcessingPhase: Enum "E-Document Processing Phase";
+ begin
+ Initialize();
+ // [GIVEN] A Sales Invoice Header with missing required fields
+ CreateTestSalesInvoiceWithLines(SalesInvoiceHeader, SalesInvoiceLine);
+ Clear(SalesInvoiceHeader."Posting Date"); // Invalidate required field
+ SourceDocumentHeader.GetTable(SalesInvoiceHeader);
+
+ // [WHEN] Calling Check method, [THEN] it should fail validation
+ asserterror SimpleJsonFormat.Check(SourceDocumentHeader, EDocumentService, EDocProcessingPhase::Create);
+ Assert.ExpectedError('Sell-to Customer No.');
+ end;
+
+
+ [Test]
+ procedure TestExercise1_CheckCreate()
+ var
+ EDocument: Record "E-Document";
+ EDocumentService: Record "E-Document Service";
+ SimpleJsonFormat: Codeunit "SimpleJson Format";
+ SalesInvoiceHeader: Record "Sales Invoice Header";
+ SalesInvoiceLine: Record "Sales Invoice Line";
+ TempBlob: Codeunit "Temp Blob";
+ SourceDocumentHeader: RecordRef;
+ SourceDocumentLines: RecordRef;
+ EDocProcessingPhase: Enum "E-Document Processing Phase";
+ begin
+ Initialize();
+ // [GIVEN] A Sales Invoice Header with missing required fields
+ CreateTestSalesInvoiceWithLines(SalesInvoiceHeader, SalesInvoiceLine);
+ SourceDocumentHeader.GetTable(SalesInvoiceHeader);
+ SourceDocumentLines.GetTable(SalesInvoiceLine);
+ EDocument."Document Type" := EDocument."Document Type"::"Sales Invoice";
+
+ // [WHEN] Calling create method, [THEN] it should succeed
+ SimpleJsonFormat.Create(EDocumentService, EDocument, SourceDocumentHeader, SourceDocumentLines, TempBlob);
+
+ // [THEN] JSON should be created successfully
+ VerifyJsonContent(TempBlob, SalesInvoiceHeader, SalesInvoiceLine);
+ end;
+
+ [Test]
+ procedure TestExercise2_OutgoingMethodsWork()
+ var
+ EDocumentService: Record "E-Document Service";
+ EDocument: Record "E-Document";
+ SimpleJsonFormat: Codeunit "SimpleJson Format";
+ SalesInvoiceHeader: Record "Sales Invoice Header";
+ SalesInvoiceLine: Record "Sales Invoice Line";
+ SourceDocumentHeader: RecordRef;
+ SourceDocumentLines: RecordRef;
+ TempBlob: Codeunit "Temp Blob";
+ EDocProcessingPhase: Enum "E-Document Processing Phase";
+ begin
+ Initialize();
+ // [GIVEN] A complete Sales Invoice
+ CreateTestSalesInvoiceWithLines(SalesInvoiceHeader, SalesInvoiceLine);
+ SourceDocumentHeader.GetTable(SalesInvoiceHeader);
+ SourceDocumentLines.GetTable(SalesInvoiceLine);
+ EDocument."Document Type" := EDocument."Document Type"::"Sales Invoice";
+
+ // [WHEN/THEN] Check should work (no error)
+ SimpleJsonFormat.Check(SourceDocumentHeader, EDocumentService, EDocProcessingPhase::Create);
+
+ // [WHEN/THEN] Create should work (no error)
+ SimpleJsonFormat.Create(EDocumentService, EDocument, SourceDocumentHeader, SourceDocumentLines, TempBlob);
+
+ // [THEN] JSON should be created successfully
+ VerifyJsonContent(TempBlob, SalesInvoiceHeader, SalesInvoiceLine);
+ end;
+
+ [Test]
+ procedure TestExercise2_GetBasicInfoFromJSON()
+ var
+ EDocument: Record "E-Document";
+ SimpleJsonFormat: Codeunit "SimpleJson Format";
+ TempBlob: Codeunit "Temp Blob";
+ JsonText: Text;
+ begin
+ Initialize();
+ // [GIVEN] A JSON document with customer information
+ JsonText := GetTestPurchaseInvoiceJson();
+ WriteJsonToBlob(JsonText, TempBlob);
+
+ // [WHEN] Parsing basic info from JSON
+ SimpleJsonFormat.GetBasicInfoFromReceivedDocument(EDocument, TempBlob);
+
+ // [THEN] EDocument should have correct basic information
+ Assert.AreEqual(EDocument."Document Type"::"Purchase Invoice", EDocument."Document Type", 'Should be Purchase Invoice');
+ Assert.AreEqual('TEST-PI-001', EDocument."Document No.", 'Document No should match');
+ Assert.AreEqual(TestVendorNo, EDocument."Bill-to/Pay-to No.", 'Vendor No should match');
+ Assert.AreEqual('Test Vendor', EDocument."Bill-to/Pay-to Name", 'Vendor Name should match');
+ Assert.AreEqual(Today(), EDocument."Document Date", 'Document Date should match');
+ Assert.AreEqual('USD', EDocument."Currency Code", 'Currency Code should match');
+ end;
+
+ [Test]
+ procedure TestExercise2_CreatePurchaseInvoiceFromJSON()
+ var
+ EDocument: Record "E-Document";
+ SimpleJsonFormat: Codeunit "SimpleJson Format";
+ PurchaseHeader: Record "Purchase Header";
+ PurchaseLine: Record "Purchase Line";
+ CreatedDocumentHeader: RecordRef;
+ CreatedDocumentLines: RecordRef;
+ TempBlob: Codeunit "Temp Blob";
+ JsonText: Text;
+ begin
+ Initialize();
+ // [GIVEN] A JSON document with complete invoice data
+ JsonText := GetTestPurchaseInvoiceJson();
+ WriteJsonToBlob(JsonText, TempBlob);
+
+ // [WHEN] Creating Purchase Invoice from JSON
+ SimpleJsonFormat.GetCompleteInfoFromReceivedDocument(EDocument, CreatedDocumentHeader, CreatedDocumentLines, TempBlob);
+
+ // [THEN] Purchase Header should be created correctly
+ CreatedDocumentHeader.SetTable(PurchaseHeader);
+ Assert.AreEqual(PurchaseHeader."Document Type"::Invoice, PurchaseHeader."Document Type", 'Should be Invoice type');
+ Assert.AreEqual(TestVendorNo, PurchaseHeader."Buy-from Vendor No.", 'Vendor should match');
+ Assert.AreEqual(Today(), PurchaseHeader."Posting Date", 'Posting Date should match');
+ Assert.AreEqual('USD', PurchaseHeader."Currency Code", 'Currency should match');
+
+ // [THEN] Purchase Lines should be created correctly
+ CreatedDocumentLines.SetTable(PurchaseLine);
+ PurchaseLine.SetRange("Document No.", PurchaseHeader."No.");
+ Assert.IsTrue(PurchaseLine.FindFirst(), 'Should have purchase lines');
+ Assert.AreEqual('ITEM-001', PurchaseLine."No.", 'Item No should match');
+ Assert.AreEqual('Test Item', PurchaseLine.Description, 'Description should match');
+ Assert.AreEqual(5, PurchaseLine.Quantity, 'Quantity should match');
+ Assert.AreEqual(200, PurchaseLine."Direct Unit Cost", 'Unit Cost should match');
+ end;
+
+ local procedure CreateTestSalesInvoice(var SalesInvoiceHeader: Record "Sales Invoice Header"; CustomerNo: Code[20]; PostingDate: Date)
+ begin
+ SalesInvoiceHeader.Init();
+ SalesInvoiceHeader."No." := 'TEST-SI-001';
+ SalesInvoiceHeader."Sell-to Customer No." := CustomerNo;
+ SalesInvoiceHeader."Sell-to Customer Name" := 'Test Customer';
+ SalesInvoiceHeader."Posting Date" := PostingDate;
+ SalesInvoiceHeader."Currency Code" := 'USD';
+ SalesInvoiceHeader."Amount Including VAT" := 1000;
+ SalesInvoiceHeader.Insert();
+ end;
+
+ local procedure CreateTestSalesInvoiceWithLines(var SalesInvoiceHeader: Record "Sales Invoice Header"; var SalesInvoiceLine: Record "Sales Invoice Line")
+ begin
+ CreateTestSalesInvoice(SalesInvoiceHeader, TestCustomerNo, Today());
+
+ SalesInvoiceLine.Init();
+ SalesInvoiceLine."Document No." := SalesInvoiceHeader."No.";
+ SalesInvoiceLine."Line No." := 10000;
+ SalesInvoiceLine.Type := SalesInvoiceLine.Type::Item;
+ SalesInvoiceLine."No." := 'ITEM-001';
+ SalesInvoiceLine.Description := 'Test Item';
+ SalesInvoiceLine.Quantity := 5;
+ SalesInvoiceLine."Unit Price" := 200;
+ SalesInvoiceLine."Amount Including VAT" := 1000;
+ SalesInvoiceLine.Insert();
+ end;
+
+ local procedure VerifyJsonContent(var TempBlob: Codeunit "Temp Blob"; SalesInvoiceHeader: Record "Sales Invoice Header"; SalesInvoiceLine: Record "Sales Invoice Line")
+ var
+ InStr: InStream;
+ JsonText: Text;
+ JsonObject: JsonObject;
+ JsonToken: JsonToken;
+ JsonArray: JsonArray;
+ LineObject: JsonObject;
+ begin
+ TempBlob.CreateInStream(InStr, TextEncoding::UTF8);
+ InStr.ReadText(JsonText);
+
+ Assert.IsTrue(JsonObject.ReadFrom(JsonText), 'Should be valid JSON');
+
+ // Verify header
+ Assert.IsTrue(JsonObject.Get('documentNo', JsonToken), 'Should have documentNo');
+ Assert.AreEqual(SalesInvoiceHeader."No.", JsonToken.AsValue().AsText(), 'DocumentNo should match');
+
+ Assert.IsTrue(JsonObject.Get('customerName', JsonToken), 'Should have customerName');
+ Assert.AreEqual(SalesInvoiceHeader."Sell-to Customer Name", JsonToken.AsValue().AsText(), 'CustomerName should match');
+
+ Assert.IsTrue(JsonObject.Get('totalAmount', JsonToken), 'Should have totalAmount');
+ Assert.AreEqual(SalesInvoiceHeader."Amount Including VAT", JsonToken.AsValue().AsDecimal(), 'TotalAmount should match');
+
+ // Verify lines
+ Assert.IsTrue(JsonObject.Get('lines', JsonToken), 'Should have lines');
+ JsonArray := JsonToken.AsArray();
+ Assert.AreEqual(1, JsonArray.Count(), 'Should have one line');
+
+ JsonArray.Get(0, JsonToken);
+ LineObject := JsonToken.AsObject();
+
+ Assert.IsTrue(LineObject.Get('description', JsonToken), 'Line should have description');
+ Assert.AreEqual(SalesInvoiceLine.Description, JsonToken.AsValue().AsText(), 'Line description should match');
+
+ Assert.IsTrue(LineObject.Get('quantity', JsonToken), 'Line should have quantity');
+ Assert.AreEqual(SalesInvoiceLine.Quantity, JsonToken.AsValue().AsDecimal(), 'Line quantity should match');
+ end;
+
+ local procedure GetTestPurchaseInvoiceJson(): Text
+ var
+ JsonObject: JsonObject;
+ LinesArray: JsonArray;
+ LineObject: JsonObject;
+ JsonText: Text;
+ begin
+ // Create test JSON that matches the expected format
+ JsonObject.Add('documentType', 'Invoice');
+ JsonObject.Add('documentNo', 'TEST-PI-001');
+ JsonObject.Add('customerNo', TestVendorNo); // Note: In JSON it's customerNo, but we map to vendor
+ JsonObject.Add('customerName', 'Test Vendor');
+ JsonObject.Add('postingDate', Format(Today(), 0, '--'));
+ JsonObject.Add('currencyCode', 'USD');
+ JsonObject.Add('totalAmount', 1000);
+
+ // Add line
+ LineObject.Add('lineNo', 10000);
+ LineObject.Add('type', 'Item');
+ LineObject.Add('no', 'ITEM-001');
+ LineObject.Add('description', 'Test Item');
+ LineObject.Add('quantity', 5);
+ LineObject.Add('unitPrice', 200);
+ LineObject.Add('lineAmount', 1000);
+ LinesArray.Add(LineObject);
+ JsonObject.Add('lines', LinesArray);
+
+ JsonObject.WriteTo(JsonText);
+ exit(JsonText);
+ end;
+
+ local procedure WriteJsonToBlob(JsonText: Text; var TempBlob: Codeunit "Temp Blob")
+ var
+ OutStr: OutStream;
+ begin
+ TempBlob.CreateOutStream(OutStr, TextEncoding::UTF8);
+ OutStr.WriteText(JsonText);
+ end;
+
+}
\ No newline at end of file
diff --git a/samples/EDocument/DirectionsEMEA2025/application/simple_json/app.json b/samples/EDocument/DirectionsEMEA2025/application/simple_json/app.json
new file mode 100644
index 00000000..073d53c3
--- /dev/null
+++ b/samples/EDocument/DirectionsEMEA2025/application/simple_json/app.json
@@ -0,0 +1,48 @@
+{
+ "id": "12345678-0001-0001-0001-000000000001",
+ "name": "SimpleJson E-Document Format",
+ "publisher": "Directions EMEA Workshop",
+ "version": "1.0.0.0",
+ "application": "27.1.0.0",
+ "brief": "SimpleJson format for E-Document workshop",
+ "description": "Workshop extension that implements the E-Document format interface for JSON documents",
+ "privacyStatement": "https://privacy.microsoft.com",
+ "EULA": "https://www.microsoft.com/en-us/legal/intellectualproperty/copyright",
+ "help": "https://learn.microsoft.com/dynamics365/business-central/",
+ "url": "https://github.com/microsoft/BCTech",
+ "logo": "",
+ "dependencies": [
+ {
+ "id": "e1d97edc-c239-46b4-8d84-6368bdf67c8b",
+ "name": "E-Document Core",
+ "publisher": "Microsoft",
+ "version": "27.1.0.0"
+ },
+ {
+ "id": "dd0be2ea-f733-4d65-bb34-a28f4624fb14",
+ "name": "Library Assert",
+ "publisher": "Microsoft",
+ "version": "27.1.0.0"
+ },
+ {
+ "id": "5d86850b-0d76-4eca-bd7b-951ad998e997",
+ "name": "Tests-TestLibraries",
+ "publisher": "Microsoft",
+ "version": "27.1.0.0"
+ }
+ ],
+ "screenshots": [],
+ "platform": "27.0.0.0",
+ "idRanges": [
+ {
+ "from": 50100,
+ "to": 50150
+ }
+ ],
+ "features": [
+ "TranslationFile",
+ "NoImplicitWith"
+ ],
+ "target": "Cloud",
+ "runtime": "16.0"
+}
diff --git a/samples/EDocument/DirectionsEMEA2025/server/README.md b/samples/EDocument/DirectionsEMEA2025/server/README.md
new file mode 100644
index 00000000..49b24b50
--- /dev/null
+++ b/samples/EDocument/DirectionsEMEA2025/server/README.md
@@ -0,0 +1,404 @@
+# Workshop API Server
+
+Simple FastAPI server for the E-Document Connector Workshop.
+
+## Overview
+
+This server provides a simple queue-based document exchange API for workshop participants to practice E-Document integrations.
+
+**Features**:
+- User registration with API keys
+- Document queue per user (isolated)
+- FIFO queue operations (enqueue, peek, dequeue)
+- In-memory storage (no database required)
+- Simple authentication via header
+
+**Technology**: Python FastAPI
+
+---
+
+## Deployment
+
+### Azure Web App (Recommended for Workshop)
+
+The server is deployed to Azure App Service for the workshop.
+
+**Requirements**:
+- Azure subscription
+- App Service (Linux)
+- Python 3.9+ runtime
+
+**Deployment Steps**:
+
+1. **Create App Service**:
+```bash
+az webapp up --name workshop-edocument-api \
+ --resource-group rg-workshop \
+ --runtime "PYTHON:3.9" \
+ --sku B1
+```
+
+2. **Deploy Code**:
+```bash
+cd server
+zip -r app.zip .
+az webapp deployment source config-zip \
+ --resource-group rg-workshop \
+ --name workshop-edocument-api \
+ --src app.zip
+```
+
+3. **Configure Startup**:
+In Azure Portal, set startup command:
+```bash
+python -m uvicorn server:app --host 0.0.0.0 --port 8000
+```
+
+4. **Verify**:
+```bash
+curl https://workshop-edocument-api.azurewebsites.net/docs
+```
+
+---
+
+## Local Development
+
+### Prerequisites
+- Python 3.9 or higher
+- pip
+
+### Installation
+
+1. **Install dependencies**:
+```bash
+pip install -r requirements.txt
+```
+
+2. **Run server**:
+```bash
+python -m uvicorn server:app --reload --port 8000
+```
+
+3. **Access**:
+- API: http://localhost:8000
+- Interactive docs: http://localhost:8000/docs
+- Alternative docs: http://localhost:8000/redoc
+
+---
+
+## API Endpoints
+
+### POST /register
+Register a new user and get an API key.
+
+**Request**:
+```json
+{
+ "name": "user-name"
+}
+```
+
+**Response**:
+```json
+{
+ "status": "ok",
+ "key": "uuid-api-key"
+}
+```
+
+### POST /enqueue
+Add a document to your queue.
+
+**Headers**: `X-Service-Key: your-api-key`
+
+**Request**: Any JSON document
+
+**Response**:
+```json
+{
+ "status": "ok",
+ "queued_count": 3
+}
+```
+
+### GET /peek
+View all documents in your queue (without removing).
+
+**Headers**: `X-Service-Key: your-api-key`
+
+**Response**:
+```json
+{
+ "queued_count": 2,
+ "items": [...]
+}
+```
+
+### GET /dequeue
+Retrieve and remove the first document from your queue.
+
+**Headers**: `X-Service-Key: your-api-key`
+
+**Response**:
+```json
+{
+ "document": {...}
+}
+```
+
+### DELETE /clear
+Clear all documents from your queue.
+
+**Headers**: `X-Service-Key: your-api-key`
+
+**Response**:
+```json
+{
+ "status": "cleared"
+}
+```
+
+---
+
+## Architecture
+
+```
+┌─────────────────────────────────────┐
+│ FastAPI Server │
+│ │
+│ ┌──────────────────────────────┐ │
+│ │ auth_keys: Dict[str, str] │ │ user_id -> api_key
+│ └──────────────────────────────┘ │
+│ │
+│ ┌──────────────────────────────┐ │
+│ │ queues: Dict[str, deque] │ │ api_key -> queue
+│ └──────────────────────────────┘ │
+│ │
+└─────────────────────────────────────┘
+```
+
+### Data Structures
+
+**auth_keys**: Maps user names to API keys
+```python
+{
+ "john-doe": "uuid-1",
+ "jane-smith": "uuid-2"
+}
+```
+
+**queues**: Maps API keys to document queues
+```python
+{
+ "uuid-1": deque([doc1, doc2, doc3]),
+ "uuid-2": deque([doc4, doc5])
+}
+```
+
+---
+
+## Testing
+
+### Using cURL
+
+```bash
+# Register
+curl -X POST http://localhost:8000/register \
+ -H "Content-Type: application/json" \
+ -d '{"name": "test-user"}'
+
+# Enqueue
+curl -X POST http://localhost:8000/enqueue \
+ -H "X-Service-Key: your-key" \
+ -H "Content-Type: application/json" \
+ -d '{"test": "document"}'
+
+# Peek
+curl -X GET http://localhost:8000/peek \
+ -H "X-Service-Key: your-key"
+
+# Dequeue
+curl -X GET http://localhost:8000/dequeue \
+ -H "X-Service-Key: your-key"
+
+# Clear
+curl -X DELETE http://localhost:8000/clear \
+ -H "X-Service-Key: your-key"
+```
+
+### Using Python
+
+```python
+import requests
+
+base_url = "http://localhost:8000"
+
+# Register
+response = requests.post(f"{base_url}/register", json={"name": "test-user"})
+api_key = response.json()["key"]
+
+headers = {"X-Service-Key": api_key}
+
+# Enqueue
+requests.post(f"{base_url}/enqueue", headers=headers, json={"test": "doc"})
+
+# Peek
+response = requests.get(f"{base_url}/peek", headers=headers)
+print(response.json())
+
+# Dequeue
+response = requests.get(f"{base_url}/dequeue", headers=headers)
+print(response.json())
+```
+
+---
+
+## Security Considerations
+
+⚠️ **WARNING**: This server is designed for workshop use only!
+
+**Security Limitations**:
+- ❌ No HTTPS enforcement
+- ❌ No rate limiting
+- ❌ No persistent storage
+- ❌ Simple API key authentication
+- ❌ No data encryption
+- ❌ No audit logging
+- ❌ All data lost on restart
+
+**For Production Use**:
+- ✅ Use proper authentication (OAuth, JWT)
+- ✅ Add HTTPS/TLS encryption
+- ✅ Implement rate limiting
+- ✅ Use persistent database
+- ✅ Add comprehensive logging
+- ✅ Implement data retention policies
+- ✅ Add monitoring and alerting
+
+---
+
+## Monitoring
+
+### Check Server Health
+
+```bash
+curl http://localhost:8000/docs
+```
+
+If the interactive docs load, the server is running.
+
+### View Logs
+
+**Local**:
+- Check terminal where uvicorn is running
+
+**Azure**:
+```bash
+az webapp log tail --name workshop-edocument-api --resource-group rg-workshop
+```
+
+Or in Azure Portal:
+- App Service → Monitoring → Log stream
+
+---
+
+## Troubleshooting
+
+### Server won't start
+- Check Python version: `python --version` (need 3.9+)
+- Verify dependencies: `pip install -r requirements.txt`
+- Check port availability: `netstat -an | grep 8000`
+
+### Can't access from outside
+- Verify firewall settings
+- Check Azure networking rules
+- Ensure correct URL (HTTP not HTTPS for local)
+
+### Authentication errors
+- Verify API key is correctly copied
+- Check header name: `X-Service-Key` (case-sensitive)
+- Re-register if key is lost
+
+### Queue empty when it shouldn't be
+- Remember: Server uses in-memory storage
+- Data is lost on restart
+- Each user has isolated queue
+
+---
+
+## Scaling Considerations
+
+For larger workshops:
+
+1. **Use Azure App Service Plan** with auto-scaling
+2. **Monitor CPU/Memory** usage
+3. **Consider Redis** for distributed queue if multiple instances needed
+4. **Add rate limiting** to prevent abuse
+5. **Implement pagination** for large queues
+
+Current implementation handles ~100 concurrent users with basic App Service plan.
+
+---
+
+## Development Notes
+
+### Adding New Endpoints
+
+1. Add route to `server.py`:
+```python
+@app.get("/new-endpoint")
+async def new_endpoint(request: Request):
+ key = get_key(request)
+ # Your logic here
+ return {"status": "ok"}
+```
+
+2. Test locally
+3. Deploy to Azure
+
+### Modifying Data Structure
+
+Currently uses:
+- `defaultdict(deque)` for queues
+- `dict` for auth keys
+
+For persistence, consider:
+- Redis for queue
+- Database for auth
+
+---
+
+## API Documentation
+
+The server provides automatic API documentation:
+
+- **Swagger UI**: `/docs`
+- **ReDoc**: `/redoc`
+- **OpenAPI JSON**: `/openapi.json`
+
+Use these for interactive testing and documentation.
+
+---
+
+## License
+
+MIT License - See repository root for details.
+
+---
+
+## Support
+
+For issues with the server:
+1. Check this README
+2. Review server logs
+3. Test endpoints with curl
+4. Contact workshop instructor
+
+For workshop content:
+- See main README.md
+- Check WORKSHOP_GUIDE.md
+- Ask instructor
+
+---
+
+**Happy API Testing!** 🚀
diff --git a/samples/EDocument/DirectionsEMEA2025/server/app.py b/samples/EDocument/DirectionsEMEA2025/server/app.py
new file mode 100644
index 00000000..62c993f2
--- /dev/null
+++ b/samples/EDocument/DirectionsEMEA2025/server/app.py
@@ -0,0 +1,62 @@
+from flask import Flask, request, jsonify, abort
+from collections import defaultdict, deque
+import uuid
+
+app = Flask(__name__)
+
+# Simple in-memory stores
+auth_keys = {} # user_id -> key
+queues = defaultdict(deque) # key -> deque
+
+@app.route("/register", methods=["POST"])
+def register():
+ data = request.get_json()
+ name = data.get("name")
+ if not name:
+ abort(400, "Missing name")
+ key = str(uuid.uuid4())
+ auth_keys[name] = key
+ queues[key] # Initialize queue
+ return jsonify({"status": "ok", "key": key})
+
+def get_key() -> str:
+ key = str.lower(request.headers.get("X-Service-Key"))
+ if not key or key not in queues:
+ abort(401, "Unauthorized or invalid key")
+ return key
+
+@app.route("/enqueue", methods=["POST"])
+def enqueue():
+ key = get_key()
+ doc = request.get_json()
+ queues[key].append(doc)
+ return jsonify({"status": "ok", "queued_count": len(queues[key])})
+
+@app.route("/dequeue", methods=["GET"])
+def dequeue():
+ key = get_key()
+ if not queues[key]:
+ abort(404, "Queue empty")
+ value = queues[key].popleft()
+ return jsonify({"document": value})
+
+@app.route("/peek", methods=["GET"])
+def peek():
+ key = get_key()
+ return jsonify({"queued_count": len(queues[key]), "items": list(queues[key])})
+
+@app.route("/clear", methods=["DELETE"])
+def clear():
+ key = get_key()
+ queues[key].clear()
+ return jsonify({"status": "cleared"})
+
+@app.route("/")
+def root():
+ return "Hello world"
+
+# if __name__ == "__main__":
+# app.run(debug=True, host="0.0.0.0", port=8000)
+
+if __name__ == '__main__':
+ app.run(debug=True)
\ No newline at end of file
diff --git a/samples/EDocument/DirectionsEMEA2025/server/requirements.txt b/samples/EDocument/DirectionsEMEA2025/server/requirements.txt
new file mode 100644
index 00000000..809745ba
--- /dev/null
+++ b/samples/EDocument/DirectionsEMEA2025/server/requirements.txt
@@ -0,0 +1,2 @@
+flask==3.0.0
+werkzeug==3.0.1
\ No newline at end of file
diff --git a/samples/EDocument/DirectionsEMEA2025/workshop/API_REFERENCE.md b/samples/EDocument/DirectionsEMEA2025/workshop/API_REFERENCE.md
new file mode 100644
index 00000000..e69de29b
diff --git a/samples/EDocument/DirectionsEMEA2025/workshop/README.md b/samples/EDocument/DirectionsEMEA2025/workshop/README.md
new file mode 100644
index 00000000..3efe07ec
--- /dev/null
+++ b/samples/EDocument/DirectionsEMEA2025/workshop/README.md
@@ -0,0 +1,294 @@
+# E-Document Connector Workshop
+## Directions EMEA 2025
+
+Welcome to the E-Document Connector Workshop! This hands-on workshop teaches you how to build complete E-Document integrations using the Business Central E-Document Core framework.
+
+---
+
+## 🎯 Workshop Overview
+
+**Duration**: 90 minutes (10 min intro + 80 min hands-on)
+
+**What You'll Build**:
+1. **SimpleJson Format** - E-Document format implementation for JSON documents
+2. **DirectionsConnector** - HTTP API integration for sending/receiving documents
+3. **Complete Round-Trip** - Full document exchange between Business Central and external systems
+
+**What You'll Learn**:
+- How the E-Document Core framework works
+- How to implement format interfaces
+- How to implement integration interfaces
+- Best practices for E-Document solutions
+
+---
+
+## 📂 Workshop Structure
+
+```
+workshop/
+├── COMPLETE_WORKSHOP_GUIDE.md # ⭐ Start here! Complete step-by-step guide
+├── API_REFERENCE.md # API endpoint documentation
+├── README.md # This file - overview and quick reference
+
+application/
+├── simple_json/ # Exercise 1: Format implementation
+│ ├── SimpleJsonFormat.Codeunit.al # ⚠️ Your code here
+│ ├── SimpleJsonFormat.EnumExt.al # ✅ Pre-written
+│ ├── SimpleJsonHelper.Codeunit.al # ✅ Helper methods
+│ ├── SimpleJsonTest.Codeunit.al # ✅ Automated tests
+│ └── app.json
+│
+└── directions_connector/ # Exercise 2: Integration implementation
+ ├── ConnectorIntegration.Codeunit.al # ⚠️ Your code here
+ ├── ConnectorIntegration.EnumExt.al # ✅ Pre-written
+ ├── ConnectorAuth.Codeunit.al # ✅ Helper methods
+ ├── ConnectorRequests.Codeunit.al # ✅ Helper methods
+ ├── ConnectorConnectionSetup.Table.al # ✅ Setup table
+ ├── ConnectorConnectionSetup.Page.al # ✅ Setup UI
+ ├── ConnectorTests.Codeunit.al # ✅ Automated tests
+ └── app.json
+
+```
+
+---
+
+## 🚀 Getting Started
+
+### For Participants
+
+**Quick Start:**
+
+1. **Open** `COMPLETE_WORKSHOP_GUIDE.md` - This is your main guide!
+2. **Ensure** you have:
+ - Business Central development environment
+ - VS Code with AL Language extension
+ - API Base URL from instructor
+3. **Follow** the guide step-by-step through both exercises
+4. **Reference** `API_REFERENCE.md` when working with HTTP endpoints
+
+**Workshop Timeline:**
+- 00:00-00:10: Introduction & Architecture (optional: review `WORKSHOP_INTRO.md`)
+- 00:10-00:40: Exercise 1 - SimpleJson Format
+- 00:40-01:10: Exercise 2 - DirectionsConnector
+- 01:10-01:25: Testing & Live Demo
+- 01:25-01:30: Wrap-up & Q&A
+
+---
+
+## ⏱️ Workshop Timeline
+
+| Time | Duration | Activity | File |
+|------|----------|----------|------|
+| 00:00-00:10 | 10 min | Introduction & Architecture | WORKSHOP_INTRO.md |
+| 00:10-00:40 | 30 min | Exercise 1: SimpleJson Format | WORKSHOP_GUIDE.md |
+| 00:40-01:10 | 30 min | Exercise 2: DirectionsConnector | WORKSHOP_GUIDE.md |
+| 01:10-01:25 | 15 min | Testing & Live Demo | WORKSHOP_GUIDE.md |
+| 01:25-01:30 | 5 min | Wrap-up | - |
+
+---
+
+## 📋 Prerequisites
+
+### Required
+- Business Central development environment (Sandbox or Docker)
+- VS Code with AL Language extension
+- Basic AL programming knowledge
+- API Base URL (provided by instructor)
+
+### Helpful
+- Understanding of REST APIs
+- JSON format familiarity
+- Postman or similar API testing tool
+
+---
+
+## 🎓 Learning Objectives
+
+By the end of this workshop, participants will be able to:
+
+1. ✅ Understand the E-Document Core framework architecture
+2. ✅ Implement the "E-Document" interface for custom formats
+3. ✅ Implement IDocumentSender and IDocumentReceiver interfaces
+4. ✅ Configure and use E-Document Services in Business Central
+5. ✅ Test complete document round-trips (send and receive)
+6. ✅ Troubleshoot common integration issues
+7. ✅ Know where to find resources for further learning
+
+---
+
+## 📚 Key Concepts
+
+### E-Document Format Interface
+Converts Business Central documents to/from external formats:
+- **Outgoing**: `Check()`, `Create()`
+- **Incoming**: `GetBasicInfoFromReceivedDocument()`, `GetCompleteInfoFromReceivedDocument()`
+
+### Integration Interfaces
+Communicates with external systems:
+- **IDocumentSender**: `Send()` - Send documents
+- **IDocumentReceiver**: `ReceiveDocuments()`, `DownloadDocument()` - Receive documents
+
+### SimpleJson Format
+- Simple JSON structure for learning
+- Header fields: document type, number, customer, date, amount
+- Lines array: line items with quantities and prices
+- Human-readable and easy to debug
+
+### Connector
+- REST API integration via HTTP
+- Authentication via API key header
+- Queue-based document exchange
+- Stateless and scalable
+
+---
+
+## 📖 Additional Resources
+
+### Workshop Materials
+- [Workshop Introduction](WORKSHOP_INTRO.md) - Slide deck presentation
+- [Workshop Guide](WORKSHOP_GUIDE.md) - Detailed instructions with code
+- [API Reference](API_REFERENCE.md) - Complete endpoint documentation
+- [Workshop Plan](WORKSHOP_PLAN.md) - Implementation overview
+
+### E-Document Framework
+- [E-Document Core README](https://github.com/microsoft/BCApps/blob/main/src/Apps/W1/EDocument/App/README.md) - Official framework documentation
+- [E-Document Interface](https://github.com/microsoft/BCApps/blob/main/src/Apps/W1/EDocument/App/src/Document/Interfaces/EDocument.Interface.al) - Interface source code
+
+### External Resources
+- [Business Central Documentation](https://learn.microsoft.com/dynamics365/business-central/)
+- [AL Language Reference](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-reference-overview)
+
+
+## 🎯 Quick Start
+
+### 5-Minute Setup
+
+1. **Get API Access:**
+ - Instructor provides API Base URL: `https://[server].azurewebsites.net/`
+ - Open Business Central
+ - Search "Connector Connection Setup"
+ - Enter API Base URL and your unique name
+ - Click "Register" to get API key
+
+2. **Create E-Document Service:**
+ - Search "E-Document Services"
+ - Create new service with:
+ - Code: `CONNECTOR`
+ - Document Format: `Simple JSON Format - Exercise 1`
+ - Service Integration V2: `Connector`
+ - Enable the service
+
+2. **Create E-Document Workflow:**
+ - Create Workflow with E-Document
+ - When E-document Created -> Send E-Document using setup: `CONNECTOR`
+ - Enable workflow, and assign it in document sending profile
+ - Assign document sending profile to Customer.
+
+3. **Start Coding:**
+ - Open `application/simple_json/SimpleJsonFormat.Codeunit.al`
+ - Find first TODO comment
+ - Follow `COMPLETE_WORKSHOP_GUIDE.md`
+
+### Quick Test
+
+**Outgoing (Send):**
+```
+1. Post a Sales Invoice
+2. Open E-Documents → Find your invoice
+3. Click "Send"
+4. Check status changes to "Sent"
+```
+
+**Incoming (Receive):**
+```
+1. E-Document Services → Select CONNECTOR
+2. Click "Receive"
+3. New E-Documents arrive
+```
+
+---
+
+## 📖 Documentation Quick Reference
+
+| Document | Purpose | When to Use |
+|----------|---------|-------------|
+| [**COMPLETE_WORKSHOP_GUIDE.md**](./COMPLETE_WORKSHOP_GUIDE.md) | ⭐ Main guide with all exercises and solutions | Start here - your primary reference |
+| [**API_REFERENCE.md**](./API_REFERENCE.md) | Complete API endpoint documentation | When implementing HTTP calls |
+
+---
+
+## 🏆 Success Criteria
+
+You've completed the workshop successfully if you can:
+
+- ✅ Post a Sales Invoice and see it as an E-Document
+- ✅ View the JSON content in the E-Document log
+- ✅ Send the E-Document to the API
+- ✅ Verify the document appears in the API queue (using `/peek`)
+- ✅ Receive documents from the API queue
+- ✅ Create Purchase Invoices from received E-Documents
+- ✅ Verify all data is mapped correctly (vendor, dates, lines, amounts)
+- ✅ Understand the complete round-trip flow
+
+---
+
+## 🔧 Code Locations
+
+### Exercise 1: SimpleJson Format
+
+**File:** `application/simple_json/SimpleJsonFormat.Codeunit.al`
+
+**TODOs:**
+- Line ~30: `Check()` - Add Posting Date validation
+- Line ~93: `CreateSalesInvoiceJson()` - Add customer fields to header
+- Line ~110: `CreateSalesInvoiceJson()` - Add description and quantity to lines
+- Line ~165: `GetBasicInfoFromReceivedDocument()` - Parse vendor info and amount
+- Line ~195: `GetCompleteInfoFromReceivedDocument()` - Set vendor and dates
+- Line ~215: `GetCompleteInfoFromReceivedDocument()` - Set line details
+
+### Exercise 2: DirectionsConnector
+
+**File:** `application/directions_connector/ConnectorIntegration.Codeunit.al`
+
+**TODOs:**
+- Line ~45: `Send()` - Get TempBlob and read JSON
+- Line ~50: `Send()` - Create POST request to enqueue
+- Line ~55: `Send()` - Send HTTP request
+- Line ~85: `ReceiveDocuments()` - Create GET request to peek
+- Line ~90: `ReceiveDocuments()` - Send HTTP request
+- Line ~105: `ReceiveDocuments()` - Add documents to metadata list
+- Line ~120: `DownloadDocument()` - Create GET request to dequeue
+- Line ~125: `DownloadDocument()` - Send HTTP request
+
+---
+
+
+## 💡 Tips for Success
+
+1. **Read TODO comments carefully** - They contain important hints
+2. **Use the helper methods** - Pre-written functions save time
+3. **Test incrementally** - Don't wait until everything is done
+4. **Check logs** - E-Document logs show JSON and HTTP details
+5. **Use API_REFERENCE.md** - Complete endpoint documentation
+6. **Ask questions** - Instructor is here to help!
+7. **Collaborate** - Exchange documents with other participants
+
+---
+
+## 📚 Learning Resources
+
+### E-Document Framework
+- **Core README**: [E-Document Core Documentation](https://github.com/microsoft/BCApps/blob/main/src/Apps/W1/EDocument/App/README.md)
+- **Interface Source**: [E-Document Interface Code](https://github.com/microsoft/BCApps/blob/main/src/Apps/W1/EDocument/App/src/Document/Interfaces/EDocument.Interface.al)
+- **Integration Interfaces**: [Integration Interface Code](https://github.com/microsoft/BCApps/tree/main/src/Apps/W1/EDocument/App/src/Integration/Interfaces)
+
+### Business Central Development
+- [BC Developer Documentation](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/)
+- [AL Language Reference](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-reference-overview)
+- [AL Samples Repository](https://github.com/microsoft/AL)
+
+---
+
+**Happy Coding!** 🚀
+
+*For questions or feedback, contact the workshop instructor.*
diff --git a/samples/EDocument/DirectionsEMEA2025/workshop/WORKSHOP_GUIDE.md b/samples/EDocument/DirectionsEMEA2025/workshop/WORKSHOP_GUIDE.md
new file mode 100644
index 00000000..e69de29b
diff --git a/samples/EDocument/DirectionsEMEA2025/workshop/docprofile.png b/samples/EDocument/DirectionsEMEA2025/workshop/docprofile.png
new file mode 100644
index 00000000..4b66d8ae
Binary files /dev/null and b/samples/EDocument/DirectionsEMEA2025/workshop/docprofile.png differ
diff --git a/samples/EDocument/DirectionsEMEA2025/workshop/image.png b/samples/EDocument/DirectionsEMEA2025/workshop/image.png
new file mode 100644
index 00000000..3fbcaf88
Binary files /dev/null and b/samples/EDocument/DirectionsEMEA2025/workshop/image.png differ
diff --git a/samples/EDocument/DirectionsEMEA2025/workshop/service.png b/samples/EDocument/DirectionsEMEA2025/workshop/service.png
new file mode 100644
index 00000000..76ca79f8
Binary files /dev/null and b/samples/EDocument/DirectionsEMEA2025/workshop/service.png differ
diff --git a/samples/EDocument/DirectionsEMEA2025/workshop/workflow.png b/samples/EDocument/DirectionsEMEA2025/workshop/workflow.png
new file mode 100644
index 00000000..7f28b8f4
Binary files /dev/null and b/samples/EDocument/DirectionsEMEA2025/workshop/workflow.png differ