Möglichkeit eingebaut, zwischen Zusatzfeldern und den normalen Attachments zu wechseln
This commit is contained in:
@@ -4,9 +4,7 @@ using Microsoft.Extensions.Configuration;
|
|||||||
using RestSharp;
|
using RestSharp;
|
||||||
using RestSharp.Authenticators;
|
using RestSharp.Authenticators;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
@@ -32,29 +30,30 @@ internal class Program
|
|||||||
var appSettingsPath = Path.Combine(baseDir, "appsettings.json");
|
var appSettingsPath = Path.Combine(baseDir, "appsettings.json");
|
||||||
var config = LoadConfig(baseDir);
|
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 angebotPath = config["Paths:Angebot"] ?? "";
|
||||||
var auftragPath = config["Paths:Auftrag"] ?? "";
|
var auftragPath = config["Paths:Auftrag"] ?? "";
|
||||||
var lieferscheinPath = config["Paths:Lieferschein"] ?? "";
|
var lieferscheinPath = config["Paths:Lieferschein"] ?? "";
|
||||||
|
|
||||||
// Upload settings (3 unterschiedliche TypeIDs / Flags)
|
var apiBase = (config["Crewbrain:ApiBase"] ?? "").TrimEnd('/');
|
||||||
var settingsAngebot = UploadSettings.FromConfig(config, "Crewbrain:Upload:Angebot");
|
if (string.IsNullOrWhiteSpace(apiBase))
|
||||||
var settingsAuftrag = UploadSettings.FromConfig(config, "Crewbrain:Upload:Auftrag");
|
throw new Exception("Missing config 'Crewbrain:ApiBase' (e.g. https://vt-media.crewbrain.com/api)");
|
||||||
var settingsLieferschein = UploadSettings.FromConfig(config, "Crewbrain:Upload:Lieferschein");
|
|
||||||
|
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("=== EPI2CrewbrainFile.Console started ===");
|
||||||
Log.Info($"Config path: {appSettingsPath}");
|
Log.Info($"Config path: {appSettingsPath}");
|
||||||
Log.Info($"Crewbrain ApiBase: {apiBase}");
|
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}");
|
||||||
|
|
||||||
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(
|
var accessToken = TokenAuthHelper.EnsureAccessToken(
|
||||||
config: config,
|
config: config,
|
||||||
appSettingsPath: appSettingsPath,
|
appSettingsPath: appSettingsPath,
|
||||||
@@ -64,13 +63,34 @@ internal class Program
|
|||||||
|
|
||||||
Log.Info($"TokenAuth: AccessToken loaded (len={accessToken.Length})");
|
Log.Info($"TokenAuth: AccessToken loaded (len={accessToken.Length})");
|
||||||
|
|
||||||
// Uploader service
|
var service = new UploaderService(apiBase, accessToken, target, attachmentsVisibility);
|
||||||
var service = new UploaderService(apiBase, accessToken, appSettingsPath, Log);
|
|
||||||
|
|
||||||
// Process
|
// Angebot
|
||||||
ProcessFolder(folder: angebotPath, uploader: service, tag: "Angebot", settings: settingsAngebot);
|
ProcessFolder(
|
||||||
ProcessFolder(folder: auftragPath, uploader: service, tag: "Auftrag", settings: settingsAuftrag);
|
folder: angebotPath,
|
||||||
ProcessFolder(folder: lieferscheinPath, uploader: service, tag: "Lieferschein", settings: settingsLieferschein);
|
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 ===");
|
Log.Info("=== EPI2CrewbrainFile.Console finished successfully ===");
|
||||||
return 0;
|
return 0;
|
||||||
@@ -99,7 +119,7 @@ internal class Program
|
|||||||
XmlConfigurator.Configure(LogManager.GetRepository(Assembly.GetEntryAssembly()!), new FileInfo(configFile));
|
XmlConfigurator.Configure(LogManager.GetRepository(Assembly.GetEntryAssembly()!), new FileInfo(configFile));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ProcessFolder(string folder, UploaderService uploader, string tag, UploadSettings settings)
|
private static void ProcessFolder(string folder, UploaderService uploader, string tag, int typeId, bool overwriteSingleDocument)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder))
|
if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder))
|
||||||
{
|
{
|
||||||
@@ -133,10 +153,10 @@ internal class Program
|
|||||||
Log.Info($"[{tag}] Resolved EventIDManual={eventIdManual} -> CrewBrain ID={eventId}");
|
Log.Info($"[{tag}] Resolved EventIDManual={eventIdManual} -> CrewBrain ID={eventId}");
|
||||||
|
|
||||||
var filenameWithoutExt = Path.GetFileNameWithoutExtension(file.Name);
|
var filenameWithoutExt = Path.GetFileNameWithoutExtension(file.Name);
|
||||||
var ok = uploader.UploadDocumentToEvent(
|
var ok = uploader.UploadPdf(
|
||||||
eventId: eventId.Value,
|
eventId: eventId.Value,
|
||||||
typeId: settings.TypeId,
|
typeId: typeId,
|
||||||
overwriteSingleDocument: settings.OverwriteSingleDocument,
|
overwriteSingleDocument: overwriteSingleDocument,
|
||||||
fullPath: file.FullName,
|
fullPath: file.FullName,
|
||||||
filename: filenameWithoutExt,
|
filename: filenameWithoutExt,
|
||||||
filetype: file.Extension.TrimStart('.')
|
filetype: file.Extension.TrimStart('.')
|
||||||
@@ -159,19 +179,22 @@ internal class Program
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Robust: findet 3- bis 5-stellige IDs im Format NNN-NN / NNNN-NN / NNNNN-NN.
|
/// Extrahiert EventIDManual generisch robust (3- bis 5-stellig + "-"+2-stellig).
|
||||||
/// Wenn mehrere Treffer existieren, nimmt er den längsten (also bevorzugt 5-stellig vor 4-stellig vor 3-stellig).
|
/// Beispiele: 905-01, 4843-01, 12345-01
|
||||||
|
/// Wenn mehrere Matches: nimmt den längsten (meist der richtige).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static string ExtractEventIdManual(string fileName)
|
private static string ExtractEventIdManual(string fileName)
|
||||||
{
|
{
|
||||||
// Match: 3..5 digits - 2 digits, nicht Teil einer größeren Zahl
|
|
||||||
var matches = Regex.Matches(fileName, @"(?<!\d)(\d{3,5}-\d{2})(?!\d)");
|
var matches = Regex.Matches(fileName, @"(?<!\d)(\d{3,5}-\d{2})(?!\d)");
|
||||||
if (matches.Count == 0) return "";
|
if (matches.Count == 0) return "";
|
||||||
|
|
||||||
return matches
|
string best = "";
|
||||||
.Select(m => m.Groups[1].Value)
|
foreach (Match m in matches)
|
||||||
.OrderByDescending(s => s.Length) // bevorzugt 12345-01
|
{
|
||||||
.First();
|
var v = m.Groups[1].Value;
|
||||||
|
if (v.Length > best.Length) best = v;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void MoveToUploaded(string baseFolder, string fileName, string tag)
|
private static void MoveToUploaded(string baseFolder, string fileName, string tag)
|
||||||
@@ -186,34 +209,54 @@ internal class Program
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var nameNoExt = Path.GetFileNameWithoutExtension(fileName);
|
|
||||||
var ext = Path.GetExtension(fileName); // inkl ".pdf"
|
|
||||||
if (string.IsNullOrWhiteSpace(ext)) ext = "";
|
|
||||||
|
|
||||||
var dest = Path.Combine(uploadedDir, fileName);
|
var dest = Path.Combine(uploadedDir, fileName);
|
||||||
|
|
||||||
|
// Duplikate: Timestamp VOR Extension
|
||||||
if (File.Exists(dest))
|
if (File.Exists(dest))
|
||||||
{
|
{
|
||||||
var stamp = DateTime.Now.ToString("yyyyMMddHHmmssffff");
|
var name = Path.GetFileNameWithoutExtension(fileName);
|
||||||
var newName = $"{nameNoExt}_{stamp}{ext}";
|
var ext = Path.GetExtension(fileName); // inkl. ".pdf"
|
||||||
|
var ts = DateTime.Now.ToString("yyyyMMddHHmmssffff");
|
||||||
|
var newName = $"{name}_{ts}{ext}";
|
||||||
dest = Path.Combine(uploadedDir, newName);
|
dest = Path.Combine(uploadedDir, newName);
|
||||||
}
|
}
|
||||||
|
|
||||||
File.Move(source, dest);
|
File.Move(source, dest);
|
||||||
Log.Info($"[{tag}] Moved to '{dest}'");
|
Log.Info($"[{tag}] Moved to '{dest}'");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private sealed record UploadSettings(int TypeId, bool OverwriteSingleDocument)
|
internal enum UploadTarget
|
||||||
{
|
{
|
||||||
public static UploadSettings FromConfig(IConfiguration cfg, string prefix)
|
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 typeId = 0;
|
||||||
bool overwrite = false;
|
|
||||||
|
|
||||||
int.TryParse(cfg[$"{prefix}:TypeId"], out typeId);
|
int.TryParse(cfg[$"{prefix}:TypeId"], out typeId);
|
||||||
|
|
||||||
|
bool overwrite = false;
|
||||||
bool.TryParse(cfg[$"{prefix}:OverwriteSingleDocument"], out overwrite);
|
bool.TryParse(cfg[$"{prefix}:OverwriteSingleDocument"], out overwrite);
|
||||||
|
|
||||||
return new UploadSettings(typeId, overwrite);
|
return new UploadCategoryConfig { TypeId = typeId, OverwriteSingleDocument = overwrite };
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +277,6 @@ internal static class TokenAuthHelper
|
|||||||
|
|
||||||
Console.Write("CrewBrain Username: ");
|
Console.Write("CrewBrain Username: ");
|
||||||
var user = (Console.ReadLine() ?? "").Trim();
|
var user = (Console.ReadLine() ?? "").Trim();
|
||||||
|
|
||||||
var pass = ReadPassword("CrewBrain Password: ");
|
var pass = ReadPassword("CrewBrain Password: ");
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(user) || string.IsNullOrWhiteSpace(pass))
|
if (string.IsNullOrWhiteSpace(user) || string.IsNullOrWhiteSpace(pass))
|
||||||
@@ -247,29 +289,6 @@ internal static class TokenAuthHelper
|
|||||||
return fetched;
|
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)
|
private static string FetchAccessToken(string apiBase, string user, string pass, ILog log)
|
||||||
{
|
{
|
||||||
var options = new RestClientOptions(apiBase.TrimEnd('/') + "/")
|
var options = new RestClientOptions(apiBase.TrimEnd('/') + "/")
|
||||||
@@ -292,6 +311,7 @@ internal static class TokenAuthHelper
|
|||||||
var node = JsonNode.Parse(body) as JsonObject
|
var node = JsonNode.Parse(body) as JsonObject
|
||||||
?? throw new Exception($"AccessToken response not a JSON object: {body}");
|
?? throw new Exception($"AccessToken response not a JSON object: {body}");
|
||||||
|
|
||||||
|
// bei dir: {"Accesstoken":"..."}
|
||||||
var token = (node["Accesstoken"]?.GetValue<string>() ?? "").Trim();
|
var token = (node["Accesstoken"]?.GetValue<string>() ?? "").Trim();
|
||||||
if (string.IsNullOrWhiteSpace(token))
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
throw new Exception($"TokenAuth: 'Accesstoken' missing in response: {body}");
|
throw new Exception($"TokenAuth: 'Accesstoken' missing in response: {body}");
|
||||||
@@ -372,22 +392,19 @@ internal class UploaderService
|
|||||||
private static readonly ILog Log = LogManager.GetLogger(typeof(UploaderService));
|
private static readonly ILog Log = LogManager.GetLogger(typeof(UploaderService));
|
||||||
|
|
||||||
private readonly string _baseUrl; // https://.../api/
|
private readonly string _baseUrl; // https://.../api/
|
||||||
private string _accessToken; // X-API-KEY
|
private readonly string _accessToken; // X-API-KEY
|
||||||
private readonly string _appSettingsPath; // for clearing token on 401/403
|
private readonly UploadTarget _target;
|
||||||
private readonly ILog _rootLog;
|
private readonly string _attachmentsVisibility;
|
||||||
|
|
||||||
public UploaderService(string apiBase, string accessToken, string appSettingsPath, ILog rootLog)
|
public UploaderService(string apiBase, string accessToken, UploadTarget target, string attachmentsVisibility)
|
||||||
{
|
{
|
||||||
_baseUrl = (apiBase ?? "").TrimEnd('/') + "/";
|
_baseUrl = (apiBase ?? "").TrimEnd('/') + "/";
|
||||||
_accessToken = (accessToken ?? "").Trim();
|
_accessToken = (accessToken ?? "").Trim();
|
||||||
_appSettingsPath = appSettingsPath;
|
_target = target;
|
||||||
_rootLog = rootLog;
|
_attachmentsVisibility = string.IsNullOrWhiteSpace(attachmentsVisibility) ? "ALL" : attachmentsVisibility.Trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
public int? ResolveCrewbrainEventIdByManualId(string eventIdManual)
|
public int? ResolveCrewbrainEventIdByManualId(string eventIdManual)
|
||||||
=> ResolveCrewbrainEventIdByManualIdInternal(eventIdManual, allowTokenRetry: true);
|
|
||||||
|
|
||||||
private int? ResolveCrewbrainEventIdByManualIdInternal(string eventIdManual, bool allowTokenRetry)
|
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -398,21 +415,12 @@ internal class UploaderService
|
|||||||
req.AddHeader("X-API-KEY", _accessToken);
|
req.AddHeader("X-API-KEY", _accessToken);
|
||||||
req.AddQueryParameter("EventIDManual", eventIdManual);
|
req.AddQueryParameter("EventIDManual", eventIdManual);
|
||||||
|
|
||||||
DebugLogRawRequest(client, req, bodyPreview: null);
|
DebugLogRequest(client, req, bodyForLog: null);
|
||||||
|
|
||||||
var resp = client.Execute(req);
|
var resp = client.Execute(req);
|
||||||
|
|
||||||
Log.Info($"GET v2/events?EventIDManual={eventIdManual} -> {(int)resp.StatusCode} {resp.StatusCode}");
|
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)
|
if (!resp.IsSuccessful)
|
||||||
{
|
{
|
||||||
var b = resp.Content ?? "";
|
var b = resp.Content ?? "";
|
||||||
@@ -451,79 +459,88 @@ internal class UploaderService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool UploadDocumentToEvent(int eventId, int typeId, bool overwriteSingleDocument, string fullPath, string filename, string filetype)
|
public bool UploadPdf(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
|
try
|
||||||
{
|
{
|
||||||
filename = (filename ?? "").Trim();
|
filename = (filename ?? "").Trim();
|
||||||
filetype = (filetype ?? "").Trim().TrimStart('.');
|
filetype = (filetype ?? "").Trim().TrimStart('.');
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(filename))
|
if (string.IsNullOrWhiteSpace(filename))
|
||||||
filename = Path.GetFileNameWithoutExtension(fullPath) ?? "";
|
filename = Path.GetFileNameWithoutExtension(fullPath) ?? "";
|
||||||
if (string.IsNullOrWhiteSpace(filename))
|
if (string.IsNullOrWhiteSpace(filename))
|
||||||
throw new Exception("UploadDocumentToEvent: filename is empty.");
|
throw new Exception("Upload: filename is empty.");
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(filetype))
|
if (string.IsNullOrWhiteSpace(filetype))
|
||||||
filetype = "pdf";
|
filetype = "pdf";
|
||||||
|
|
||||||
var bytes = File.ReadAllBytes(fullPath);
|
var base64 = Convert.ToBase64String(File.ReadAllBytes(fullPath));
|
||||||
var base64 = Convert.ToBase64String(bytes);
|
|
||||||
|
|
||||||
// API fordert "Filename" mandatory → wir geben hier den kompletten Dateinamen inkl. Extension mit,
|
// Log payload-Preview OHNE Base64 (nur Länge)
|
||||||
// aber lassen dein "filename" (ohne Ext) drin lesbar.
|
var payloadForLog = new
|
||||||
var filenameWithExt = filename.EndsWith("." + filetype, StringComparison.OrdinalIgnoreCase)
|
|
||||||
? filename
|
|
||||||
: $"{filename}.{filetype}";
|
|
||||||
|
|
||||||
var payload = new
|
|
||||||
{
|
{
|
||||||
Base64String = base64,
|
Base64String = $"<base64 len={base64.Length}>",
|
||||||
Filename = filenameWithExt,
|
Filename = filename,
|
||||||
Filetype = filetype
|
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 });
|
var client = new RestClient(new RestClientOptions(_baseUrl) { FollowRedirects = true });
|
||||||
|
|
||||||
var req = new RestRequest($"v2/events/{eventId}/additionalDatas/{typeId}/addDocument", Method.Put);
|
RestRequest req;
|
||||||
req.AddHeader("Accept", "application/json");
|
string bodyToSend;
|
||||||
req.AddHeader("Content-Type", "application/json");
|
|
||||||
req.AddHeader("X-API-KEY", _accessToken);
|
if (_target == UploadTarget.AdditionalData)
|
||||||
|
{
|
||||||
|
// PUT /v2/events/{eventId}/additionalDatas/{typeId}/addDocument
|
||||||
|
req = new RestRequest($"v2/events/{eventId}/additionalDatas/{typeId}/addDocument", Method.Put);
|
||||||
|
|
||||||
if (overwriteSingleDocument)
|
if (overwriteSingleDocument)
|
||||||
req.AddQueryParameter("overwriteSingleDocument", "true");
|
req.AddQueryParameter("overwriteSingleDocument", "true");
|
||||||
|
|
||||||
var jsonBody = JsonSerializer.Serialize(payload);
|
var payload = new
|
||||||
req.AddStringBody(jsonBody, DataFormat.Json);
|
|
||||||
|
|
||||||
// DEBUG Raw Request (ohne Base64)
|
|
||||||
var bodyPreview = JsonSerializer.Serialize(new
|
|
||||||
{
|
{
|
||||||
Base64String = $"<base64 len={base64.Length} bytes={bytes.Length}>",
|
Base64String = base64,
|
||||||
Filename = filenameWithExt,
|
Filename = filename,
|
||||||
Filetype = filetype
|
Filetype = filetype
|
||||||
});
|
};
|
||||||
DebugLogRawRequest(client, req, bodyPreview);
|
bodyToSend = JsonSerializer.Serialize(payload);
|
||||||
|
}
|
||||||
var resp = client.Execute(req);
|
else
|
||||||
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.");
|
// POST /v2/events/{eventId}/attachments (EventAttachment)
|
||||||
|
// Falls euer Pfad anders heißt, hier anpassen.
|
||||||
|
req = new RestRequest($"v2/events/{eventId}/attachments", Method.Post);
|
||||||
|
|
||||||
// NOTE: Bei euch kommt 403 auch für Validierungsfehler (z.B. Filename fehlt)
|
var payload = new
|
||||||
// Darum: ReAuth nur dann, wenn Response "wirklich" nach Auth aussieht.
|
{
|
||||||
// Wir machen es pragmatisch: wenn allowTokenRetry -> einmal neu Token holen und retry,
|
Base64String = base64,
|
||||||
// ABER nur wenn message NICHT nach Feldvalidierung klingt.
|
Filename = filename,
|
||||||
var content = resp.Content ?? "";
|
Filetype = filetype,
|
||||||
if (allowTokenRetry && LooksLikeAuthFailure(content) && TryReAuthToken())
|
Visibility = _attachmentsVisibility,
|
||||||
return UploadDocumentToEventInternal(eventId, typeId, overwriteSingleDocument, fullPath, filename, filetype, allowTokenRetry: false);
|
VisibilityUsergroups = new int[] { 0 }
|
||||||
|
};
|
||||||
|
bodyToSend = JsonSerializer.Serialize(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!resp.IsSuccessful)
|
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 ?? "";
|
var body = resp.Content ?? "";
|
||||||
if (body.Length > 2000) body = body.Substring(0, 2000) + "...(truncated)";
|
if (body.Length > 2000) body = body.Substring(0, 2000) + "...(truncated)";
|
||||||
@@ -535,125 +552,57 @@ internal class UploaderService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Log.Error($"UploadDocumentToEvent failed (EventID={eventId}, File={fullPath})", ex);
|
Log.Error($"Upload failed (EventID={eventId}, File={fullPath})", ex);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool TryReAuthToken()
|
private static void DebugLogRequest(RestClient client, RestRequest req, string? bodyForLog)
|
||||||
{
|
|
||||||
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;
|
if (!Log.IsDebugEnabled) return;
|
||||||
|
|
||||||
|
Uri? uri;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var baseUrl = client.Options.BaseUrl?.ToString() ?? "(null)";
|
uri = client.BuildUri(req);
|
||||||
var resource = req.Resource ?? "";
|
}
|
||||||
var method = req.Method.ToString();
|
catch
|
||||||
|
{
|
||||||
|
uri = null;
|
||||||
|
}
|
||||||
|
|
||||||
var fullUrl = CombineUrl(baseUrl, resource);
|
var sb = new StringBuilder();
|
||||||
var headers = new List<string>();
|
sb.AppendLine("=== RAW REQUEST (debug) ===");
|
||||||
var query = new List<string>();
|
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)
|
foreach (var p in req.Parameters)
|
||||||
{
|
{
|
||||||
if (p.Type == ParameterType.HttpHeader)
|
// Header masking
|
||||||
|
if (p.Type == ParameterType.HttpHeader && p.Name.Equals("X-API-KEY", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
var val = p.Value?.ToString() ?? "";
|
sb.AppendLine($" Header {p.Name}: ***masked***");
|
||||||
if (p.Name != null && p.Name.Equals("X-API-KEY", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
val = Mask(val);
|
|
||||||
}
|
}
|
||||||
headers.Add($"{p.Name}: {val}");
|
else
|
||||||
}
|
|
||||||
else if (p.Type == ParameterType.QueryString)
|
|
||||||
{
|
{
|
||||||
query.Add($"{p.Name}={p.Value}");
|
sb.AppendLine($" {p.Type} {p.Name}: {p.Value}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.Debug("----- RAW REQUEST -----");
|
if (!string.IsNullOrWhiteSpace(bodyForLog))
|
||||||
Log.Debug($"{method} {fullUrl}");
|
|
||||||
if (query.Count > 0) Log.Debug("Query: " + string.Join("&", query));
|
|
||||||
if (headers.Count > 0)
|
|
||||||
{
|
{
|
||||||
Log.Debug("Headers:");
|
// Base64 aus Body entfernen (nur für Log), sehr simple:
|
||||||
foreach (var h in headers) Log.Debug(" " + h);
|
var redacted = Regex.Replace(bodyForLog, @"""Base64String"":\s*""[^""]*""", @"""Base64String"":""<redacted>""");
|
||||||
|
if (redacted.Length > 4000) redacted = redacted.Substring(0, 4000) + "...(truncated)";
|
||||||
|
sb.AppendLine("Body (redacted):");
|
||||||
|
sb.AppendLine(redacted);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(bodyPreview))
|
sb.AppendLine("===========================");
|
||||||
{
|
Log.Debug(sb.ToString());
|
||||||
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})";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ Diese TypeID wird später in der Anwendung konfiguriert.
|
|||||||
"ApiBase": "https://vt-media.crewbrain.com/api",
|
"ApiBase": "https://vt-media.crewbrain.com/api",
|
||||||
|
|
||||||
"Upload": {
|
"Upload": {
|
||||||
|
"Target": "AdditionalData",
|
||||||
|
"AttachmentsVisibility": "ALL",
|
||||||
"Angebot": {
|
"Angebot": {
|
||||||
"TypeId": 1,
|
"TypeId": 1,
|
||||||
"OverwriteSingleDocument": false
|
"OverwriteSingleDocument": false
|
||||||
@@ -76,6 +78,13 @@ Diese TypeID wird später in der Anwendung konfiguriert.
|
|||||||
Erklärung der Felder
|
Erklärung der Felder
|
||||||
Feld Bedeutung
|
Feld Bedeutung
|
||||||
ApiBase Basis-URL der CrewBrain API
|
ApiBase Basis-URL der CrewBrain API
|
||||||
|
|
||||||
|
Crewbrain:Upload:Target:
|
||||||
|
|
||||||
|
"AdditionalData" = AddOnField-Dokument (TypeId je Dokumentart)
|
||||||
|
|
||||||
|
"JobAttachments" = normales Job Attachment (TypeId wird ignoriert)
|
||||||
|
|
||||||
Upload.<Typ>.TypeId TypeID der Zusatzinformation (siehe oben)
|
Upload.<Typ>.TypeId TypeID der Zusatzinformation (siehe oben)
|
||||||
Upload.<Typ>.OverwriteSingleDocument Ersetzt vorhandenes Dokument (nur erlaubt bei DOCUMENT_SINGLE)
|
Upload.<Typ>.OverwriteSingleDocument Ersetzt vorhandenes Dokument (nur erlaubt bei DOCUMENT_SINGLE)
|
||||||
TokenAuth.AccessToken Wird automatisch vom Tool gesetzt
|
TokenAuth.AccessToken Wird automatisch vom Tool gesetzt
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
},
|
},
|
||||||
"Crewbrain": {
|
"Crewbrain": {
|
||||||
"Upload": {
|
"Upload": {
|
||||||
|
"Target": "JobAttachments",
|
||||||
|
"AttachmentsVisibility": "ALL",
|
||||||
|
|
||||||
|
|
||||||
"Angebot": { "TypeId": 2, "OverwriteSingleDocument": false },
|
"Angebot": { "TypeId": 2, "OverwriteSingleDocument": false },
|
||||||
"Auftrag": { "TypeId": 3, "OverwriteSingleDocument": false },
|
"Auftrag": { "TypeId": 3, "OverwriteSingleDocument": false },
|
||||||
"Lieferschein": { "TypeId": 4, "OverwriteSingleDocument": false }
|
"Lieferschein": { "TypeId": 4, "OverwriteSingleDocument": false }
|
||||||
|
|||||||
Reference in New Issue
Block a user