Move CLI project into subfolder
This commit is contained in:
24
Cli/Cli.csproj
Normal file
24
Cli/Cli.csproj
Normal file
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>FsToolbox.Cli</RootNamespace>
|
||||
<AssemblyName>FsToolbox.Cli</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
18
Cli/CustomTasks.Regions.cs
Normal file
18
Cli/CustomTasks.Regions.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using FsToolbox.Cli.Tasks;
|
||||
|
||||
namespace FsToolbox.Cli
|
||||
{
|
||||
public partial class CustomTasks
|
||||
{
|
||||
#region Record AldiMember
|
||||
|
||||
/// <summary>
|
||||
/// Aggregates store and member information for ALDI membership analysis.
|
||||
/// </summary>
|
||||
/// <param name="Store">The store participating in the membership relation.</param>
|
||||
/// <param name="Member">The member associated with the store.</param>
|
||||
private record AldiMember(RegionTasks.Store Store, StoreTasks.Member Member);
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
122
Cli/CustomTasks.cs
Normal file
122
Cli/CustomTasks.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
using System.Text;
|
||||
using FsToolbox.Cli.Helper;
|
||||
using FsToolbox.Cli.Tasks;
|
||||
|
||||
namespace FsToolbox.Cli
|
||||
{
|
||||
public partial class CustomTasks
|
||||
{
|
||||
#region Public Method CheckAldiMembershipsAsync
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes ALDI store memberships in the Siegen region and writes users with multiple memberships to a report file.
|
||||
/// </summary>
|
||||
/// <param name="httpClient">The HTTP client used to perform the requests.</param>
|
||||
public static async Task CheckAldiMembershipsAsync(HttpClient httpClient)
|
||||
{
|
||||
await AuthHelper.EnsureAuthenticationAsync(httpClient);
|
||||
|
||||
// Implementation for checking Aldi memberships would go here.
|
||||
var stores = await RegionTasks.GetStoresInRegionAsync(httpClient, 139);
|
||||
var activeAldis = stores.Where(x => x.CooperationStatus == RegionTasks.CooperationStatus.Cooperating && x.Name.Contains("ALDI", StringComparison.CurrentCultureIgnoreCase)).ToList();
|
||||
|
||||
Console.WriteLine("Found " + activeAldis.Count + " active ALDI stores in region Siegen.");
|
||||
|
||||
var aldiMembers = new List<AldiMember>();
|
||||
|
||||
// Collect members from each active ALDI store
|
||||
foreach (var activeAldi in activeAldis)
|
||||
{
|
||||
Thread.Sleep(200);
|
||||
Console.WriteLine("Checking members for store: " + activeAldi.Name);
|
||||
|
||||
var members = await StoreTasks.GetStoreMembersAsync(httpClient, activeAldi.Id);
|
||||
var activeMembers = members.Where(x => x is { Verified: StoreTasks.VerifiedStatus.Verified, Team_Active: StoreTasks.TeamActiveStatus.Active }).ToList();
|
||||
|
||||
activeMembers.ForEach(x => aldiMembers.Add(new(activeAldi, x)));
|
||||
}
|
||||
|
||||
// Group memberships
|
||||
var grouped = aldiMembers.GroupBy(x => x.Member.Id);
|
||||
|
||||
// Find groups with more than two memberships
|
||||
var multipleMemberships = grouped.Where(g => g.Count() > 2).OrderByDescending(g => g.Count()).ToList();
|
||||
|
||||
|
||||
Console.WriteLine($"Users with more than two ALDI store memberships: {multipleMemberships.Count}");
|
||||
Console.WriteLine($"Saving data to file.");
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("ALDI Store Memberships Report");
|
||||
sb.AppendLine("Generated: " + DateTime.Now.ToString("dd.MM.yyyy HH:mm"));
|
||||
sb.AppendLine("============================");
|
||||
sb.AppendLine();
|
||||
|
||||
foreach (var group in multipleMemberships)
|
||||
{
|
||||
sb.AppendLine($"{group.First().Member.Name} ({group.Key}) - {group.Count()} ALDIs");
|
||||
foreach (var membership in group) sb.AppendLine($" - {membership.Store.Name}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
// Write to file with timestamp
|
||||
var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
|
||||
var filename = $"AldiMemberships_{timestamp}.txt";
|
||||
await File.WriteAllTextAsync(filename, sb.ToString());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Public Method ConfirmUnconfirmedPickupsLindenbergAsync
|
||||
|
||||
/// <summary>
|
||||
/// Confirms all unconfirmed pickup slots for the configured Lindenberg store after user confirmation.
|
||||
/// </summary>
|
||||
/// <param name="httpClient">The HTTP client used to perform the requests.</param>
|
||||
public static async Task ConfirmUnconfirmedPickupsLindenbergAsync(HttpClient httpClient)
|
||||
{
|
||||
await AuthHelper.EnsureAuthenticationAsync(httpClient);
|
||||
|
||||
var toConfirm = new List<(string Name, string Date, int User)>();
|
||||
|
||||
// Collect unconfirmed slots
|
||||
var pickups = await StoreTasks.GetPickupsAsync(httpClient, 56749);
|
||||
foreach (var pickup in pickups)
|
||||
foreach (var slot in pickup.OccupiedSlots)
|
||||
if (!slot.IsConfirmed)
|
||||
{
|
||||
var pickupDate = pickup.Date;
|
||||
var userId = slot.Profile.Id;
|
||||
var userName = slot.Profile.Name;
|
||||
|
||||
toConfirm.Add((userName, pickupDate, userId));
|
||||
}
|
||||
|
||||
if (toConfirm.Count != 0)
|
||||
{
|
||||
toConfirm.ForEach(x => Console.WriteLine($"Slot found: {x.Name} on {x.Date}"));
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Write("No unconfirmed slots found.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm question
|
||||
Console.Write("Confirm all unconfirmed slots? (y/n) (Enter for default): ");
|
||||
var input = Console.ReadLine();
|
||||
if (string.IsNullOrEmpty(input) || input.Equals("y", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.WriteLine("Confirming unconfirmed slots...");
|
||||
|
||||
// Confirm the unconfirmed slots
|
||||
foreach (var confirmEntry in toConfirm)
|
||||
await StoreTasks.PatchPickupAsync(httpClient, 52170, confirmEntry.Date, confirmEntry.User);
|
||||
|
||||
Console.WriteLine("done.");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
29
Cli/Endpoints.cs
Normal file
29
Cli/Endpoints.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using FsToolbox.Cli.Helper;
|
||||
|
||||
namespace FsToolbox.Cli
|
||||
{
|
||||
public static class Endpoints
|
||||
{
|
||||
#region Public Properties
|
||||
|
||||
public static string RegionStores => $"{ApiBase}/api/region/{{0}}/stores";
|
||||
|
||||
public static string StoreMembers => $"{ApiBase}/api/stores/{{0}}/member";
|
||||
|
||||
public static string StorePickups => $"{ApiBase}/api/stores/{{0}}/pickups";
|
||||
|
||||
public static string StorePickupsSlot => $"{ApiBase}/api/stores/{{0}}/pickups/{{1}}/{{2}}";
|
||||
|
||||
public static string UserCurrent => $"{ApiBase}/api/user/current";
|
||||
|
||||
public static string UserLogin => $"{ApiBase}/api/user/login";
|
||||
|
||||
#endregion
|
||||
|
||||
#region Private Properties
|
||||
|
||||
private static string ApiBase => SettingsProvider.Current.Api.BaseUrl.TrimEnd('/');
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
100
Cli/Helper/AuthHelper.cs
Normal file
100
Cli/Helper/AuthHelper.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using FsToolbox.Cli.Tasks;
|
||||
|
||||
namespace FsToolbox.Cli.Helper
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides helper methods to manage authentication and cached CSRF tokens.
|
||||
/// </summary>
|
||||
public static class AuthHelper
|
||||
{
|
||||
#region Public Method EnsureAuthenticationAsync
|
||||
|
||||
/// <summary>
|
||||
/// Ensures the HTTP client carries a valid CSRF token, logging in when needed.
|
||||
/// </summary>
|
||||
/// <param name="httpClient">The HTTP client whose headers should be updated.</param>
|
||||
public static async Task EnsureAuthenticationAsync(HttpClient httpClient)
|
||||
{
|
||||
// Check if header already contains a CSRF token
|
||||
if (httpClient.DefaultRequestHeaders.TryGetValues("X-CSRF-Token", out var existingTokens))
|
||||
{
|
||||
var existingToken = existingTokens.FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(existingToken))
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to load CSRF token from file
|
||||
var csrfToken = await LoadCsrfTokenAsync();
|
||||
csrfToken = null;
|
||||
|
||||
// If no valid token found, call login endpoint to get a new one
|
||||
if (string.IsNullOrWhiteSpace(csrfToken))
|
||||
csrfToken = await UserTasks.CallLoginEndpointAsync(httpClient);
|
||||
|
||||
// Set CSRF token in HTTP client headers
|
||||
if (!string.IsNullOrWhiteSpace(csrfToken))
|
||||
{
|
||||
csrfToken = csrfToken.ReplaceLineEndings(string.Empty);
|
||||
|
||||
httpClient.DefaultRequestHeaders.Remove("X-CSRF-Token");
|
||||
httpClient.DefaultRequestHeaders.Add("X-CSRF-Token", csrfToken);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Public Method StoreCsrfTokenAsync
|
||||
|
||||
/// <summary>
|
||||
/// Persists the CSRF token to the local cache file until it expires.
|
||||
/// </summary>
|
||||
/// <param name="csrfToken">The token to store; ignored when null or whitespace.</param>
|
||||
/// <param name="expiration">The UTC expiration timestamp received from the server.</param>
|
||||
public static async Task StoreCsrfTokenAsync(string? csrfToken, DateTime expiration)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(csrfToken)) return;
|
||||
|
||||
var tokenInfo = new
|
||||
{
|
||||
token = csrfToken,
|
||||
expiresAt = expiration
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(tokenInfo, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
});
|
||||
|
||||
await File.WriteAllTextAsync("csrf_token.json", json);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Private Method LoadCsrfTokenAsync
|
||||
|
||||
private static async Task<string?> LoadCsrfTokenAsync()
|
||||
{
|
||||
if (!File.Exists("csrf_token.json")) return null;
|
||||
|
||||
var json = await File.ReadAllTextAsync("csrf_token.json");
|
||||
var tokenInfo = JsonSerializer.Deserialize<JsonObject>(json);
|
||||
|
||||
if (tokenInfo == null) return null;
|
||||
|
||||
var token = tokenInfo["token"]?.GetValue<string>();
|
||||
var expiresAtString = tokenInfo["expiresAt"]?.GetValue<string>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token) || string.IsNullOrWhiteSpace(expiresAtString)) return null;
|
||||
|
||||
if (DateTime.TryParse(expiresAtString, out var expiresAt))
|
||||
if (DateTime.UtcNow < expiresAt.ToUniversalTime())
|
||||
return token;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
101
Cli/Helper/Settings.cs
Normal file
101
Cli/Helper/Settings.cs
Normal file
@@ -0,0 +1,101 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace FsToolbox.Cli.Helper
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents the root configuration settings for the application, including API and credentials.
|
||||
/// </summary>
|
||||
public class AppSettings
|
||||
{
|
||||
#region Public Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets the API-related settings.
|
||||
/// </summary>
|
||||
public ApiSettings Api { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the credentials settings for authentication.
|
||||
/// </summary>
|
||||
public CredentialsSettings Credentials { get; init; } = new();
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contains settings related to the API endpoints.
|
||||
/// </summary>
|
||||
public class ApiSettings
|
||||
{
|
||||
#region Public Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets the base URL for the API, without the /api path segment.
|
||||
/// </summary>
|
||||
public string BaseUrl { get; init; } = "https://beta.foodsharing.de";
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contains credentials and authentication settings.
|
||||
/// </summary>
|
||||
public class CredentialsSettings
|
||||
{
|
||||
#region Public Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets the email address used for login.
|
||||
/// </summary>
|
||||
public string Email { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the password used for login.
|
||||
/// </summary>
|
||||
public string Password { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether two-factor authentication is enabled.
|
||||
/// </summary>
|
||||
public bool TwoFactorEnabled { get; init; }
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides access to the current application settings loaded from configuration.
|
||||
/// </summary>
|
||||
public static class SettingsProvider
|
||||
{
|
||||
#region Constants
|
||||
|
||||
private static AppSettings? _current;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Public Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current application settings instance.
|
||||
/// </summary>
|
||||
public static AppSettings Current => _current ??= Load();
|
||||
|
||||
#endregion
|
||||
|
||||
#region Private Method Load
|
||||
|
||||
private static AppSettings Load()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.SetBasePath(AppContext.BaseDirectory)
|
||||
.AddJsonFile("appsettings.json", false, true)
|
||||
.Build();
|
||||
|
||||
var settings = new AppSettings();
|
||||
configuration.Bind(settings);
|
||||
return settings;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
30
Cli/Program.cs
Normal file
30
Cli/Program.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FsToolbox.Cli;
|
||||
|
||||
// See https://aka.ms/new-console-template for more information
|
||||
|
||||
using var httpClient = new HttpClient();
|
||||
|
||||
// Show menu for the user to choose from the two tasks
|
||||
Console.WriteLine("Choose a task to execute:");
|
||||
Console.WriteLine("1. Check Aldi Memberships");
|
||||
Console.WriteLine("2. Confirm all Unconfirmed Pickups for Lindenberg");
|
||||
Console.Write("Enter the number of the task to execute (or any other key to exit): ");
|
||||
var choice = Console.ReadLine();
|
||||
|
||||
switch (choice)
|
||||
{
|
||||
case "1":
|
||||
await CustomTasks.CheckAldiMembershipsAsync(httpClient);
|
||||
break;
|
||||
case "2":
|
||||
await CustomTasks.ConfirmUnconfirmedPickupsLindenbergAsync(httpClient);
|
||||
break;
|
||||
default:
|
||||
Console.WriteLine("Exiting...");
|
||||
break;
|
||||
}
|
||||
35
Cli/Tasks/RegionTasks.Regions.cs
Normal file
35
Cli/Tasks/RegionTasks.Regions.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
namespace FsToolbox.Cli.Tasks
|
||||
{
|
||||
public static partial class RegionTasks
|
||||
{
|
||||
#region Record Store
|
||||
|
||||
/// <summary>
|
||||
/// Basic region store information including cooperation status.
|
||||
/// </summary>
|
||||
/// <param name="Id">The store identifier.</param>
|
||||
/// <param name="Name">The store name.</param>
|
||||
/// <param name="CooperationStatus">The cooperation status of the store.</param>
|
||||
public record Store(int Id, string Name, CooperationStatus CooperationStatus);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Enum CooperationStatus
|
||||
|
||||
/// <summary>
|
||||
/// Describes the cooperation state between the store and the organization.
|
||||
/// </summary>
|
||||
public enum CooperationStatus
|
||||
{
|
||||
NoStatus = 0,
|
||||
NoContact = 1,
|
||||
Negotiating = 2,
|
||||
DoNotWant = 4,
|
||||
Cooperating = 5,
|
||||
DonatingToTafel = 6,
|
||||
NoExisting = 7
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
50
Cli/Tasks/RegionTasks.cs
Normal file
50
Cli/Tasks/RegionTasks.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using FsToolbox.Cli.Helper;
|
||||
|
||||
namespace FsToolbox.Cli.Tasks
|
||||
{
|
||||
public static partial class RegionTasks
|
||||
{
|
||||
#region Public Method GetStoresInRegionAsync
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves all stores within the specified region, ensuring the request is authenticated.
|
||||
/// </summary>
|
||||
/// <param name="httpClient">The HTTP client used to perform the request.</param>
|
||||
/// <param name="regionId">The region identifier to query.</param>
|
||||
/// <returns>A list of stores, or an empty list when the call fails or returns no data.</returns>
|
||||
public static async Task<List<Store>> GetStoresInRegionAsync(HttpClient httpClient, int regionId)
|
||||
{
|
||||
await AuthHelper.EnsureAuthenticationAsync(httpClient);
|
||||
|
||||
var uri = string.Format(Endpoints.RegionStores, regionId);
|
||||
var response = await httpClient.GetAsync(uri);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// handle unsuccessful response
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await Console.Error.WriteLineAsync($"Region stores retrieval failed ({(int)response.StatusCode} {response.ReasonPhrase}): {responseBody}");
|
||||
return [];
|
||||
}
|
||||
|
||||
Console.WriteLine($"Stores in region {regionId}:");
|
||||
Console.WriteLine(responseBody);
|
||||
|
||||
var root = JsonNode.Parse(responseBody);
|
||||
if (root == null) return [];
|
||||
|
||||
// Deserialize JsonNode to List<Pickup>
|
||||
var opts = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = null // <── WICHTIG
|
||||
};
|
||||
|
||||
return root["stores"].Deserialize<List<Store>>(opts) ?? [];
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
77
Cli/Tasks/StoreTasks.Regions.cs
Normal file
77
Cli/Tasks/StoreTasks.Regions.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
namespace FsToolbox.Cli.Tasks
|
||||
{
|
||||
public static partial class StoreTasks
|
||||
{
|
||||
#region Record Member
|
||||
|
||||
/// <summary>
|
||||
/// Detailed store member information including verification and team status flags.
|
||||
/// </summary>
|
||||
/// <param name="Id">The member identifier.</param>
|
||||
/// <param name="Name">The member name.</param>
|
||||
/// <param name="Team_Active">The member's active status within the team.</param>
|
||||
/// <param name="Verified">The verification state of the member.</param>
|
||||
public record Member(int Id, string Name, TeamActiveStatus Team_Active, VerifiedStatus Verified);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Record Pickup
|
||||
|
||||
/// <summary>
|
||||
/// Represents a pickup date and the occupied slots for that date.
|
||||
/// </summary>
|
||||
/// <param name="Date">The pickup date string.</param>
|
||||
/// <param name="OccupiedSlots">The slots already assigned for the date.</param>
|
||||
public record Pickup(string Date, List<Slot> OccupiedSlots);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Record Profile
|
||||
|
||||
/// <summary>
|
||||
/// Minimal profile information for a store member.
|
||||
/// </summary>
|
||||
/// <param name="Id">The Foodsharing profile identifier.</param>
|
||||
/// <param name="Name">The profile display name.</param>
|
||||
public record Profile(int Id, string Name);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Record Slot
|
||||
|
||||
/// <summary>
|
||||
/// Describes a booked slot and the profile occupying it.
|
||||
/// </summary>
|
||||
/// <param name="IsConfirmed">Indicates whether the slot is confirmed.</param>
|
||||
/// <param name="Profile">The profile assigned to the slot.</param>
|
||||
public record Slot(bool IsConfirmed, Profile Profile);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Enum TeamActiveStatus
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether a team member is active or only available as a jumper.
|
||||
/// </summary>
|
||||
public enum TeamActiveStatus
|
||||
{
|
||||
Jumper = 2,
|
||||
Active = 1
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Enum VerifiedStatus
|
||||
|
||||
/// <summary>
|
||||
/// Specifies whether a member has completed verification.
|
||||
/// </summary>
|
||||
public enum VerifiedStatus
|
||||
{
|
||||
Unverified = 0,
|
||||
Verified = 1
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
113
Cli/Tasks/StoreTasks.cs
Normal file
113
Cli/Tasks/StoreTasks.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using FsToolbox.Cli.Helper;
|
||||
|
||||
namespace FsToolbox.Cli.Tasks
|
||||
{
|
||||
public static partial class StoreTasks
|
||||
{
|
||||
#region Public Method GetPickupsAsync
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves all pickups for the specified store with authentication handled automatically.
|
||||
/// </summary>
|
||||
/// <param name="httpClient">The HTTP client used to send the request.</param>
|
||||
/// <param name="storeId">The store identifier to query.</param>
|
||||
/// <returns>A list of pickups, or an empty list when no data is available.</returns>
|
||||
public static async Task<List<Pickup>> GetPickupsAsync(HttpClient httpClient, int storeId)
|
||||
{
|
||||
await AuthHelper.EnsureAuthenticationAsync(httpClient);
|
||||
|
||||
var uri = string.Format(Endpoints.StorePickups, storeId);
|
||||
var response = await httpClient.GetAsync(uri);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// handle unsuccessful response
|
||||
if (!response.IsSuccessStatusCode) await Console.Error.WriteLineAsync($"Pickup retrieval failed ({(int)response.StatusCode} {response.ReasonPhrase}): {responseBody}");
|
||||
|
||||
var root = JsonNode.Parse(responseBody);
|
||||
if (root == null) return [];
|
||||
|
||||
// Deserialize JsonNode to List<Pickup>
|
||||
var opts = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = null // <── WICHTIG
|
||||
};
|
||||
|
||||
return root["pickups"].Deserialize<List<Pickup>>(opts) ?? [];
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Public Method GetStoreMembersAsync
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the members of the specified store, requiring authentication before the request.
|
||||
/// </summary>
|
||||
/// <param name="httpClient">The HTTP client used to send the request.</param>
|
||||
/// <param name="storeId">The store identifier to query.</param>
|
||||
/// <returns>A list of store members, or an empty list when the call fails.</returns>
|
||||
public static async Task<List<Member>> GetStoreMembersAsync(HttpClient httpClient, int storeId)
|
||||
{
|
||||
await AuthHelper.EnsureAuthenticationAsync(httpClient);
|
||||
|
||||
var uri = string.Format(Endpoints.StoreMembers, storeId);
|
||||
var response = await httpClient.GetAsync(uri);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// handle unsuccessful response
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await Console.Error.WriteLineAsync($"Store members retrieval failed ({(int)response.StatusCode} {response.ReasonPhrase}): {responseBody}");
|
||||
return [];
|
||||
}
|
||||
|
||||
var root = JsonNode.Parse(responseBody);
|
||||
if (root == null) return [];
|
||||
|
||||
// Deserialize JsonNode to List<Pickup>
|
||||
var opts = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = null // <── WICHTIG
|
||||
};
|
||||
|
||||
return root.Deserialize<List<Member>>(opts) ?? [];
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Public Method PatchPickupAsync
|
||||
|
||||
/// <summary>
|
||||
/// Marks a pickup slot as confirmed for the specified store, date, and user.
|
||||
/// </summary>
|
||||
/// <param name="httpClient">The HTTP client used to send the request.</param>
|
||||
/// <param name="storeId">The store identifier containing the pickup.</param>
|
||||
/// <param name="pickupDate">The date of the pickup to patch.</param>
|
||||
/// <param name="fsId">The Foodsharing user identifier associated with the slot.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
public static async Task PatchPickupAsync(HttpClient httpClient, int storeId, string pickupDate, int fsId)
|
||||
{
|
||||
await AuthHelper.EnsureAuthenticationAsync(httpClient);
|
||||
|
||||
var uri = string.Format(Endpoints.StorePickupsSlot, storeId, pickupDate, fsId);
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["isConfirmed"] = true
|
||||
};
|
||||
var response = await httpClient.PatchAsync(uri, JsonContent.Create(payload));
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// handle unsuccessful response
|
||||
if (!response.IsSuccessStatusCode)
|
||||
await Console.Error.WriteLineAsync($"Pickup patch failed ({(int)response.StatusCode} {response.ReasonPhrase}): {responseBody}");
|
||||
else
|
||||
Console.WriteLine($"Pickup patch succeeded {fsId} on {pickupDate}");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
109
Cli/Tasks/UserTasks.cs
Normal file
109
Cli/Tasks/UserTasks.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net.Http.Json;
|
||||
using FsToolbox.Cli.Helper;
|
||||
|
||||
namespace FsToolbox.Cli.Tasks
|
||||
{
|
||||
public static class UserTasks
|
||||
{
|
||||
/// <summary>
|
||||
/// Performs a login request using configured credentials and optional two-factor authentication.
|
||||
/// </summary>
|
||||
/// <param name="httpClient">The HTTP client used to send the request.</param>
|
||||
/// <returns>The CSRF token when login succeeds; otherwise, <c>null</c>.</returns>
|
||||
public static async Task<string?> CallLoginEndpointAsync(HttpClient httpClient)
|
||||
{
|
||||
var credentials = SettingsProvider.Current.Credentials;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(credentials.Email) || string.IsNullOrWhiteSpace(credentials.Password))
|
||||
{
|
||||
Console.WriteLine("Email and password must be configured in appsettings.json.");
|
||||
return null;
|
||||
}
|
||||
|
||||
string? authCode = null;
|
||||
if (credentials.TwoFactorEnabled)
|
||||
{
|
||||
Console.Write("Enter 2FA code: ");
|
||||
authCode = Console.ReadLine()?.Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(authCode))
|
||||
{
|
||||
Console.WriteLine("A valid 2FA code is required when two-factor authentication is enabled.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
string? csrfToken = null;
|
||||
|
||||
object payload = credentials.TwoFactorEnabled
|
||||
? new
|
||||
{
|
||||
email = credentials.Email,
|
||||
password = credentials.Password,
|
||||
code = authCode,
|
||||
remember_me = true
|
||||
}
|
||||
: new
|
||||
{
|
||||
email = credentials.Email,
|
||||
password = credentials.Password,
|
||||
remember_me = true
|
||||
};
|
||||
|
||||
var response = await httpClient.PostAsJsonAsync(Endpoints.UserLogin, payload);
|
||||
|
||||
// handle unsuccessful response
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
await Console.Error.WriteLineAsync($"Login failed ({(int)response.StatusCode} {response.ReasonPhrase}): {responseBody}");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Read headers as dictionary
|
||||
var headers = response.Headers.ToDictionary(h => h.Key, h => string.Join(", ", h.Value));
|
||||
if (headers.TryGetValue("Set-Cookie", out var setCookieHeader))
|
||||
{
|
||||
// Split cookies by comma and semicolon
|
||||
var cookies = setCookieHeader.Split([";"], StringSplitOptions.RemoveEmptyEntries);
|
||||
var csrfTokenEntry = cookies.FirstOrDefault(c => c.Trim().StartsWith("FS_CSRF_TOKEN="));
|
||||
csrfToken = csrfTokenEntry?.Split('=')[1];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(csrfToken)) return null;
|
||||
|
||||
var expiryEntry = cookies.FirstOrDefault(c => c.Trim().StartsWith("expires="));
|
||||
var expireString = expiryEntry?.Split('=')[1];
|
||||
if (DateTime.TryParse(expireString, out var expiration))
|
||||
await AuthHelper.StoreCsrfTokenAsync(csrfToken, expiration);
|
||||
}
|
||||
|
||||
return csrfToken;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves information about the current authenticated user.
|
||||
/// </summary>
|
||||
/// <param name="httpClient">The HTTP client used to send the request.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public static async Task GetCurrentUserAsync(HttpClient httpClient)
|
||||
{
|
||||
await AuthHelper.EnsureAuthenticationAsync(httpClient);
|
||||
|
||||
var response = await httpClient.GetAsync(Endpoints.UserCurrent);
|
||||
|
||||
// handle unsuccessful response
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
await Console.Error.WriteLineAsync($"Get current user failed ({(int)response.StatusCode} {response.ReasonPhrase}): {responseBody}");
|
||||
return;
|
||||
}
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
Console.WriteLine("Current User Info:");
|
||||
Console.WriteLine(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
10
Cli/appsettings.example.json
Normal file
10
Cli/appsettings.example.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"Api": {
|
||||
"BaseUrl": "https://beta.foodsharing.de"
|
||||
},
|
||||
"Credentials": {
|
||||
"Email": "demo@example.com",
|
||||
"Password": "demo-password",
|
||||
"TwoFactorEnabled": false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user