using log4net;
using log4net.Config;
using Microsoft.Extensions.Configuration;
using RestSharp;
using RestSharp.Authenticators;
using System;
using System.IO;
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 angebotPath = config["Paths:Angebot"] ?? "";
var auftragPath = config["Paths:Auftrag"] ?? "";
var lieferscheinPath = config["Paths:Lieferschein"] ?? "";
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)");
var target = UploadTargetExtensions.Parse(config["Crewbrain:Upload:Target"]);
var attachmentsVisibility = (config["Crewbrain:Upload:AttachmentsVisibility"] ?? "ALL").Trim();
// per Dokumentart: TypeId + overwrite
var angebotCfg = UploadCategoryConfig.FromConfig(config, "Crewbrain:Upload:Angebot");
var auftragCfg = UploadCategoryConfig.FromConfig(config, "Crewbrain:Upload:Auftrag");
var lieferscheinCfg = UploadCategoryConfig.FromConfig(config, "Crewbrain:Upload:Lieferschein");
Log.Info("=== EPI2CrewbrainFile.Console started ===");
Log.Info($"Config path: {appSettingsPath}");
Log.Info($"Crewbrain ApiBase: {apiBase}");
Log.Info($"Upload Target: {target}");
Log.Info($"Attachments Visibility: {attachmentsVisibility}");
Log.Info($"Paths: Angebot='{angebotPath}' Auftrag='{auftragPath}' Lieferschein='{lieferscheinPath}'");
Log.Info($"TypeIds (only for AdditionalData): Angebot={angebotCfg.TypeId} Auftrag={auftragCfg.TypeId} Lieferschein={lieferscheinCfg.TypeId}");
var accessToken = TokenAuthHelper.EnsureAccessToken(
config: config,
appSettingsPath: appSettingsPath,
log: Log,
apiBase: apiBase
);
Log.Info($"TokenAuth: AccessToken loaded (len={accessToken.Length})");
var service = new UploaderService(apiBase, accessToken, target, attachmentsVisibility);
// Angebot
ProcessFolder(
folder: angebotPath,
uploader: service,
tag: "Angebot",
typeId: angebotCfg.TypeId,
overwriteSingleDocument: angebotCfg.OverwriteSingleDocument
);
// Auftrag
ProcessFolder(
folder: auftragPath,
uploader: service,
tag: "Auftrag",
typeId: auftragCfg.TypeId,
overwriteSingleDocument: auftragCfg.OverwriteSingleDocument
);
// Lieferschein
ProcessFolder(
folder: lieferscheinPath,
uploader: service,
tag: "Lieferschein",
typeId: lieferscheinCfg.TypeId,
overwriteSingleDocument: lieferscheinCfg.OverwriteSingleDocument
);
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, int typeId, bool overwriteSingleDocument)
{
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.UploadPdf(
eventId: eventId.Value,
typeId: typeId,
overwriteSingleDocument: 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);
}
}
}
///
/// Extrahiert EventIDManual generisch robust (3- bis 5-stellig + "-"+2-stellig).
/// Beispiele: 905-01, 4843-01, 12345-01
/// Wenn mehrere Matches: nimmt den längsten (meist der richtige).
///
private static string ExtractEventIdManual(string fileName)
{
var matches = Regex.Matches(fileName, @"(? best.Length) best = v;
}
return best;
}
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 dest = Path.Combine(uploadedDir, fileName);
// Duplikate: Timestamp VOR Extension
if (File.Exists(dest))
{
var name = Path.GetFileNameWithoutExtension(fileName);
var ext = Path.GetExtension(fileName); // inkl. ".pdf"
var ts = DateTime.Now.ToString("yyyyMMddHHmmssffff");
var newName = $"{name}_{ts}{ext}";
dest = Path.Combine(uploadedDir, newName);
}
File.Move(source, dest);
Log.Info($"[{tag}] Moved to '{dest}'");
}
}
internal enum UploadTarget
{
AdditionalData,
JobAttachments
}
internal static class UploadTargetExtensions
{
public static UploadTarget Parse(string? raw)
{
raw = (raw ?? "").Trim();
if (raw.Equals("JobAttachments", StringComparison.OrdinalIgnoreCase)) return UploadTarget.JobAttachments;
if (raw.Equals("AdditionalData", StringComparison.OrdinalIgnoreCase)) return UploadTarget.AdditionalData;
return UploadTarget.AdditionalData; // default
}
}
internal sealed class UploadCategoryConfig
{
public int TypeId { get; init; } = 0;
public bool OverwriteSingleDocument { get; init; } = false;
public static UploadCategoryConfig FromConfig(IConfiguration cfg, string prefix)
{
int typeId = 0;
int.TryParse(cfg[$"{prefix}:TypeId"], out typeId);
bool overwrite = false;
bool.TryParse(cfg[$"{prefix}:OverwriteSingleDocument"], out overwrite);
return new UploadCategoryConfig { TypeId = typeId, OverwriteSingleDocument = 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;
}
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}");
// bei dir: {"Accesstoken":"..."}
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 readonly string _accessToken; // X-API-KEY
private readonly UploadTarget _target;
private readonly string _attachmentsVisibility;
public UploaderService(string apiBase, string accessToken, UploadTarget target, string attachmentsVisibility)
{
_baseUrl = (apiBase ?? "").TrimEnd('/') + "/";
_accessToken = (accessToken ?? "").Trim();
_target = target;
_attachmentsVisibility = string.IsNullOrWhiteSpace(attachmentsVisibility) ? "ALL" : attachmentsVisibility.Trim();
}
public int? ResolveCrewbrainEventIdByManualId(string eventIdManual)
{
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);
DebugLogRequest(client, req, bodyForLog: null);
var resp = client.Execute(req);
Log.Info($"GET v2/events?EventIDManual={eventIdManual} -> {(int)resp.StatusCode} {resp.StatusCode}");
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 UploadPdf(int eventId, int typeId, bool overwriteSingleDocument, string fullPath, string filename, string filetype)
{
try
{
filename = (filename ?? "").Trim();
filetype = (filetype ?? "").Trim().TrimStart('.');
if (string.IsNullOrWhiteSpace(filename))
filename = Path.GetFileNameWithoutExtension(fullPath) ?? "";
if (string.IsNullOrWhiteSpace(filename))
throw new Exception("Upload: filename is empty.");
if (string.IsNullOrWhiteSpace(filetype))
filetype = "pdf";
var base64 = Convert.ToBase64String(File.ReadAllBytes(fullPath));
// Log payload-Preview OHNE Base64 (nur Länge)
var payloadForLog = new
{
Base64String = $"",
Filename = filename,
Filetype = filetype,
Target = _target.ToString(),
TypeIdUsedOnlyForAdditionalData = typeId,
OverwriteSingleDocument = overwriteSingleDocument,
Visibility = _attachmentsVisibility
};
Log.Debug("Upload payload preview: " + JsonSerializer.Serialize(payloadForLog));
var client = new RestClient(new RestClientOptions(_baseUrl) { FollowRedirects = true });
RestRequest req;
string bodyToSend;
if (_target == UploadTarget.AdditionalData)
{
// PUT /v2/events/{eventId}/additionalDatas/{typeId}/addDocument
req = new RestRequest($"v2/events/{eventId}/additionalDatas/{typeId}/addDocument", Method.Put);
if (overwriteSingleDocument)
req.AddQueryParameter("overwriteSingleDocument", "true");
var payload = new
{
Base64String = base64,
Filename = filename,
Filetype = filetype
};
bodyToSend = JsonSerializer.Serialize(payload);
}
else
{
// POST /v2/events/{eventId}/attachments (EventAttachment)
// Falls euer Pfad anders heißt, hier anpassen.
req = new RestRequest($"v2/events/{eventId}/attachments", Method.Post);
var payload = new
{
Base64String = base64,
Filename = filename,
Filetype = filetype,
Visibility = _attachmentsVisibility,
VisibilityUsergroups = new int[] { 0 }
};
bodyToSend = JsonSerializer.Serialize(payload);
}
req.AddHeader("Accept", "application/json");
req.AddHeader("Content-Type", "application/json");
req.AddHeader("X-API-KEY", _accessToken);
req.AddStringBody(bodyToSend, DataFormat.Json);
DebugLogRequest(client, req, bodyForLog: bodyToSend);
var resp = client.Execute(req);
Log.Info($"{req.Method} {req.Resource} -> {(int)resp.StatusCode} {resp.StatusCode}");
// Success-Codes:
// - AdditionalData: meist 200/201/202 möglich (je nach Implementierung)
// - Attachments: laut Doku 201 Created
var ok = (int)resp.StatusCode is >= 200 and < 300;
if (!ok)
{
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($"Upload failed (EventID={eventId}, File={fullPath})", ex);
return false;
}
}
private static void DebugLogRequest(RestClient client, RestRequest req, string? bodyForLog)
{
if (!Log.IsDebugEnabled) return;
Uri? uri;
try
{
uri = client.BuildUri(req);
}
catch
{
uri = null;
}
var sb = new StringBuilder();
sb.AppendLine("=== RAW REQUEST (debug) ===");
sb.AppendLine($"Method: {req.Method}");
sb.AppendLine($"URL: {(uri?.ToString() ?? "(unable to build url)")}");
if (req.Parameters != null)
{
sb.AppendLine("Params/Headers:");
foreach (var p in req.Parameters)
{
// Header masking
if (p.Type == ParameterType.HttpHeader && p.Name.Equals("X-API-KEY", StringComparison.OrdinalIgnoreCase))
{
sb.AppendLine($" Header {p.Name}: ***masked***");
}
else
{
sb.AppendLine($" {p.Type} {p.Name}: {p.Value}");
}
}
}
if (!string.IsNullOrWhiteSpace(bodyForLog))
{
// Base64 aus Body entfernen (nur für Log), sehr simple:
var redacted = Regex.Replace(bodyForLog, @"""Base64String"":\s*""[^""]*""", @"""Base64String"":""""");
if (redacted.Length > 4000) redacted = redacted.Substring(0, 4000) + "...(truncated)";
sb.AppendLine("Body (redacted):");
sb.AppendLine(redacted);
}
sb.AppendLine("===========================");
Log.Debug(sb.ToString());
}
}