diff --git a/EPI2CrewbrainFile.slnx b/EPI2CrewbrainFile.slnx
new file mode 100644
index 0000000..2131a1b
--- /dev/null
+++ b/EPI2CrewbrainFile.slnx
@@ -0,0 +1,3 @@
+
+
+
diff --git a/EPI2CrewbrainFile/DocumentFile.cs b/EPI2CrewbrainFile/DocumentFile.cs
new file mode 100644
index 0000000..6389944
--- /dev/null
+++ b/EPI2CrewbrainFile/DocumentFile.cs
@@ -0,0 +1,4 @@
+namespace EPI2CrewbrainFile
+{
+ internal record DocumentFile(string Dateiname, string Dateiendung, string Datei);
+}
\ No newline at end of file
diff --git a/EPI2CrewbrainFile/Documents.cs b/EPI2CrewbrainFile/Documents.cs
new file mode 100644
index 0000000..7428741
--- /dev/null
+++ b/EPI2CrewbrainFile/Documents.cs
@@ -0,0 +1,79 @@
+// Documents.cs
+namespace EPI2CrewbrainFile
+{
+ using System;
+ using System.Collections.Generic;
+ using System.Globalization;
+ using Newtonsoft.Json;
+ using Newtonsoft.Json.Converters;
+
+ public partial class Documents
+ {
+ [JsonProperty("ID")]
+ public long Id { get; set; }
+
+ [JsonProperty("DokumentID")]
+ public long DokumentId { get; set; }
+
+ [JsonProperty("Zugehoerigkeit")]
+ public string Zugehoerigkeit { get; set; }
+
+ [JsonProperty("ZugehoerigkeitID")]
+ public long ZugehoerigkeitId { get; set; }
+
+ [JsonProperty("Sichtbarkeit")]
+ public long Sichtbarkeit { get; set; }
+
+ [JsonProperty("AngelegtID")]
+ public long AngelegtId { get; set; }
+
+ [JsonProperty("AngelegtDatum")]
+ public DateTimeOffset AngelegtDatum { get; set; }
+
+ [JsonProperty("GeaendertID")]
+ public long GeaendertId { get; set; }
+
+ [JsonProperty("GeaendertDatum")]
+ public DateTimeOffset GeaendertDatum { get; set; }
+
+ [JsonProperty("Geloescht")]
+ public object Geloescht { get; set; }
+
+ [JsonProperty("Dateiname")]
+ public string Dateiname { get; set; }
+
+ [JsonProperty("Dateiendung")]
+ public string Dateiendung { get; set; }
+
+ [JsonProperty("Groesse")]
+ public long Groesse { get; set; }
+
+ [JsonProperty("FremdKundenID")]
+ public long FremdKundenId { get; set; }
+
+ [JsonProperty("URL")]
+ public Uri Url { get; set; }
+
+ public static List FromJson(string json) =>
+ JsonConvert.DeserializeObject>(json, Converter.Settings);
+ }
+
+ public static class Serialize
+ {
+ public static string ToJson(this List self) =>
+ JsonConvert.SerializeObject(self, Converter.Settings);
+ }
+
+ internal static class Converter
+ {
+ public static readonly JsonSerializerSettings Settings = new JsonSerializerSettings
+ {
+ MetadataPropertyHandling = MetadataPropertyHandling.Ignore,
+ DateParseHandling = DateParseHandling.None,
+ Converters =
+ {
+ new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal }
+ },
+ };
+ }
+}
diff --git a/EPI2CrewbrainFile/EPI2CrewbrainFile.csproj b/EPI2CrewbrainFile/EPI2CrewbrainFile.csproj
new file mode 100644
index 0000000..542a5c6
--- /dev/null
+++ b/EPI2CrewbrainFile/EPI2CrewbrainFile.csproj
@@ -0,0 +1,28 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Always
+
+
+ Always
+
+
+
+
diff --git a/EPI2CrewbrainFile/Program.cs b/EPI2CrewbrainFile/Program.cs
new file mode 100644
index 0000000..2450ef5
--- /dev/null
+++ b/EPI2CrewbrainFile/Program.cs
@@ -0,0 +1,659 @@
+using log4net;
+using log4net.Config;
+using Microsoft.Extensions.Configuration;
+using RestSharp;
+using RestSharp.Authenticators;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.RegularExpressions;
+
+internal class Program
+{
+ private static readonly ILog Log = LogManager.GetLogger(typeof(Program));
+
+ static int Main(string[] args)
+ {
+ Console.OutputEncoding = Encoding.UTF8;
+ Console.InputEncoding = Encoding.UTF8;
+ ConfigureLogging();
+
+ AppDomain.CurrentDomain.UnhandledException += (_, e) =>
+ Log.Fatal("Unhandled exception", e.ExceptionObject as Exception);
+
+ try
+ {
+ var baseDir = AppContext.BaseDirectory;
+ var appSettingsPath = Path.Combine(baseDir, "appsettings.json");
+ var config = LoadConfig(baseDir);
+
+ var apiBase = (config["Crewbrain:ApiBase"] ?? "").TrimEnd('/');
+ if (string.IsNullOrWhiteSpace(apiBase))
+ throw new Exception("Missing config 'Crewbrain:ApiBase' (e.g. https://vt-media.crewbrain.com/api)");
+
+ // Paths
+ var angebotPath = config["Paths:Angebot"] ?? "";
+ var auftragPath = config["Paths:Auftrag"] ?? "";
+ var lieferscheinPath = config["Paths:Lieferschein"] ?? "";
+
+ // Upload settings (3 unterschiedliche TypeIDs / Flags)
+ var settingsAngebot = UploadSettings.FromConfig(config, "Crewbrain:Upload:Angebot");
+ var settingsAuftrag = UploadSettings.FromConfig(config, "Crewbrain:Upload:Auftrag");
+ var settingsLieferschein = UploadSettings.FromConfig(config, "Crewbrain:Upload:Lieferschein");
+
+ Log.Info("=== EPI2CrewbrainFile.Console started ===");
+ Log.Info($"Config path: {appSettingsPath}");
+ Log.Info($"Crewbrain ApiBase: {apiBase}");
+
+ Log.Info($"AngebotPath: {angebotPath} | TypeId={settingsAngebot.TypeId} | OverwriteSingleDocument={settingsAngebot.OverwriteSingleDocument}");
+ Log.Info($"AuftragPath: {auftragPath} | TypeId={settingsAuftrag.TypeId} | OverwriteSingleDocument={settingsAuftrag.OverwriteSingleDocument}");
+ Log.Info($"LieferscheinPath: {lieferscheinPath} | TypeId={settingsLieferschein.TypeId} | OverwriteSingleDocument={settingsLieferschein.OverwriteSingleDocument}");
+
+ // Access token (prompt once, store)
+ var accessToken = TokenAuthHelper.EnsureAccessToken(
+ config: config,
+ appSettingsPath: appSettingsPath,
+ log: Log,
+ apiBase: apiBase
+ );
+
+ Log.Info($"TokenAuth: AccessToken loaded (len={accessToken.Length})");
+
+ // Uploader service
+ var service = new UploaderService(apiBase, accessToken, appSettingsPath, Log);
+
+ // Process
+ ProcessFolder(folder: angebotPath, uploader: service, tag: "Angebot", settings: settingsAngebot);
+ ProcessFolder(folder: auftragPath, uploader: service, tag: "Auftrag", settings: settingsAuftrag);
+ ProcessFolder(folder: lieferscheinPath, uploader: service, tag: "Lieferschein", settings: settingsLieferschein);
+
+ Log.Info("=== EPI2CrewbrainFile.Console finished successfully ===");
+ return 0;
+ }
+ catch (Exception ex)
+ {
+ Log.Fatal("Fatal error", ex);
+ return 1;
+ }
+ finally
+ {
+ LogManager.Shutdown();
+ }
+ }
+
+ private static IConfigurationRoot LoadConfig(string baseDir)
+ => new ConfigurationBuilder()
+ .SetBasePath(baseDir)
+ .AddJsonFile("appsettings.json", optional: false, reloadOnChange: false)
+ .Build();
+
+ private static void ConfigureLogging()
+ {
+ Directory.CreateDirectory(Path.Combine(AppContext.BaseDirectory, "logs"));
+ var configFile = Path.Combine(AppContext.BaseDirectory, "log4net.config");
+ XmlConfigurator.Configure(LogManager.GetRepository(Assembly.GetEntryAssembly()!), new FileInfo(configFile));
+ }
+
+ private static void ProcessFolder(string folder, UploaderService uploader, string tag, UploadSettings settings)
+ {
+ if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder))
+ {
+ Log.Warn($"[{tag}] Folder missing: {folder}");
+ return;
+ }
+
+ var pdfs = new DirectoryInfo(folder).GetFiles("*.pdf");
+ Log.Info($"[{tag}] Found {pdfs.Length} PDF(s) in {folder}");
+
+ foreach (var file in pdfs)
+ {
+ try
+ {
+ var eventIdManual = ExtractEventIdManual(file.Name);
+ if (string.IsNullOrWhiteSpace(eventIdManual))
+ {
+ Log.Warn($"[{tag}] Could not extract EventIDManual from filename: {file.Name} (skip)");
+ continue;
+ }
+
+ Log.Info($"[{tag}] File '{file.Name}' -> EventIDManual={eventIdManual}");
+
+ var eventId = uploader.ResolveCrewbrainEventIdByManualId(eventIdManual);
+ if (eventId == null)
+ {
+ Log.Warn($"[{tag}] No CrewBrain event found for EventIDManual={eventIdManual}. File not moved.");
+ continue;
+ }
+
+ Log.Info($"[{tag}] Resolved EventIDManual={eventIdManual} -> CrewBrain ID={eventId}");
+
+ var filenameWithoutExt = Path.GetFileNameWithoutExtension(file.Name);
+ var ok = uploader.UploadDocumentToEvent(
+ eventId: eventId.Value,
+ typeId: settings.TypeId,
+ overwriteSingleDocument: settings.OverwriteSingleDocument,
+ fullPath: file.FullName,
+ filename: filenameWithoutExt,
+ filetype: file.Extension.TrimStart('.')
+ );
+
+ if (ok)
+ {
+ MoveToUploaded(folder, file.Name, tag);
+ }
+ else
+ {
+ Log.Warn($"[{tag}] Upload failed -> file NOT moved. ({file.Name})");
+ }
+ }
+ catch (Exception ex)
+ {
+ Log.Error($"[{tag}] Error processing '{file.FullName}'", ex);
+ }
+ }
+ }
+
+ ///
+ /// Robust: findet 3- bis 5-stellige IDs im Format NNN-NN / NNNN-NN / NNNNN-NN.
+ /// Wenn mehrere Treffer existieren, nimmt er den längsten (also bevorzugt 5-stellig vor 4-stellig vor 3-stellig).
+ ///
+ private static string ExtractEventIdManual(string fileName)
+ {
+ // Match: 3..5 digits - 2 digits, nicht Teil einer größeren Zahl
+ var matches = Regex.Matches(fileName, @"(? m.Groups[1].Value)
+ .OrderByDescending(s => s.Length) // bevorzugt 12345-01
+ .First();
+ }
+
+ private static void MoveToUploaded(string baseFolder, string fileName, string tag)
+ {
+ var uploadedDir = Path.Combine(baseFolder, "Uploaded");
+ Directory.CreateDirectory(uploadedDir);
+
+ var source = Path.Combine(baseFolder, fileName);
+ if (!File.Exists(source))
+ {
+ Log.Warn($"[{tag}] Move: source missing: {source}");
+ return;
+ }
+
+ var nameNoExt = Path.GetFileNameWithoutExtension(fileName);
+ var ext = Path.GetExtension(fileName); // inkl ".pdf"
+ if (string.IsNullOrWhiteSpace(ext)) ext = "";
+
+ var dest = Path.Combine(uploadedDir, fileName);
+ if (File.Exists(dest))
+ {
+ var stamp = DateTime.Now.ToString("yyyyMMddHHmmssffff");
+ var newName = $"{nameNoExt}_{stamp}{ext}";
+ dest = Path.Combine(uploadedDir, newName);
+ }
+
+ File.Move(source, dest);
+ Log.Info($"[{tag}] Moved to '{dest}'");
+ }
+
+ private sealed record UploadSettings(int TypeId, bool OverwriteSingleDocument)
+ {
+ public static UploadSettings FromConfig(IConfiguration cfg, string prefix)
+ {
+ int typeId = 0;
+ bool overwrite = false;
+
+ int.TryParse(cfg[$"{prefix}:TypeId"], out typeId);
+ bool.TryParse(cfg[$"{prefix}:OverwriteSingleDocument"], out overwrite);
+
+ return new UploadSettings(typeId, overwrite);
+ }
+ }
+}
+
+internal static class TokenAuthHelper
+{
+ public static string EnsureAccessToken(IConfiguration config, string appSettingsPath, ILog log, string apiBase)
+ {
+ apiBase = (apiBase ?? "").TrimEnd('/');
+
+ var token = (config["Crewbrain:TokenAuth:AccessToken"] ?? "").Trim();
+ if (!string.IsNullOrWhiteSpace(token))
+ {
+ log.Info("TokenAuth: Using stored AccessToken from appsettings.json");
+ return token;
+ }
+
+ log.Warn("TokenAuth: No AccessToken stored. Prompting for username/password to request one-time token...");
+
+ Console.Write("CrewBrain Username: ");
+ var user = (Console.ReadLine() ?? "").Trim();
+
+ var pass = ReadPassword("CrewBrain Password: ");
+
+ if (string.IsNullOrWhiteSpace(user) || string.IsNullOrWhiteSpace(pass))
+ throw new Exception("TokenAuth: Missing username/password; cannot request access token.");
+
+ var fetched = FetchAccessToken(apiBase, user, pass, log);
+ SaveAccessToken(appSettingsPath, fetched, log);
+
+ log.Info("TokenAuth: AccessToken saved to appsettings.json");
+ return fetched;
+ }
+
+ public static void ClearStoredToken(string appSettingsPath, ILog log)
+ {
+ try
+ {
+ var json = File.Exists(appSettingsPath) ? File.ReadAllText(appSettingsPath) : "";
+ var root = (JsonNode.Parse(json) as JsonObject) ?? new JsonObject();
+
+ var crew = root["Crewbrain"] as JsonObject;
+ var tokenAuth = crew?["TokenAuth"] as JsonObject;
+ if (tokenAuth == null) return;
+
+ tokenAuth["AccessToken"] = "";
+ tokenAuth["ClearedAtUtc"] = DateTime.UtcNow.ToString("o");
+
+ File.WriteAllText(appSettingsPath, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
+ log.Warn("TokenAuth: Stored token cleared in appsettings.json (will re-prompt next time).");
+ }
+ catch (Exception ex)
+ {
+ log.Warn("TokenAuth: Failed to clear token from appsettings.json", ex);
+ }
+ }
+
+ private static string FetchAccessToken(string apiBase, string user, string pass, ILog log)
+ {
+ var options = new RestClientOptions(apiBase.TrimEnd('/') + "/")
+ {
+ Authenticator = new HttpBasicAuthenticator(user, pass),
+ FollowRedirects = true
+ };
+ var client = new RestClient(options);
+
+ var req = new RestRequest("accesstoken", Method.Get);
+ req.AddHeader("Accept", "application/json");
+
+ var resp = client.Execute(req);
+
+ log.Info($"GET /accesstoken -> {(int)resp.StatusCode} {resp.StatusCode}");
+ if (!resp.IsSuccessful)
+ throw new Exception($"AccessToken request failed: {(int)resp.StatusCode} {resp.StatusCode} Body={resp.Content}");
+
+ var body = resp.Content ?? "";
+ var node = JsonNode.Parse(body) as JsonObject
+ ?? throw new Exception($"AccessToken response not a JSON object: {body}");
+
+ var token = (node["Accesstoken"]?.GetValue() ?? "").Trim();
+ if (string.IsNullOrWhiteSpace(token))
+ throw new Exception($"TokenAuth: 'Accesstoken' missing in response: {body}");
+
+ log.Info($"TokenAuth: token received (len={token.Length})");
+ return token;
+ }
+
+ private static void SaveAccessToken(string appSettingsPath, string accessToken, ILog log)
+ {
+ JsonObject root;
+ try
+ {
+ var json = File.Exists(appSettingsPath) ? File.ReadAllText(appSettingsPath) : "";
+ root = (JsonNode.Parse(json) as JsonObject) ?? new JsonObject();
+ }
+ catch (Exception ex)
+ {
+ if (File.Exists(appSettingsPath))
+ {
+ var backupPath = appSettingsPath + ".broken_" + DateTime.Now.ToString("yyyyMMddHHmmss");
+ File.Copy(appSettingsPath, backupPath, overwrite: true);
+ log.Warn($"TokenAuth: appsettings.json invalid JSON. Backed up to: {backupPath}", ex);
+ }
+ root = new JsonObject();
+ }
+
+ var crew = root["Crewbrain"] as JsonObject ?? new JsonObject();
+ root["Crewbrain"] = crew;
+
+ var tokenAuth = crew["TokenAuth"] as JsonObject ?? new JsonObject();
+ crew["TokenAuth"] = tokenAuth;
+
+ tokenAuth["AccessToken"] = accessToken;
+ tokenAuth["SavedAtUtc"] = DateTime.UtcNow.ToString("o");
+
+ File.WriteAllText(appSettingsPath, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
+ }
+
+ private static string ReadPassword(string prompt)
+ {
+ Console.Write(prompt);
+ var sb = new StringBuilder();
+
+ while (true)
+ {
+ var key = Console.ReadKey(intercept: true);
+
+ if (key.Key == ConsoleKey.Enter)
+ {
+ Console.WriteLine();
+ break;
+ }
+
+ if (key.Key == ConsoleKey.Backspace)
+ {
+ if (sb.Length > 0)
+ {
+ sb.Length--;
+ Console.Write("\b \b");
+ }
+ continue;
+ }
+
+ if (!char.IsControl(key.KeyChar))
+ {
+ sb.Append(key.KeyChar);
+ Console.Write("*");
+ }
+ }
+
+ return sb.ToString();
+ }
+}
+
+internal class UploaderService
+{
+ private static readonly ILog Log = LogManager.GetLogger(typeof(UploaderService));
+
+ private readonly string _baseUrl; // https://.../api/
+ private string _accessToken; // X-API-KEY
+ private readonly string _appSettingsPath; // for clearing token on 401/403
+ private readonly ILog _rootLog;
+
+ public UploaderService(string apiBase, string accessToken, string appSettingsPath, ILog rootLog)
+ {
+ _baseUrl = (apiBase ?? "").TrimEnd('/') + "/";
+ _accessToken = (accessToken ?? "").Trim();
+ _appSettingsPath = appSettingsPath;
+ _rootLog = rootLog;
+ }
+
+ public int? ResolveCrewbrainEventIdByManualId(string eventIdManual)
+ => ResolveCrewbrainEventIdByManualIdInternal(eventIdManual, allowTokenRetry: true);
+
+ private int? ResolveCrewbrainEventIdByManualIdInternal(string eventIdManual, bool allowTokenRetry)
+ {
+ try
+ {
+ var client = new RestClient(new RestClientOptions(_baseUrl) { FollowRedirects = true });
+
+ var req = new RestRequest("v2/events", Method.Get);
+ req.AddHeader("Accept", "application/json");
+ req.AddHeader("X-API-KEY", _accessToken);
+ req.AddQueryParameter("EventIDManual", eventIdManual);
+
+ DebugLogRawRequest(client, req, bodyPreview: null);
+
+ var resp = client.Execute(req);
+ Log.Info($"GET v2/events?EventIDManual={eventIdManual} -> {(int)resp.StatusCode} {resp.StatusCode}");
+
+ if ((int)resp.StatusCode == 401 || (int)resp.StatusCode == 403)
+ {
+ Log.Error("Resolve: unauthorized/forbidden. Token might be invalid/expired.");
+
+ if (allowTokenRetry && TryReAuthToken())
+ return ResolveCrewbrainEventIdByManualIdInternal(eventIdManual, allowTokenRetry: false);
+
+ return null;
+ }
+
+ if (!resp.IsSuccessful)
+ {
+ var b = resp.Content ?? "";
+ if (b.Length > 1200) b = b.Substring(0, 1200) + "...(truncated)";
+ Log.Warn($"Resolve event failed: {(int)resp.StatusCode} {resp.StatusCode} Body={b}");
+ return null;
+ }
+
+ var body = resp.Content ?? "";
+ var root = JsonNode.Parse(body) as JsonObject;
+ var data = root?["data"] as JsonArray;
+
+ if (data == null || data.Count == 0)
+ {
+ Log.Warn($"Resolve event: no items for EventIDManual={eventIdManual}");
+ return null;
+ }
+
+ if (data.Count > 1)
+ Log.Warn($"Resolve event: multiple items ({data.Count}) for EventIDManual={eventIdManual}. Using first.");
+
+ var first = data[0] as JsonObject;
+ var idNode = first?["ID"];
+ if (idNode == null)
+ {
+ Log.Warn($"Resolve event: 'ID' missing in first item for EventIDManual={eventIdManual}");
+ return null;
+ }
+
+ return idNode.GetValue();
+ }
+ catch (Exception ex)
+ {
+ Log.Error($"ResolveCrewbrainEventIdByManualId failed (EventIDManual={eventIdManual})", ex);
+ return null;
+ }
+ }
+
+ public bool UploadDocumentToEvent(int eventId, int typeId, bool overwriteSingleDocument, string fullPath, string filename, string filetype)
+ => UploadDocumentToEventInternal(eventId, typeId, overwriteSingleDocument, fullPath, filename, filetype, allowTokenRetry: true);
+
+ private bool UploadDocumentToEventInternal(int eventId, int typeId, bool overwriteSingleDocument, string fullPath, string filename, string filetype, bool allowTokenRetry)
+ {
+ try
+ {
+ filename = (filename ?? "").Trim();
+ filetype = (filetype ?? "").Trim().TrimStart('.');
+
+ if (string.IsNullOrWhiteSpace(filename))
+ filename = Path.GetFileNameWithoutExtension(fullPath) ?? "";
+ if (string.IsNullOrWhiteSpace(filename))
+ throw new Exception("UploadDocumentToEvent: filename is empty.");
+
+ if (string.IsNullOrWhiteSpace(filetype))
+ filetype = "pdf";
+
+ var bytes = File.ReadAllBytes(fullPath);
+ var base64 = Convert.ToBase64String(bytes);
+
+ // API fordert "Filename" mandatory → wir geben hier den kompletten Dateinamen inkl. Extension mit,
+ // aber lassen dein "filename" (ohne Ext) drin lesbar.
+ var filenameWithExt = filename.EndsWith("." + filetype, StringComparison.OrdinalIgnoreCase)
+ ? filename
+ : $"{filename}.{filetype}";
+
+ var payload = new
+ {
+ Base64String = base64,
+ Filename = filenameWithExt,
+ Filetype = filetype
+ };
+
+ var client = new RestClient(new RestClientOptions(_baseUrl) { FollowRedirects = true });
+
+ var req = new RestRequest($"v2/events/{eventId}/additionalDatas/{typeId}/addDocument", Method.Put);
+ req.AddHeader("Accept", "application/json");
+ req.AddHeader("Content-Type", "application/json");
+ req.AddHeader("X-API-KEY", _accessToken);
+
+ if (overwriteSingleDocument)
+ req.AddQueryParameter("overwriteSingleDocument", "true");
+
+ var jsonBody = JsonSerializer.Serialize(payload);
+ req.AddStringBody(jsonBody, DataFormat.Json);
+
+ // DEBUG Raw Request (ohne Base64)
+ var bodyPreview = JsonSerializer.Serialize(new
+ {
+ Base64String = $"",
+ Filename = filenameWithExt,
+ Filetype = filetype
+ });
+ DebugLogRawRequest(client, req, bodyPreview);
+
+ var resp = client.Execute(req);
+ Log.Info($"PUT v2/events/{eventId}/additionalDatas/{typeId}/addDocument -> {(int)resp.StatusCode} {resp.StatusCode}");
+
+ if ((int)resp.StatusCode == 401 || (int)resp.StatusCode == 403)
+ {
+ Log.Error("Upload: unauthorized/forbidden. Token might be invalid/expired.");
+
+ // NOTE: Bei euch kommt 403 auch für Validierungsfehler (z.B. Filename fehlt)
+ // Darum: ReAuth nur dann, wenn Response "wirklich" nach Auth aussieht.
+ // Wir machen es pragmatisch: wenn allowTokenRetry -> einmal neu Token holen und retry,
+ // ABER nur wenn message NICHT nach Feldvalidierung klingt.
+ var content = resp.Content ?? "";
+ if (allowTokenRetry && LooksLikeAuthFailure(content) && TryReAuthToken())
+ return UploadDocumentToEventInternal(eventId, typeId, overwriteSingleDocument, fullPath, filename, filetype, allowTokenRetry: false);
+ }
+
+ if (!resp.IsSuccessful)
+ {
+ var body = resp.Content ?? "";
+ if (body.Length > 2000) body = body.Substring(0, 2000) + "...(truncated)";
+ Log.Warn($"Upload failed: {(int)resp.StatusCode} {resp.StatusCode} Body={body}");
+ return false;
+ }
+
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Log.Error($"UploadDocumentToEvent failed (EventID={eventId}, File={fullPath})", ex);
+ return false;
+ }
+ }
+
+ private bool TryReAuthToken()
+ {
+ try
+ {
+ // Token leeren, dann per EnsureAccessToken neu holen (Prompt)
+ TokenAuthHelper.ClearStoredToken(_appSettingsPath, _rootLog);
+
+ // Config neu laden, damit EnsureAccessToken die aktuelle Datei sieht
+ var baseDir = AppContext.BaseDirectory;
+ var cfg = new ConfigurationBuilder()
+ .SetBasePath(baseDir)
+ .AddJsonFile("appsettings.json", optional: false, reloadOnChange: false)
+ .Build();
+
+ var apiBase = (cfg["Crewbrain:ApiBase"] ?? "").TrimEnd('/');
+ var newToken = TokenAuthHelper.EnsureAccessToken(cfg, _appSettingsPath, _rootLog, apiBase);
+
+ if (string.IsNullOrWhiteSpace(newToken))
+ return false;
+
+ _accessToken = newToken.Trim();
+ _rootLog.Warn("TokenAuth: Re-auth succeeded, token replaced in memory.");
+ return true;
+ }
+ catch (Exception ex)
+ {
+ _rootLog.Warn("TokenAuth: Re-auth failed.", ex);
+ return false;
+ }
+ }
+
+ private static bool LooksLikeAuthFailure(string body)
+ {
+ if (string.IsNullOrWhiteSpace(body)) return true;
+
+ // Wenn es klar nach Validierungsfehler aussieht (Missing mandatory field etc.), NICHT reauthen
+ var b = body.ToLowerInvariant();
+ if (b.Contains("missing mandatory field")) return false;
+ if (b.Contains("\"field\"")) return false;
+
+ // Auth-typische Signale
+ if (b.Contains("unauthorized")) return true;
+ if (b.Contains("forbidden")) return true;
+ if (b.Contains("invalid token")) return true;
+ if (b.Contains("not authorized")) return true;
+
+ // default: lieber einmal reauthen als endlos scheitern
+ return true;
+ }
+
+ private static void DebugLogRawRequest(RestClient client, RestRequest req, string? bodyPreview)
+ {
+ if (!Log.IsDebugEnabled) return;
+
+ try
+ {
+ var baseUrl = client.Options.BaseUrl?.ToString() ?? "(null)";
+ var resource = req.Resource ?? "";
+ var method = req.Method.ToString();
+
+ var fullUrl = CombineUrl(baseUrl, resource);
+ var headers = new List();
+ var query = new List();
+
+ foreach (var p in req.Parameters)
+ {
+ if (p.Type == ParameterType.HttpHeader)
+ {
+ var val = p.Value?.ToString() ?? "";
+ if (p.Name != null && p.Name.Equals("X-API-KEY", StringComparison.OrdinalIgnoreCase))
+ {
+ val = Mask(val);
+ }
+ headers.Add($"{p.Name}: {val}");
+ }
+ else if (p.Type == ParameterType.QueryString)
+ {
+ query.Add($"{p.Name}={p.Value}");
+ }
+ }
+
+ Log.Debug("----- RAW REQUEST -----");
+ Log.Debug($"{method} {fullUrl}");
+ if (query.Count > 0) Log.Debug("Query: " + string.Join("&", query));
+ if (headers.Count > 0)
+ {
+ Log.Debug("Headers:");
+ foreach (var h in headers) Log.Debug(" " + h);
+ }
+
+ if (!string.IsNullOrWhiteSpace(bodyPreview))
+ {
+ Log.Debug("Body (preview):");
+ Log.Debug(bodyPreview);
+ }
+ Log.Debug("-----------------------");
+ }
+ catch (Exception ex)
+ {
+ Log.Debug("Failed to build raw request debug log.", ex);
+ }
+ }
+
+ private static string CombineUrl(string baseUrl, string resource)
+ {
+ baseUrl = (baseUrl ?? "").TrimEnd('/');
+ resource = (resource ?? "").TrimStart('/');
+ return baseUrl + "/" + resource;
+ }
+
+ private static string Mask(string s)
+ {
+ s = s ?? "";
+ if (s.Length <= 6) return new string('*', s.Length);
+ return s.Substring(0, 3) + "***" + s.Substring(s.Length - 3) + $" (len={s.Length})";
+ }
+}
diff --git a/EPI2CrewbrainFile/README.md b/EPI2CrewbrainFile/README.md
new file mode 100644
index 0000000..f65f30e
--- /dev/null
+++ b/EPI2CrewbrainFile/README.md
@@ -0,0 +1,128 @@
+# EPI → CrewBrain Dokumenten-Uploader
+
+Dieses Tool überträgt PDF-Dokumente (Angebote, Aufträge, Lieferscheine) automatisiert aus definierten Ordnern in **CrewBrain** und ordnet sie den passenden Jobs/Events zu.
+
+Die Zuordnung erfolgt über die **EventIDManual** (z. B. `4843-01`), die aus dem Dateinamen extrahiert wird.
+
+---
+
+## Voraussetzungen in CrewBrain (wichtig)
+
+Damit Dokumente hochgeladen werden können, **müssen in CrewBrain passende Zusatzinformationen vom Typ „Dokument“ angelegt sein**.
+
+### 1. Zusatzinformationen anlegen
+
+Öffne in CrewBrain:
+
+https:///administration/jobs/jobdata
+
+
+(z. B. `https://vt-media.crewbrain.com/administration/jobs/jobdata`)
+
+Lege dort für **jede Dokumentart** eine Zusatzinformation an:
+
+- Angebot
+- Auftrag
+- Lieferschein
+
+**Wichtig:**
+- Der Typ der Zusatzinformation **muss „Dokument“ sein**
+- Jede Zusatzinformation erhält intern eine **TypeID**
+
+---
+
+### 2. TypeID ermitteln
+
+Nach dem Anlegen oder Bearbeiten einer Zusatzinformation kannst du die **TypeID** direkt aus der URL ablesen:
+
+Beispiel:
+
+https://vt-media.crewbrain.com/administration/jobs/jobdata/additionaldatatype/2
+
+
+➡️ **TypeID = 2**
+
+Diese TypeID wird später in der Anwendung konfiguriert.
+
+---
+
+## Konfiguration (`appsettings.json`)
+
+### Relevanter Bereich: `Crewbrain`
+
+```json
+"Crewbrain": {
+ "ApiBase": "https://vt-media.crewbrain.com/api",
+
+ "Upload": {
+ "Angebot": {
+ "TypeId": 1,
+ "OverwriteSingleDocument": false
+ },
+ "Auftrag": {
+ "TypeId": 0,
+ "OverwriteSingleDocument": true
+ },
+ "Lieferschein": {
+ "TypeId": 2,
+ "OverwriteSingleDocument": false
+ }
+ },
+
+ "TokenAuth": {
+ "AccessToken": ""
+ }
+}
+Erklärung der Felder
+Feld Bedeutung
+ApiBase Basis-URL der CrewBrain API
+Upload..TypeId TypeID der Zusatzinformation (siehe oben)
+Upload..OverwriteSingleDocument Ersetzt vorhandenes Dokument (nur erlaubt bei DOCUMENT_SINGLE)
+TokenAuth.AccessToken Wird automatisch vom Tool gesetzt
+Dokumentverarbeitung
+Unterstützte Dateitypen: PDF
+
+Event-Zuordnung erfolgt über EventIDManual im Dateinamen
+
+Unterstützte Formate:
+
+905-01
+
+4843-01
+
+12345-01
+
+Bei mehreren Treffern im Dateinamen wird automatisch die längste ID verwendet
+
+Beispiel
+ABM4843-01-Kunde-Angebot.pdf
+→ EventIDManual = 4843-01
+Token-Authentifizierung
+Das Tool verwendet CrewBrain Access Tokens (X-API-KEY):
+
+Beim ersten Start:
+
+Benutzername & Passwort werden einmalig abgefragt
+
+Token wird automatisch gespeichert
+
+Tokens bleiben 14 Tage nach letzter Nutzung gültig
+
+Bei 401/403 wird automatisch ein neuer Token angefordert
+
+⚠️ Passwörter werden nicht gespeichert
+
+Ergebnis
+Erfolgreich hochgeladene Dateien werden in einen Uploaded/-Unterordner verschoben
+
+Bei Namenskonflikten wird ein Zeitstempel vor der Dateiendung ergänzt:
+
+Angebot_4843-01.pdf
+→ Angebot_4843-01_202601210915301234.pdf
+Hinweise
+Fehlerhafte oder nicht zuordenbare Dateien werden nicht verschoben
+
+Alle API-Requests (inkl. Payload-Preview) sind im DEBUG-Log sichtbar
+
+Base64-Inhalte werden im Log nicht vollständig ausgegeben
+
diff --git a/EPI2CrewbrainFile/appsettings.json b/EPI2CrewbrainFile/appsettings.json
new file mode 100644
index 0000000..ad057e9
--- /dev/null
+++ b/EPI2CrewbrainFile/appsettings.json
@@ -0,0 +1,21 @@
+{
+ "Paths": {
+ "Angebot": "\\\\SRHNDATEN01\\GemeinsameDaten\\201 Dokumentation\\01 EpiPDF\\Angebot",
+ "Auftrag": "\\\\SRHNDATEN01\\GemeinsameDaten\\201 Dokumentation\\01 EpiPDF\\Auftrag",
+ "Lieferschein": "\\\\SRHNDATEN01\\GemeinsameDaten\\201 Dokumentation\\01 EpiPDF\\Lieferschein"
+
+ },
+ "Crewbrain": {
+ "Upload": {
+ "Angebot": { "TypeId": 2, "OverwriteSingleDocument": false },
+ "Auftrag": { "TypeId": 3, "OverwriteSingleDocument": false },
+ "Lieferschein": { "TypeId": 4, "OverwriteSingleDocument": false }
+ },
+ "ApiBase": "https://vt-media.crewbrain.com/api",
+
+ "TokenAuth": {
+ "AccessToken": "",
+ "SavedAtUtc": ""
+ }
+ }
+}
diff --git a/EPI2CrewbrainFile/log4net.config b/EPI2CrewbrainFile/log4net.config
new file mode 100644
index 0000000..48dadda
--- /dev/null
+++ b/EPI2CrewbrainFile/log4net.config
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+