diff --git a/.gitignore b/.gitignore index c918eb9..9d8d9f9 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ obj/ *.dbmdl *.jfm +# Generated XML documentation output (regenerated on build) +MFR_RESTClient/MFR_RESTClient.xml + # NuGet packages/ *.nupkg diff --git a/MFR_RESTClient/Docs/mfr_interface_description.md b/MFR_RESTClient/Docs/mfr_interface_description.md new file mode 100644 index 0000000..7038f64 --- /dev/null +++ b/MFR_RESTClient/Docs/mfr_interface_description.md @@ -0,0 +1,1276 @@ +--- +title: "mfr Mobile Field Report - Interface Description for AI Coding Tools" +subtitle: "REST/OData integration contract derived from the public mfr wiki, public Postman documentation context, and public implementation evidence" +author: "Prepared for AI-assisted development" +date: "2026-06-05" +--- + +# mfr Mobile Field Report - Interface Description for AI Coding Tools + +## 1. Purpose and scope + +This document describes the public integration surface of **mfr - mobile field report** in a form that can be handed to AI coding tools, SDK generators, or developers building integrations. + +The intent is not to replace the vendor's current Postman collection or tenant-specific API documentation. Instead, it provides a structured, implementation-oriented contract with: + +- API surfaces and base URLs. +- Authentication assumptions. +- OData conventions. +- Domain resources and likely entity relationships. +- Endpoint catalog with confidence labels. +- Request/response examples. +- Error-handling, pagination, filtering, and retry guidance. +- TypeScript SDK design guidance. +- An OpenAPI starter definition for the confirmed REST endpoints and common OData endpoints. +- Verification checklist for turning this draft into a production-certified contract. + +## 2. Source map and confidence model + +### 2.1 Source URLs supplied + +1. Official mfr wiki page: `https://faq.mobilefieldreport.com/de/wiki/beschreibung-schnittstelle` +2. Public Postman documentation URL: `https://documenter.getpostman.com/view/6932380/2sB3dWsn6U` + +### 2.2 Additional public evidence used + +The following public evidence was used to fill in implementation details where the vendor wiki only describes the high-level interface family: + +- Public Postman/OData indexing snippets for older mfr OData documentation. +- Public n8n community discussion and public node implementation references for mfr document upload. +- Public node implementation evidence for OData endpoints such as Companies, Contacts, Appointments, ItemTypes, and ServiceObjects. +- OData standard documentation for query semantics. +- Ecosystem references such as Zapier connector fields and public integration guides. + +### 2.3 Confidence labels + +Use the following labels whenever implementing or generating code from this document. + +| Label | Meaning | How to use it | +|---|---|---| +| Confirmed | Directly visible in official mfr wiki or strongly supported by official-looking endpoint examples. | Safe to implement first, still validate against tenant credentials. | +| Strongly indicated | Supported by public implementation evidence or older public Postman/OData documentation. | Implement behind integration tests; verify against current Postman collection. | +| Inferred | Derived from OData standards or naming conventions. | Treat as a default strategy, not a contract guarantee. | +| Unknown / verify | Not available from public text extraction or likely tenant-specific. | Do not hard-code; ask tenant/vendor or inspect live metadata. | + +## 3. Interface families + +The official wiki identifies these integration mechanisms: + +| Interface | Description | Typical use | +|---|---|---| +| UGL Artikel Import | Import of item/article data. | Bulk item/product import, likely from ERP/catalog systems. | +| OData | Microsoft-origin REST data-exchange standard; successor-style integration surface compared with older SOAP-style integrations. | CRUD and querying over business entities. | +| REST API | HTTP operations via POST, GET, PUT, and DELETE for data and documents. | Special operations such as deep creation and document upload. | +| Navision / Microsoft Dynamics development kit | Integration kit for Dynamics/Navision ecosystems. | ERP connection and synchronization. | +| AMQP Message Bus | Change notifications. | Event-driven synchronization, cache invalidation, async workflows. | + +The rest of this document focuses on the HTTP APIs: **OData** and **REST API**. + +## 4. Base URLs and environments + +### 4.1 Observed base host + +The public examples consistently use: + +```text +https://portal.mobilefieldreport.com +``` + +### 4.2 Resource roots + +| Root | Confidence | Meaning | +|---|---:|---| +| `https://portal.mobilefieldreport.com/odata` | Strongly indicated | OData resource root for entity CRUD and queries. | +| `https://portal.mobilefieldreport.com/mfr` | Confirmed | REST operation root for mfr-specific operations. | + +### 4.3 Recommended SDK configuration + +Do not hard-code the host. Provide configuration: + +```ts +export interface MfrClientConfig { + baseUrl?: string; // default: https://portal.mobilefieldreport.com + odataPath?: string; // default: /odata + restPath?: string; // default: /mfr + username: string; + password: string; + timeoutMs?: number; // default: 30000 + userAgent?: string; +} +``` + +## 5. Authentication and headers + +### 5.1 Authentication + +Public implementation evidence indicates **HTTP Basic Authentication** using a username and password. Confirm against the tenant's current Postman collection before production release. + +Recommended client behavior: + +- Use HTTPS only. +- Store credentials in a secrets manager or environment variables. +- Never log the raw `Authorization` header. +- Support tenant-specific credentials. +- Fail fast if credentials are missing. + +Example environment variables: + +```bash +MFR_BASE_URL="https://portal.mobilefieldreport.com" +MFR_USERNAME="service-account@example.com" +MFR_PASSWORD="***" +``` + +### 5.2 Default headers + +For JSON operations: + +```http +Accept: application/json +Content-Type: application/json +Authorization: Basic +``` + +For file upload: + +```http +Accept: application/json +Content-Type: multipart/form-data; boundary= +Authorization: Basic +``` + +Let the HTTP client generate the `multipart/form-data` boundary. + +## 6. OData conventions + +### 6.1 Entity paths and IDs + +Observed OData entity URLs use the format: + +```text +/odata/ServiceObjects(9875849220L) +/odata/Companies(1234567890L) +``` + +Implementation guidance: + +- Treat entity IDs as **64-bit numeric identifiers**. +- Use `string` or `bigint` in JavaScript/TypeScript to avoid precision loss. +- Public examples append an `L` suffix to path IDs. Preserve this suffix when constructing OData entity URLs unless live metadata proves otherwise. +- Some list operations support filtering by `ExternalId`. + +Recommended TypeScript ID type: + +```ts +export type MfrId = string; // store as decimal string; append L only in URL path builder +``` + +### 6.2 OData query parameters + +Use standard OData query options where supported: + +| Option | Use | +|---|---| +| `$filter` | Server-side filtering. | +| `$select` | Reduce response fields. | +| `$expand` | Load related entities, e.g. `Contacts`. | +| `$orderby` | Sort results. | +| `$top` | Limit page size. | +| `$skip` | Offset pagination. | +| `$count` | Ask for total count if the service supports it. | + +### 6.3 Filtering examples + +```text +/odata/Companies?$filter=ExternalId eq 'CUST-10001' +/odata/ServiceObjects?$filter=ExternalId eq 'SO-20002' +/odata/Appointments?$filter=StartDateTime ge datetime'2026-06-01T00:00:00Z' +/odata/Contacts?$filter=CompanyId eq 1234567890L&$expand=Company +``` + +Implementation guidance: + +- URL-encode query parameters. +- Escape single quotes in OData string literals by doubling them: `O'Brien` -> `'O''Brien'`. +- Treat datetime literal syntax as implementation-specific until verified. Public integration guidance indicates `datetime'YYYY-MM-DDTHH:mm:ssZ'`. +- Prefer ISO 8601 UTC timestamps in generated code. + +### 6.4 Pagination + +The public OData implementation evidence uses `$top` and `$skip`. + +Recommended default pagination strategy: + +```ts +const pageSize = 100; +for (let skip = 0; ; skip += pageSize) { + const page = await client.listCompanies({ top: pageSize, skip }); + if (page.length === 0) break; + yield* page; + if (page.length < pageSize) break; +} +``` + +Avoid assuming server-side continuation tokens unless discovered in live responses. + +### 6.5 Updating entities + +For OData updates, verify the supported method in the current tenant. Common OData patterns are: + +- `POST /odata/EntitySet` to create. +- `GET /odata/EntitySet(idL)` to read. +- `PUT /odata/EntitySet(idL)` or `PATCH /odata/EntitySet(idL)` to update. +- `DELETE /odata/EntitySet(idL)` to delete. + +If the Postman collection exposes only some verbs, implement only the exposed verbs. + +## 7. Domain model overview + +The core mfr domain is field-service execution: service requests/jobs, service objects/locations, customers/companies, appointments, technicians/users, contacts, item types/products, documents, checklists, reports, and billing/time data. + +### 7.1 Core entities + +| Entity | Likely OData set | Description | Confidence | +|---|---|---|---:| +| Company / Customer | `Companies` | Customer organization or physical person. | Strongly indicated | +| Contact | `Contacts` | Contact person with telephone, mobile, and email. | Strongly indicated | +| Service Request / Job | `ServiceRequests` | Work order/job/task container. | Confirmed / strongly indicated | +| Service Object / Location | `ServiceObjects` | Installed object, location, asset, or service site. | Confirmed / strongly indicated | +| Appointment | `Appointments` | Scheduled visit/time slot, optionally linked to request, service object, contact, technician. | Strongly indicated | +| Item Type / Product | `ItemTypes` | Product, article, service item, stock/material type. | Strongly indicated | +| Document | REST document operations | File upload and association. | Strongly indicated | +| User / Technician | `Users` or equivalent | Technician / mfr user account. | Strongly indicated | +| Tags | unknown | Labels attached to jobs/entities. | Strongly indicated by ecosystem connectors | +| Reports | REST report operation | Generate report by job and report definition code. | Strongly indicated by ecosystem connectors | + +### 7.2 Customer / Company model + +Observed and inferred fields: + +```ts +export interface MfrCompany { + Id?: MfrId; + Name: string; + ExternalId?: string; + IsPhysicalPerson?: boolean | 0 | 1; + Location?: MfrLocation; + MainContact?: MfrContact; + SupportTelephone?: string; + SupportFax?: string; + SupportMail?: string; + Note?: string; +} +``` + +### 7.3 Location model + +```ts +export interface MfrLocation { + Id?: MfrId; + AddressString?: string; + Postal?: string; + City?: string; + Country?: string; // e.g. DE + Latitude?: number; + Longitude?: number; +} +``` + +### 7.4 Contact model + +```ts +export interface MfrContact { + Id?: MfrId; + FirstName?: string; + LastName?: string; + Telephone?: string; + MobilePhone?: string; + Email?: string; + CompanyId?: MfrId; +} +``` + +### 7.5 Service object model + +```ts +export interface MfrServiceObject { + Id?: MfrId; + Name: string; + ExternalId?: string; + CompanyId?: MfrId; + Location?: MfrLocation; + Contacts?: MfrContact[]; + Country?: string; + CreateGeoLocation?: boolean; + CreateFromServiceRequestTemplateId?: MfrId; +} +``` + +### 7.6 Service request / job model + +```ts +export type ServiceRequestState = + | 'ReadyForScheduling' + | 'Draft' + | 'Scheduled' + | 'InProgress' + | 'Completed' + | 'Cancelled' + | string; // keep extensible because tenant values may vary + +export interface MfrServiceRequest { + Id?: MfrId; + Name: string; + Description?: string; + ExternalId?: string; + CustomerId?: MfrId; + Customer?: MfrCompany; + State?: ServiceRequestState; + ServiceObjects?: MfrServiceObject[]; + Appointments?: MfrAppointment[]; +} +``` + +### 7.7 Appointment model + +```ts +export interface MfrAppointment { + Id?: MfrId; + ContactId?: MfrId; + ServiceRequestId?: MfrId; + ServiceObjectId?: MfrId; + StartDateTime: string; // ISO 8601 + EndDateTime: string; // ISO 8601 + Type?: string; + Description?: string; + TechnicianUsername?: string; +} +``` + +### 7.8 Item type / product model + +```ts +export interface MfrItemType { + Id?: MfrId; + NameOrNumber: string; + ExternalId?: string; + UnitId?: MfrId; + Type?: string; + Costs?: number; + Price?: number; + Manufacture?: string; + VAT?: number; + Description?: string; + GlobalTradeItemNr?: string; +} +``` + +## 8. Confirmed REST operation: deep create service request + +### 8.1 Endpoint + +```http +POST /mfr/ServiceRequest/Deep +Host: portal.mobilefieldreport.com +Content-Type: application/json +Accept: application/json +``` + +Full observed URL: + +```text +https://portal.mobilefieldreport.com/mfr/ServiceRequest/Deep +``` + +### 8.2 Purpose + +Create a service request/job together with nested customer and service-object data in one operation. + +### 8.3 Important field behavior + +`CreateFromServiceRequestTemplateId` determines which order/service-request template is used when creating the order. The template ID is found in mfr administration under templates (`Verwaltung > Vorlagen`) according to the official wiki example. + +### 8.4 Request body example + +```json +{ + "Name": "Auftragsbezeichnung", + "Description": "Auftragsbeschreibung", + "Customer": { + "Id": 0, + "IsPhysicalPerson": 1, + "ExternalId": "543", + "Name": "Frank Service GmbH", + "Location": { + "Postal": "23423", + "AddressString": "Dorfstrasse 3", + "City": "Leipzig" + } + }, + "State": "ReadyForScheduling", + "ServiceObjects": [ + { + "Id": 0, + "CreateFromServiceRequestTemplateId": 2342342, + "CreateGeoLocation": true, + "Country": "DE", + "Contacts": [ + { + "FirstName": "Frank", + "LastName": "Peterson", + "Telephone": "023423", + "MobilePhone": "234234", + "Email": "test@test.de" + } + ], + "Name": "Service object name", + "ExternalId": "28", + "Location": { + "AddressString": "Gleisstrasse 2", + "Postal": "04229", + "City": "Leipzig" + } + } + ] +} +``` + +### 8.5 Response body + +The exact response shape must be verified from the current Postman collection or live tenant. A robust client should support these common response patterns: + +```ts +export type DeepCreateServiceRequestResponse = + | MfrServiceRequest + | { Id: MfrId; ServiceRequestId?: MfrId; [key: string]: unknown } + | { value: MfrServiceRequest }; +``` + +### 8.6 Validation rules for generated clients + +- Require `Name`. +- Require either a nested `Customer` or a known `CustomerId` if tenant supports it. +- Require at least one service object when using the nested official example flow. +- Validate email format where provided, but do not reject missing contact fields unless the tenant requires them. +- Use integer/decimal-string template IDs, not floating point. +- Support German and international address formats. + +## 9. Strongly indicated REST operation: document upload and create + +### 9.1 Endpoint + +```http +POST /mfr/Document/UploadAndCreate +Host: portal.mobilefieldreport.com +Content-Type: multipart/form-data +Accept: application/json +``` + +Full observed URL: + +```text +https://portal.mobilefieldreport.com/mfr/Document/UploadAndCreate +``` + +### 9.2 Purpose + +Upload a document file and create an mfr document record, usually to associate the document with a job, service request, service object, or another entity. + +### 9.3 Multipart fields + +Public implementation evidence shows a multipart request containing: + +| Field | Type | Required | Notes | +|---|---|---:|---| +| `file` | binary file | yes | The file content, with filename and content type. | +| `options` | JSON string | likely yes | Public implementation evidence includes `{ "filename": "..." }`. Tenant/Postman may define additional association fields. | + +### 9.4 Example multipart pseudo-code + +```ts +const form = new FormData(); +form.append('file', fileBuffer, { + filename: 'report.pdf', + contentType: 'application/pdf' +}); +form.append('options', JSON.stringify({ + filename: 'report.pdf' + // Verify exact association fields in current Postman docs: + // serviceRequestId, jobId, entityId, entityType, description, etc. +})); + +await http.post('/mfr/Document/UploadAndCreate', form, { + headers: form.getHeaders() +}); +``` + +### 9.5 Client design guidance + +Because the public text does not expose the complete `options` schema, generate an extensible type: + +```ts +export interface UploadDocumentOptions { + filename?: string; + serviceRequestId?: MfrId; + jobId?: MfrId; + serviceObjectId?: MfrId; + entityId?: MfrId; + entityType?: string; + description?: string; + [key: string]: unknown; +} +``` + +Then expose: + +```ts +uploadDocument(file: Blob | Buffer | Readable, options: UploadDocumentOptions): Promise +``` + +## 10. OData endpoint catalog + +This catalog lists endpoints that are confirmed or strongly indicated by public evidence. Verify all mutable operations against the current Postman collection before production use. + +### 10.1 Companies + +**Entity set:** `Companies` +**Purpose:** customer/company master data. + +```http +GET /odata/Companies?$top=100&$skip=0&$filter=ExternalId eq 'CUST-001' +GET /odata/Companies({id}L) +POST /odata/Companies +PUT/PATCH/DELETE /odata/Companies({id}L) # verify +``` + +Common create/update body: + +```json +{ + "Name": "Example GmbH", + "ExternalId": "CUST-001", + "IsPhysicalPerson": false, + "Location": { + "AddressString": "Hauptstrasse 1", + "Postal": "10115", + "City": "Berlin", + "Country": "DE" + }, + "MainContact": { + "FirstName": "Erika", + "LastName": "Mustermann", + "Email": "erika@example.com", + "Telephone": "+49 30 123456" + }, + "SupportTelephone": "+49 30 55555", + "SupportMail": "support@example.com", + "Note": "Imported from ERP" +} +``` + +### 10.2 Contacts + +**Entity set:** `Contacts` +**Purpose:** people/contact data. + +```http +GET /odata/Contacts?$top=100&$skip=0&$expand=Company +GET /odata/Contacts({id}L) +POST /odata/Contacts # verify if exposed in current collection +PUT/PATCH/DELETE /odata/Contacts({id}L) # verify +``` + +Common fields: + +```json +{ + "FirstName": "Frank", + "LastName": "Peterson", + "Telephone": "023423", + "MobilePhone": "234234", + "Email": "frank.peterson@example.com", + "CompanyId": "1234567890" +} +``` + +### 10.3 Service objects + +**Entity set:** `ServiceObjects` +**Purpose:** service sites, assets, or locations connected to customers and jobs. + +```http +GET /odata/ServiceObjects?$top=100&$skip=0&$expand=Contacts +GET /odata/ServiceObjects({id}L)?$expand=Contacts +POST /odata/ServiceObjects +PUT/PATCH/DELETE /odata/ServiceObjects({id}L) # verify +``` + +Common create body: + +```json +{ + "Name": "Boiler Room A", + "ExternalId": "SO-001", + "CompanyId": "1234567890", + "Location": { + "AddressString": "Gleisstrasse 2", + "Postal": "04229", + "City": "Leipzig", + "Country": "DE" + } +} +``` + +### 10.4 Service requests / jobs + +**Likely entity set:** `ServiceRequests` +**Purpose:** job/work-order/task data. + +```http +GET /odata/ServiceRequests?$top=100&$skip=0&$filter=ExternalId eq 'JOB-001' +GET /odata/ServiceRequests({id}L) +POST /mfr/ServiceRequest/Deep +POST /odata/ServiceRequests # verify if simple create is exposed +PUT/PATCH/DELETE /odata/ServiceRequests({id}L) # verify +``` + +Use the deep REST endpoint when nested creation is required. + +### 10.5 Appointments + +**Entity set:** `Appointments` +**Purpose:** scheduled service visits. + +```http +GET /odata/Appointments?$top=100&$skip=0 +GET /odata/Appointments({id}L) +POST /odata/Appointments +PUT/PATCH/DELETE /odata/Appointments({id}L) # verify +``` + +Common create body: + +```json +{ + "ContactId": "1234567890", + "StartDateTime": "2026-06-10T08:00:00Z", + "EndDateTime": "2026-06-10T10:00:00Z", + "Type": "Service" +} +``` + +Ecosystem connector evidence indicates appointment creation may also require appointment type, duration, start date/time, end date/time, description, and technician username in some flows. Treat technician assignment fields as tenant-specific until verified. + +### 10.6 Item types / products + +**Entity set:** `ItemTypes` +**Purpose:** product/article/service item master data. + +```http +GET /odata/ItemTypes?$top=100&$skip=0&$filter=ExternalId eq 'ITEM-001' +GET /odata/ItemTypes({id}L) +POST /odata/ItemTypes +PUT/PATCH/DELETE /odata/ItemTypes({id}L) # verify +``` + +Common create body: + +```json +{ + "NameOrNumber": "PUMP-001", + "ExternalId": "ERP-ITEM-001", + "UnitId": "1", + "Type": "Material", + "Costs": 12.50, + "Price": 19.95, + "Manufacture": "Example Manufacturer", + "VAT": 19, + "Description": "Replacement pump", + "GlobalTradeItemNr": "4000000000000" +} +``` + +### 10.7 Entity links / relationships + +Older public Postman/OData snippets show relationship operations like: + +```http +POST /odata/ServiceObjects({serviceObjectId}L)/$links/Contacts +``` + +The exact request body for link creation must be verified. Common OData link bodies use a URI reference pattern such as: + +```json +{ + "uri": "https://portal.mobilefieldreport.com/odata/Contacts(1234567890L)" +} +``` + +Do not implement link writes without validating the body shape against live Postman documentation. + +## 11. Common workflows + +### 11.1 Create a new customer and service request in one call + +Preferred when using the official deep-create flow: + +1. Build nested customer object. +2. Build at least one service object with location and contact. +3. Add `CreateFromServiceRequestTemplateId` if a template should be applied. +4. Set `State` to an accepted state such as `ReadyForScheduling`. +5. `POST /mfr/ServiceRequest/Deep`. +6. Store returned mfr IDs and external IDs in the source system. + +### 11.2 Synchronize companies from ERP + +1. For each ERP customer, query by `ExternalId`: + `GET /odata/Companies?$filter=ExternalId eq 'ERP-123'`. +2. If found, update the existing company if update is supported. +3. If not found, create via `POST /odata/Companies`. +4. Store the returned mfr `Id` as a cross-reference. +5. Use rate limiting and retry on transient 5xx/429 errors. + +### 11.3 Create service object under an existing customer + +1. Resolve customer/company by external ID. +2. `POST /odata/ServiceObjects` with `CompanyId` and `Location`. +3. Optionally attach contacts through nested data or OData link operations if supported. +4. Query with `$expand=Contacts` to verify relationships. + +### 11.4 Create appointment for an existing job + +1. Resolve service request/job ID. +2. Resolve technician/user if required by tenant. +3. Resolve appointment type if tenant requires enumerated types. +4. `POST /odata/Appointments` with start/end time and links. +5. Read back the appointment and verify assigned technician/status. + +### 11.5 Upload document for a job + +1. Read file from source system. +2. Build multipart form with `file` and `options`. +3. Include `filename` and association fields required by tenant/Postman collection. +4. `POST /mfr/Document/UploadAndCreate`. +5. Store returned document ID. +6. Read job/document relationship if an endpoint is available. + +## 12. Error handling and retries + +### 12.1 Expected HTTP status classes + +| Status | Meaning | Client behavior | +|---:|---|---| +| 200 | Success, often for reads or operations returning an object. | Parse JSON. | +| 201 | Created. | Parse JSON and capture created ID. | +| 204 | Success without body. | Return void/success. | +| 400 | Invalid payload or query. | Do not retry; surface validation details. | +| 401 | Missing/invalid credentials. | Do not retry blindly; refresh/reconfigure credentials. | +| 403 | Not authorized for tenant/resource. | Do not retry; escalate permissions. | +| 404 | Resource not found. | Return typed not-found error. | +| 409 | Conflict/duplicate/concurrent update. | Consider idempotency lookup by `ExternalId`. | +| 413 | Upload too large. | Surface file size limit. | +| 415 | Unsupported media type. | Check `Content-Type` and file MIME type. | +| 429 | Rate limited. | Retry with backoff if allowed. | +| 500-599 | Server/transient errors. | Retry idempotent operations with backoff. | + +### 12.2 Error parser + +Generated clients should not assume a single error body shape. Implement a permissive parser: + +```ts +export interface MfrApiErrorBody { + error?: unknown; + message?: string; + Message?: string; + details?: unknown; + [key: string]: unknown; +} +``` + +### 12.3 Retry policy + +Recommended default: + +- Retry only GET and explicitly idempotent operations by default. +- Retry 429 and 5xx. +- Use exponential backoff with jitter. +- Never retry file uploads automatically unless the caller opts in. +- Before retrying create operations, search by `ExternalId` to avoid duplicates. + +## 13. Idempotency and external IDs + +Many integration flows include `ExternalId`. Treat `ExternalId` as the source-system cross-reference. + +Recommended create-or-update pattern: + +```ts +async function upsertCompany(input: MfrCompany): Promise { + if (!input.ExternalId) throw new Error('ExternalId is required for idempotent upsert'); + const existing = await client.findCompanyByExternalId(input.ExternalId); + if (existing) return client.updateCompany(existing.Id!, input); + return client.createCompany(input); +} +``` + +If update is not available or not verified, implement create-only plus duplicate detection. + +## 14. Time zones and date fields + +Use UTC ISO 8601 strings for API payloads unless tenant docs specify local time. Store the user's/local timezone separately if required for scheduling UX. + +Recommended conversion: + +```ts +const startUtc = zonedTimeToUtc('2026-06-10 08:00', 'Europe/Berlin').toISOString(); +``` + +Payload example: + +```json +{ + "StartDateTime": "2026-06-10T06:00:00Z", + "EndDateTime": "2026-06-10T08:00:00Z" +} +``` + +## 15. Security and privacy requirements + +Generated integrations should follow these requirements: + +- Use HTTPS only. +- Keep API credentials out of code and logs. +- Redact `Authorization`, password fields, file contents, and personal data in logs. +- Minimize `$expand` and `$select` to only needed fields. +- Avoid exporting unnecessary personal data from contacts and appointments. +- Add an audit trail for create/update/delete operations. +- Use service accounts with least privilege when possible. +- Treat uploaded files as potentially sensitive. + +## 16. TypeScript SDK blueprint + +### 16.1 Client class + +```ts +export class MfrClient { + constructor(private config: MfrClientConfig) {} + + // REST operations + createServiceRequestDeep(input: DeepCreateServiceRequestRequest): Promise; + uploadDocument(file: Uploadable, options: UploadDocumentOptions): Promise; + + // Companies + listCompanies(query?: ODataQuery): Promise; + getCompany(id: MfrId): Promise; + findCompanyByExternalId(externalId: string): Promise; + createCompany(input: MfrCompany): Promise; + updateCompany(id: MfrId, input: Partial): Promise; + + // Contacts + listContacts(query?: ODataQuery): Promise; + getContact(id: MfrId): Promise; + + // Service objects + listServiceObjects(query?: ODataQuery): Promise; + getServiceObject(id: MfrId, expand?: string[]): Promise; + createServiceObject(input: MfrServiceObject): Promise; + + // Appointments + listAppointments(query?: ODataQuery): Promise; + getAppointment(id: MfrId): Promise; + createAppointment(input: MfrAppointment): Promise; + + // Item types + listItemTypes(query?: ODataQuery): Promise; + getItemType(id: MfrId): Promise; + findItemTypeByExternalId(externalId: string): Promise; + createItemType(input: MfrItemType): Promise; +} +``` + +### 16.2 URL builders + +```ts +function formatMfrId(id: MfrId): string { + const value = String(id).replace(/L$/, ''); + if (!/^\d+$/.test(value)) throw new Error(`Invalid MFR numeric id: ${id}`); + return `${value}L`; +} + +function entityUrl(entitySet: string, id: MfrId): string { + return `/odata/${entitySet}(${formatMfrId(id)})`; +} +``` + +### 16.3 OData query builder + +```ts +export interface ODataQuery { + filter?: string; + select?: string[]; + expand?: string[]; + orderby?: string; + top?: number; + skip?: number; + count?: boolean; +} + +function buildODataQuery(q: ODataQuery = {}): string { + const params = new URLSearchParams(); + if (q.filter) params.set('$filter', q.filter); + if (q.select?.length) params.set('$select', q.select.join(',')); + if (q.expand?.length) params.set('$expand', q.expand.join(',')); + if (q.orderby) params.set('$orderby', q.orderby); + if (q.top != null) params.set('$top', String(q.top)); + if (q.skip != null) params.set('$skip', String(q.skip)); + if (q.count != null) params.set('$count', String(q.count)); + const s = params.toString(); + return s ? `?${s}` : ''; +} + +function odataStringLiteral(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} +``` + +### 16.4 HTTP implementation requirements + +- Centralize HTTP transport. +- Add per-request timeout. +- Parse JSON only when `Content-Type` indicates JSON and body is non-empty. +- Include request ID/correlation ID in logs if server returns one. +- Throw typed errors with `status`, `method`, `url`, and redacted response body. +- Unit-test URL encoding and ID formatting. + +## 17. OpenAPI starter definition + +The following OpenAPI fragment is intentionally conservative. It models confirmed and strongly indicated routes but leaves unverified response schemas extensible. + +```yaml +openapi: 3.1.0 +info: + title: mfr Mobile Field Report Integration API + version: 0.1.0-draft + description: Draft integration contract. Verify against current mfr Postman collection and tenant metadata. +servers: + - url: https://portal.mobilefieldreport.com +security: + - basicAuth: [] +components: + securitySchemes: + basicAuth: + type: http + scheme: basic + schemas: + MfrId: + type: string + pattern: '^\\d+$' + Location: + type: object + additionalProperties: true + properties: + AddressString: { type: string } + Postal: { type: string } + City: { type: string } + Country: { type: string } + Contact: + type: object + additionalProperties: true + properties: + Id: { $ref: '#/components/schemas/MfrId' } + FirstName: { type: string } + LastName: { type: string } + Telephone: { type: string } + MobilePhone: { type: string } + Email: { type: string, format: email } + Company: + type: object + additionalProperties: true + required: [Name] + properties: + Id: { $ref: '#/components/schemas/MfrId' } + Name: { type: string } + ExternalId: { type: string } + IsPhysicalPerson: + oneOf: + - type: boolean + - type: integer + enum: [0, 1] + Location: { $ref: '#/components/schemas/Location' } + MainContact: { $ref: '#/components/schemas/Contact' } + ServiceObject: + type: object + additionalProperties: true + required: [Name] + properties: + Id: { $ref: '#/components/schemas/MfrId' } + Name: { type: string } + ExternalId: { type: string } + CompanyId: { $ref: '#/components/schemas/MfrId' } + Location: { $ref: '#/components/schemas/Location' } + Contacts: + type: array + items: { $ref: '#/components/schemas/Contact' } + DeepCreateServiceRequestRequest: + type: object + additionalProperties: true + required: [Name] + properties: + Name: { type: string } + Description: { type: string } + State: { type: string } + Customer: { $ref: '#/components/schemas/Company' } + ServiceObjects: + type: array + items: { $ref: '#/components/schemas/ServiceObject' } +paths: + /mfr/ServiceRequest/Deep: + post: + operationId: createServiceRequestDeep + summary: Create a service request with nested customer and service objects + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DeepCreateServiceRequestRequest' + responses: + '200': + description: Created or returned service request + content: + application/json: + schema: + type: object + additionalProperties: true + '201': + description: Created + /mfr/Document/UploadAndCreate: + post: + operationId: uploadAndCreateDocument + summary: Upload a document and create a document record + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file, options] + properties: + file: + type: string + format: binary + options: + type: string + description: JSON string with filename and association fields. Verify schema in Postman. + responses: + '200': + description: Uploaded document + content: + application/json: + schema: + type: object + additionalProperties: true + /odata/Companies: + get: + operationId: listCompanies + parameters: + - name: $filter + in: query + schema: { type: string } + - name: $top + in: query + schema: { type: integer, minimum: 1 } + - name: $skip + in: query + schema: { type: integer, minimum: 0 } + responses: + '200': + description: Company collection + post: + operationId: createCompany + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/Company' } + responses: + '200': { description: Created company } + '201': { description: Created company } + /odata/Companies({id}L): + get: + operationId: getCompany + parameters: + - name: id + in: path + required: true + schema: { $ref: '#/components/schemas/MfrId' } + responses: + '200': { description: Company } + /odata/ServiceObjects: + get: + operationId: listServiceObjects + parameters: + - name: $filter + in: query + schema: { type: string } + - name: $expand + in: query + schema: { type: string } + responses: + '200': { description: Service object collection } + post: + operationId: createServiceObject + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/ServiceObject' } + responses: + '200': { description: Created service object } + '201': { description: Created service object } +``` + +## 18. AI coding prompt to generate an SDK + +Use this prompt with an AI coding tool after adding tenant-specific Postman details. + +```text +Build a TypeScript SDK for the mfr Mobile Field Report API. + +Requirements: +- Use Basic Auth over HTTPS. +- Make baseUrl configurable; default to https://portal.mobilefieldreport.com. +- Use /odata for entity CRUD and /mfr for special REST operations. +- Treat IDs as strings because mfr IDs can exceed JavaScript safe integer range. +- Append L to numeric OData path IDs, e.g. /odata/Companies(123L). +- Implement OData query builder supporting $filter, $select, $expand, $orderby, $top, $skip, $count. +- Implement createServiceRequestDeep using POST /mfr/ServiceRequest/Deep. +- Implement uploadDocument using multipart/form-data POST /mfr/Document/UploadAndCreate with fields file and options. +- Implement Companies, Contacts, ServiceObjects, Appointments, and ItemTypes list/get/create methods. +- Mark update/delete methods experimental unless verified in Postman. +- Include typed errors, retries for GET on 429/5xx, redacted logging, and tests for URL building. +- Do not assume exact response bodies; parse extensibly with additionalProperties. +``` + +## 19. Test plan + +### 19.1 Unit tests + +| Test | Expected result | +|---|---| +| `formatMfrId('123')` | `123L` | +| `formatMfrId('123L')` | `123L` | +| `formatMfrId('abc')` | throws validation error | +| OData string literal for `O'Brien` | `'O''Brien'` | +| Query builder with filter and expand | URL-encoded `$filter` and `$expand` parameters | +| Empty JSON response | returns `undefined` / void without JSON parse error | +| Error response with `Message` | typed error includes readable message | +| Upload document | multipart contains `file` and `options` fields | + +### 19.2 Integration tests + +Run these against a sandbox/test tenant: + +1. Authenticate with invalid credentials and confirm 401 handling. +2. List companies with `$top=1`. +3. Create company with unique `ExternalId`. +4. Query same company by `ExternalId`. +5. Create service object linked to company. +6. Create service request with `/mfr/ServiceRequest/Deep` using a test template ID. +7. Upload a small text file to `/mfr/Document/UploadAndCreate`. +8. Attempt duplicate create and confirm idempotency behavior. +9. Test pagination with `$top` and `$skip`. +10. Verify datetime filters on appointments. + +### 19.3 Production readiness checklist + +- Current Postman collection exported and archived. +- Tenant-specific base URL confirmed. +- Authentication method confirmed. +- All required fields for create operations confirmed. +- Template IDs documented. +- Appointment type/user assignment semantics documented. +- Document upload `options` schema confirmed. +- Maximum upload size confirmed. +- Rate limit behavior confirmed. +- Update/delete support confirmed or intentionally disabled. +- Data protection and logging rules approved. + +## 20. Known gaps to verify + +The following must be verified before a production-grade implementation is declared complete: + +1. Exact authentication method and whether API tokens are supported in addition to Basic Auth. +2. Current Postman collection endpoint list and examples. +3. Exact response bodies for create/update/delete and deep-create operations. +4. Whether OData returns plain arrays, `{ value: [...] }`, or another wrapper for all list endpoints. +5. Whether updates require `PUT`, `PATCH`, merge semantics, or ETags. +6. Whether delete operations are enabled for each entity. +7. Whether OData metadata (`$metadata`) is publicly available and complete. +8. Exact enum values for `State`, appointment `Type`, item `Type`, units, tags, and report definitions. +9. Exact document upload `options` schema and association model. +10. AMQP connection details and event schema. +11. UGL article import file format. +12. Navision/Dynamics kit installation and payload semantics. + +## 21. Recommended next implementation sequence + +1. Import the current Postman collection into the repository as documentation only. +2. Generate a minimal OpenAPI definition from verified endpoints. +3. Implement transport, authentication, error parser, and OData query builder. +4. Implement read-only list/get methods first. +5. Implement idempotent create flows using `ExternalId`. +6. Implement `/mfr/ServiceRequest/Deep` with tenant-provided template IDs. +7. Implement document upload after verifying `options` schema. +8. Add sandbox integration tests. +9. Add production-safe logging and credential handling. +10. Freeze a versioned SDK contract and publish. + +## 22. cURL examples + +### 22.1 List service objects with contacts + +```bash +curl -u "$MFR_USERNAME:$MFR_PASSWORD" \ + "https://portal.mobilefieldreport.com/odata/ServiceObjects?%24top=10&%24expand=Contacts" +``` + +### 22.2 Get a single service object + +```bash +curl -u "$MFR_USERNAME:$MFR_PASSWORD" \ + "https://portal.mobilefieldreport.com/odata/ServiceObjects(9875849220L)?%24expand=Contacts" +``` + +### 22.3 Create service request deeply + +```bash +curl -u "$MFR_USERNAME:$MFR_PASSWORD" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -X POST \ + "https://portal.mobilefieldreport.com/mfr/ServiceRequest/Deep" \ + --data @deep-service-request.json +``` + +### 22.4 Upload document + +```bash +curl -u "$MFR_USERNAME:$MFR_PASSWORD" \ + -X POST \ + "https://portal.mobilefieldreport.com/mfr/Document/UploadAndCreate" \ + -F "file=@report.pdf;type=application/pdf" \ + -F 'options={"filename":"report.pdf"}' +``` + +## 23. Reference URLs + +- Official mfr wiki: `https://faq.mobilefieldreport.com/de/wiki/beschreibung-schnittstelle` +- Public Postman documentation supplied: `https://documenter.getpostman.com/view/6932380/2sB3dWsn6U` +- OData overview: `https://www.odata.org/` +- OData query option documentation: Microsoft Learn OData query option documentation +- Public n8n community/API discussion for document upload +- Public mfr ecosystem connector references, used only as non-authoritative implementation evidence + +## 24. Final note for AI coding tools + +When generating code, distinguish between **transport mechanics** and **business certainty**. The transport mechanics (Basic Auth, OData URL building, JSON requests, multipart upload) can be implemented generically. Business-specific fields, enums, template IDs, and document association fields must remain configurable until confirmed against the current tenant and the live Postman collection. diff --git a/MFR_RESTClient/MFRClient.cs b/MFR_RESTClient/MFRClient.cs index 2b075d3..3eb8ceb 100644 --- a/MFR_RESTClient/MFRClient.cs +++ b/MFR_RESTClient/MFRClient.cs @@ -3,6 +3,7 @@ using System.Text.RegularExpressions; using System.Web; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Newtonsoft.Json.Linq; using RestSharp; using RestSharp.Authenticators; @@ -34,7 +35,9 @@ public class MFRClient : IDisposable _clientCredentials = credentials; _clientOptions = new RestClientOptions { - Authenticator = new HttpBasicAuthenticator(_clientCredentials.Username, _clientCredentials.Password) + Authenticator = new HttpBasicAuthenticator(_clientCredentials.Username, _clientCredentials.Password), + Timeout = TimeSpan.FromMilliseconds(config.TimeoutMs), + UserAgent = config.UserAgent }; _client = new RestClient(_clientOptions); } @@ -42,6 +45,7 @@ public class MFRClient : IDisposable public async Task ReadAnything(string address, bool throwErrorIfNotOk = true) { var request = new RestRequest(resource: address, method: Method.Get); + request.AddHeader("Accept", "application/json"); var response = await Execute(request, "ReadAnything", throwErrorIfNotOk); return response.Content ?? ""; } @@ -49,10 +53,37 @@ public class MFRClient : IDisposable public async Task ReadOData(string address, bool throwErrorIfNotOk = true) { var request = new RestRequest(resource: address, method: Method.Get); + request.AddHeader("Accept", "application/json"); var response = await Execute(request, "ReadOData", throwErrorIfNotOk); return new ODataEnvelope(response.Content, url: address); } + /// + /// Reads an OData collection and follows @odata.nextLink pages, aggregating + /// all value items into a single envelope (see interface doc §6.4 pagination). + /// + public async Task ReadODataAllPages(string address, int maxPages = 100, bool throwErrorIfNotOk = true) + { + var first = await ReadOData(address, throwErrorIfNotOk); + first.ConvertToArray(); + var all = first.Value as JArray ?? new JArray(); + var next = first.NextLink; + int pages = 1; + while (next != null && pages < maxPages) + { + var page = await ReadOData(next.ToString(), throwErrorIfNotOk); + page.ConvertToArray(); + if (page.Value is JArray arr) + foreach (var item in arr) all.Add(item); + next = page.NextLink; + pages++; + } + first.Value = all; + first.NextLink = null; + _logger.LogDebug("ReadODataAllPages aggregated {Count} items over {Pages} page(s) — {Address}", all.Count, pages, address); + return first; + } + public byte[]? GetFile(string address, bool throwErrorIfNotOk = true) { byte[]? data = null; @@ -84,19 +115,34 @@ public class MFRClient : IDisposable } /// - /// Executes a query against MFR. + /// Executes a request against MFR. Idempotent GET requests are retried on + /// transient failures (HTTP 429 / 5xx and network/timeout errors) using + /// exponential backoff with jitter, honouring Retry-After when present + /// (see interface doc §12.3). Non-GET requests are never auto-retried. /// private async Task Execute(RestRequest request, string message, bool throwErrorIfNotOk) { - var response = await _client.ExecuteAsync(request); + bool retryable = request.Method == Method.Get; + int maxAttempts = retryable ? Math.Max(1, ClientConfig.MaxRetries + 1) : 1; + RestResponse response = null!; - bool notFound = response.StatusCode == HttpStatusCode.InternalServerError - && (response.Content?.IndexOf("Sequence contains no elements", 0, StringComparison.InvariantCultureIgnoreCase) ?? -1) > -1; + for (int attempt = 1; attempt <= maxAttempts; attempt++) + { + response = await _client.ExecuteAsync(request); - if (throwErrorIfNotOk - && response.StatusCode != HttpStatusCode.OK - && response.StatusCode != HttpStatusCode.Created - && !notFound) + bool notFound = IsNotFound(response); + bool transient = !notFound && IsTransient(response); + + if (!transient || attempt == maxAttempts) break; + + var delay = BackoffDelay(attempt, response); + _logger.LogWarning("MFR transient failure: {Message} — status={Status}/{ResponseStatus}, retry {Attempt}/{Max} in {DelayMs}ms", + message, (int)response.StatusCode, response.ResponseStatus, attempt, maxAttempts - 1, (int)delay.TotalMilliseconds); + await Task.Delay(delay); + } + + bool ok = response.StatusCode is HttpStatusCode.OK or HttpStatusCode.Created or HttpStatusCode.NoContent; + if (throwErrorIfNotOk && !ok && !IsNotFound(response)) { _logger.LogWarning("Execute rest issue: {Message} — status={Status}, resource={Resource}", message, response.StatusDescription, request.Resource); @@ -106,6 +152,32 @@ public class MFRClient : IDisposable return response; } + /// MFR returns HTTP 500 "Sequence contains no elements" for empty single-entity reads; treat as not-found, not an error. + private static bool IsNotFound(RestResponse response) => + response.StatusCode == HttpStatusCode.InternalServerError + && (response.Content?.IndexOf("Sequence contains no elements", 0, StringComparison.InvariantCultureIgnoreCase) ?? -1) > -1; + + private static bool IsTransient(RestResponse response) + { + if (response.ResponseStatus is ResponseStatus.TimedOut or ResponseStatus.Error) return true; + int code = (int)response.StatusCode; + return code == 429 || (code >= 500 && code <= 599); + } + + private static TimeSpan BackoffDelay(int attempt, RestResponse response) + { + // Honour Retry-After (seconds) when the server provides it. + var retryAfter = response.Headers? + .FirstOrDefault(h => string.Equals(h.Name, "Retry-After", StringComparison.OrdinalIgnoreCase))?.Value?.ToString(); + if (int.TryParse(retryAfter, out var seconds) && seconds is > 0 and <= 120) + return TimeSpan.FromSeconds(seconds); + + // Exponential backoff (0.5s, 1s, 2s, …) capped at 10s, plus jitter. + double baseMs = Math.Min(10000, 500 * Math.Pow(2, attempt - 1)); + double jitter = Random.Shared.Next(0, 250); + return TimeSpan.FromMilliseconds(baseMs + jitter); + } + private void ThrowExceptionIfNecessary(RestResponse response, string location, string? customMessage = null) { if (!HideCustomExceptions) diff --git a/MFR_RESTClient/MFRClientModels.cs b/MFR_RESTClient/MFRClientModels.cs index a9dc19a..b88da65 100644 --- a/MFR_RESTClient/MFRClientModels.cs +++ b/MFR_RESTClient/MFRClientModels.cs @@ -4,12 +4,32 @@ namespace MFR_RESTClient; public class MFRClientConfig { + /// Default mfr host (see MFR_RESTClient/Docs/mfr_interface_description.md §4). + public const string DefaultHost = "https://portal.mobilefieldreport.com"; + + /// OData resource root (e.g. https://host/odata/). Kept for backward compatibility. public string BaseUrl { get; } = ""; + /// REST operation root (e.g. https://host/mfr/) for mfr-specific operations + /// such as deep-create and document upload (see interface doc §8, §9). + public string RestRoot { get; } = ""; + + /// HTTP timeout in milliseconds (interface doc §4.3 default: 30000). + public int TimeoutMs { get; set; } = 30000; + + /// User-Agent sent with requests. + public string UserAgent { get; set; } = "FuchsIntranet-MFRClient"; + + /// Transient-retry attempts for idempotent GET requests (429 / 5xx). 1 = no retry. + public int MaxRetries { get; set; } = 3; + public MFRClientConfig(string url) { BaseUrl = url.StartsWith("http") ? url : $"https://{url.Trim()}/odata/"; if (!BaseUrl.EndsWith("/")) BaseUrl += "/"; + // Derive the REST root (/mfr/) from the same scheme+host as the OData root. + try { var u = new Uri(BaseUrl); RestRoot = $"{u.Scheme}://{u.Authority}/mfr/"; } + catch { RestRoot = DefaultHost + "/mfr/"; } } public bool IsLogoutRequired { get; set; } = false; diff --git a/MFR_RESTClient/MFR_RESTClient.vbproj b/MFR_RESTClient/MFR_RESTClient.vbproj deleted file mode 100644 index 600c6e8..0000000 --- a/MFR_RESTClient/MFR_RESTClient.vbproj +++ /dev/null @@ -1,79 +0,0 @@ - - - net10.0 - Library - Empty - false - db-dev.processweb.de;Debug;Release;server02.processweb.de - - - MFR_RESTClient.xml - 42016,41999,42017,42018,42019,42032,42036,42020,42021,42022 - - - false - MFR_RESTClient.xml - 42016,41999,42017,42018,42019,42032,42036,42020,42021,42022 - - - - True - Application.myapp - - - True - True - Resources.resx - - - True - Settings.settings - True - - - - - MyApplicationCodeGenerator - Application.Designer.vb - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/MFR_RESTClient/MFR_RESTClient.vbproj.user b/MFR_RESTClient/MFR_RESTClient.vbproj.user deleted file mode 100644 index 9b86104..0000000 --- a/MFR_RESTClient/MFR_RESTClient.vbproj.user +++ /dev/null @@ -1,6 +0,0 @@ - - - - ShowAllFiles - - \ No newline at end of file diff --git a/MFR_RESTClient/MFR_RESTClient.xml b/MFR_RESTClient/MFR_RESTClient.xml deleted file mode 100644 index 18edfe7..0000000 --- a/MFR_RESTClient/MFR_RESTClient.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - MFR_RESTClient - - - - - Get or set a value indicating the exceptions thrown by the client should be hidden. - - - - - Construct with typed config and credentials. - - - - - Executes a query against MFR. - - - - - Cleanse HTML tags and other detritus from the string to make it plaintext. - - - - diff --git a/MFR_RESTClient/app.config b/MFR_RESTClient/app.config deleted file mode 100644 index f061b7a..0000000 --- a/MFR_RESTClient/app.config +++ /dev/null @@ -1,121 +0,0 @@ - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file