feat/files-control #52

Open
notazof wants to merge 32 commits from feat/files-control into main
94 changed files with 10747 additions and 1232 deletions

View file

@ -31,6 +31,7 @@ jobs:
steps:
- name: Clone yavsc
run: |
set -e
cd /src
git clone https://forgejo.pschneider.fr/notazof/yavsc.git _src
cd _src
@ -40,12 +41,14 @@ jobs:
fi
git submodule update --init --recursive
echo "✅ Checked out at $(git rev-parse HEAD) on $(git branch --show-current 2>/dev/null || echo detached HEAD)"
- name: Secret scan
run: |
echo "🔍 Scanning for secrets..."
cd /src/_src && dotnet tool restore && dotnet picket git --verbose --redact --exit-code 1 --log-opts -n1 \
- name: Test
run: |
echo "🚀 Lancement des tests..."
cd /src/_src && dotnet test \
--verbosity normal \
--filter="Category!=Platform-Android" \
--logger "xunit;LogFileName=test-results.xml" \
&& echo "✅ Success !" || echo "❌ Fail ($?)!"
--logger "xunit;LogFileName=test-results.xml"

View file

@ -175,6 +175,12 @@ jobs:
run: |
cd /src/_src
dotnet restore
- name: Test
run: |
cd /src/_src && dotnet test \
--verbosity normal \
--filter="Category!=Platform-Android" \
--logger "xunit;LogFileName=test-results.xml"
- name: Build de PostIt.Android ARM64
run: |
@ -200,6 +206,7 @@ jobs:
RELEASE_BODY: ${{ env.RELEASE_BODY }}
IS_PRERELEASE: ${{ env.IS_PRERELEASE }}
run: |
set -e
if [[ -z "$TAG" ]]; then
echo "::error::No tag resolved for the API call."
exit 1

45
.gitleaksignore Normal file
View file

@ -0,0 +1,45 @@
# Exclure uniquement les dossiers de sortie de compilation
bin/
obj/
src/*/bin/
src/*/obj/
test/*/bin/
test/*/obj/
# Toolchain front (Node / esbuild)
node_modules/
build/
package-lock.json
# Exclure les caches lourds
.git/
.vs/
.env
.*.env
*.csproj.lscache
data/
appsettings.*.json
appsettings-*.*.json
# Exception: the Testing-environment override for Yavsc.Org is a tracked
# configuration source, not a secrets file. TestWebApplicationFactory
# (Yavsc.Org.Tests) flips ASPNETCORE_ENVIRONMENT to "Testing" so
# AddConfiguration("org") in Program.Main loads this file as the
# last in the chain (it is optional). It overrides the connection
# string and SMTP section for the in-memory test host and contains
# no production secrets.
!src/Yavsc.Org/appsettings-org.Testing.json
generated/
*.tmp
tmp/
DataDir/
*.tests.trx
*.tests.html
*.log

27
.vscode/tasks.json vendored
View file

@ -47,7 +47,10 @@
"group": "build",
"isBuildCommand": true,
"isTestCommand": false,
"isBackground": true
"isBackground": true,
"options": {
"cwd": "${workspaceFolder}"
}
},
{
"label": "test blogs backend",
@ -63,6 +66,28 @@
"isDefault": false
}
},
{
"label": "test api backend (npgsql)",
"type": "process",
"problemMatcher": ["$msCompile"],
"command": "dotnet",
"args": [
"test",
"Yavsc.Api.Test.csproj",
"-v",
"minimal"
],
"options": {
"cwd": "src/Yavsc.Api.Test",
"env": {
"YAVSC_API_TEST_DB_PROVIDER": "npgsql",
}
},
"group": {
"kind": "test",
"isDefault": false
}
},
{
"label": "build-webapi",
"type": "process",

View file

@ -48,70 +48,4 @@ docker-build:
docker-run:
docker run -d -p 5000:5000 --name yavsc yavsc
# Crée une branche release/<V> depuis main, met à jour les
# `<Version>` des .csproj via dotnet-gitversion, et la
# pousse sur origin.
#
# Usage : make release V=1.0.7-rc1
#
# Pré-requis : être sur main, working tree clean. La cible
# vérifie les deux et refuse sinon — elle ne fait JAMAIS
# de checkout automatique, c'est à l'opérateur de s'être
# positionné sur la bonne branche au préalable (sinon le
# bump pourrait partir sur une branche tierce par accident).
#
# Notes :
# - Le nom de branche vient de l'argument V (ex: 1.0.7-rc1
# donne release/1.0.7-rc1). C'est une étiquette d'intention,
# pas la version assembly.
# - La version dans les .csproj vient de GitVersion qui la
# calcule depuis l'historique git (tag le plus proche +
# nombre de commits). C'est la version assembly réelle.
# - L'ordre (fetch → branche → bump → push) garantit qu'on
# part d'un main synchro et qu'on ne pollue pas main avec
# le bump (qui vit sur la branche release).
# - Fail-fast si la branche existe déjà en local ou sur origin.
release:
@if [ -z "$(V)" ]; then \
echo "Usage: make release V=<version>"; \
echo " V : version semver (ex. 1.0.7-rc1) — sert à nommer la branche."; \
exit 1; \
fi
@if [ -n "$$(git status --porcelain)" ]; then \
echo "Working tree sale, refus de créer une branche release."; \
git status --short; \
exit 1; \
fi
@BRANCH="release/$(V)"; \
if git show-ref --verify --quiet "refs/heads/$$BRANCH"; then \
echo "La branche $$BRANCH existe déjà en local."; \
echo " Pour la supprimer : git branch -D $$BRANCH"; \
exit 1; \
fi; \
if git ls-remote --exit-code --heads origin "$$BRANCH" >/dev/null 2>&1; then \
echo "La branche $$BRANCH existe déjà sur origin."; \
exit 1; \
fi; \
echo "==> Fetch + vérification synchro main"; \
git fetch origin main; \
if ! git merge-base --is-ancestor origin/main HEAD; then \
echo "main a avancé plus loin que HEAD. Fais :"; \
echo " git pull --ff-only origin main"; \
exit 1; \
fi; \
echo "==> Création de $$BRANCH depuis main"; \
git checkout -b "$$BRANCH"; \
echo "==> dotnet-gitversion /updateprojectfiles"; \
dotnet-gitversion /updateprojectfiles; \
echo "==> Commit du bump"; \
git add .; \
if git diff --cached --quiet; then \
echo "Pas de changements à committer (gitversion n'a produit aucune diff)."; \
else \
git commit -m "chore(release): bump version via gitversion for $(V)"; \
fi; \
echo "==> Push de $$BRANCH sur origin"; \
git push -u origin "$$BRANCH"; \
echo "==> Terminé. Branche $$BRANCH live sur origin."
.PHONY: test release
.PHONY: test install docker-image docker-build docker-run

View file

@ -95,6 +95,15 @@ showConfig:
@echo CONFIGURATION: $(CONFIGURATION)
@echo BASEAPPDIR: $(BASEAPPDIR)
showApiLogs:
@sudo journalctl -u yavscApi.service -S "2 min ago" | tee yavscApi.log
showOrgLogs:
@sudo journalctl -u yavscOrg.service -S "2 min ago" | tee yavscOrg.log
showBlogsLogs:
@sudo journalctl -u yavscBlogs.service -S "2 min ago" | tee yavscBlogs.log
clean:
@rm -rf generated

View file

@ -1,5 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {}
"tools": {
"picket": {
"version": "0.2.12",
"commands": [
"picket"
],
"rollForward": false
}
}
}

View file

@ -162,6 +162,12 @@ public class ActivitiesPageViewModelTests
return Task.CompletedTask;
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<T>(method, path, (object?)null, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -352,6 +352,12 @@ public class BillingCommandPageViewModelTests
return Task.CompletedTask;
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<T>(method, path, (object?)null, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -45,10 +45,8 @@ public class BillingQueriesPageViewModelTests
Assert.Equal(2, vm.Queries.Count);
Assert.All(vm.Queries, q => Assert.DoesNotContain("Rejected", q.StatusLabel, StringComparison.OrdinalIgnoreCase));
Assert.Contains("lecture seule", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
Assert.False(vm.CanOpenDetails);
vm.SelectedQuery = vm.Queries[0];
Assert.False(vm.OpenSelectedQueryCommand.CanExecute(null));
Assert.True(vm.Queries.Count > 0);
}
private sealed class StubBillingApi : IYavscApiClient
@ -129,6 +127,12 @@ public class BillingQueriesPageViewModelTests
return Task.CompletedTask;
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<T>(method, path, (object?)null, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -0,0 +1,270 @@
using System.Net.Http;
using PostIt.ViewModels;
using Yavsc;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public class EstimateEditionPageViewModelTests
{
private static BillingQuerySummaryDto SampleQuery() => new()
{
Id = 42,
BillingCode = "Brush",
ActivityCode = "hair",
PerformerId = "perf-1",
ClientId = "cli-1",
Status = QueryStatus.InProgress,
Description = "Coupe simple",
EventDate = new DateTime(2026, 9, 12, 10, 0, 0, DateTimeKind.Utc),
};
private static EstimateEditionPageViewModel CreateViewModel(StubEstimateApi api, BillingQuerySummaryDto? query = null)
{
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
return new EstimateEditionPageViewModel(query ?? SampleQuery(), client);
}
[Fact]
public void Constructor_prefills_description_and_adds_a_first_line()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
Assert.Equal("Coupe simple", vm.EstimateDescription);
Assert.Single(vm.Lines);
Assert.Same(vm.Lines[0], vm.SelectedLine);
Assert.Contains("#42", vm.ContextLabel);
Assert.Contains("cli-1", vm.ContextLabel);
}
[Fact]
public void AddLine_appends_and_selects_the_new_line()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
vm.AddLineCommand.Execute(null);
Assert.Equal(2, vm.Lines.Count);
Assert.Same(vm.Lines[1], vm.SelectedLine);
}
[Fact]
public void RemoveLine_removes_the_selected_line()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
var first = vm.Lines[0];
vm.RemoveLineCommand.Execute(null);
Assert.Empty(vm.Lines);
Assert.Null(vm.SelectedLine);
Assert.False(vm.RemoveLineCommand.CanExecute(null));
Assert.DoesNotContain(first, vm.Lines);
}
[Fact]
public void Total_sums_line_totals_and_tracks_edits()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
vm.Lines[0].Count = 2;
vm.Lines[0].UnitaryCost = 15.5m;
Assert.Equal(31m, vm.Total);
Assert.Equal($"{31m:0.00} EUR", vm.TotalLabel);
vm.AddLineCommand.Execute(null);
vm.Lines[1].Count = 1;
vm.Lines[1].UnitaryCost = 9m;
Assert.Equal(40m, vm.Total);
}
[Fact]
public async Task Send_without_title_warns_and_does_not_post()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
vm.Lines[0].Name = "Coupe";
vm.Lines[0].Description = "Coupe simple";
vm.Lines[0].UnitaryCost = 25m;
await vm.SendCommand.ExecuteAsync(null);
Assert.Null(api.LastBody);
Assert.Equal(StatusSeverity.Warning, vm.ActionStatus.Severity);
Assert.Contains("titre", vm.ActionStatus.Message);
}
[Fact]
public async Task Send_without_any_line_warns_and_does_not_post()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
vm.EstimateTitle = "Devis coupe";
vm.Lines.Clear();
await vm.SendCommand.ExecuteAsync(null);
Assert.Null(api.LastBody);
Assert.Equal(StatusSeverity.Warning, vm.ActionStatus.Severity);
Assert.Contains("ligne", vm.ActionStatus.Message);
}
[Fact]
public async Task Send_with_a_blank_line_name_warns_and_does_not_post()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
vm.EstimateTitle = "Devis coupe";
vm.Lines[0].Description = "Oubli du nom";
await vm.SendCommand.ExecuteAsync(null);
Assert.Null(api.LastBody);
Assert.Equal(StatusSeverity.Warning, vm.ActionStatus.Severity);
Assert.Contains("nom", vm.ActionStatus.Message);
}
[Fact]
public async Task Send_posts_the_estimate_payload_to_the_estimate_route()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
vm.EstimateTitle = " Devis coupe ";
vm.Lines[0].Name = "Coupe";
vm.Lines[0].Description = "Coupe simple";
vm.Lines[0].Count = 2.4m;
vm.Lines[0].UnitaryCost = 25m;
await vm.SendCommand.ExecuteAsync(null);
Assert.Equal("https://business.example/api/v1/estimate", api.LastPath);
Assert.Equal(HttpMethod.Post, api.LastMethod);
var payload = Assert.IsType<EstimateDto>(api.LastBody);
Assert.Equal(42, payload.CommandId);
Assert.Equal("cli-1", payload.ClientId);
Assert.Equal("Brush", payload.CommandType);
Assert.Equal("Devis coupe", payload.Title);
Assert.Equal("Coupe simple", payload.Description);
Assert.Empty(payload.AttachedFiles);
Assert.Empty(payload.AttachedGraphics);
var line = Assert.Single(payload.Bill);
Assert.Equal("Coupe", line.Name);
Assert.Equal(2, line.Count);
Assert.Equal(25m, line.UnitaryCost);
Assert.Equal("EUR", line.Currency);
}
[Fact]
public async Task Send_marks_the_page_as_sent_and_disables_resend()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
vm.EstimateTitle = "Devis coupe";
vm.Lines[0].Name = "Coupe";
vm.Lines[0].Description = "Coupe simple";
vm.Lines[0].UnitaryCost = 25m;
await vm.SendCommand.ExecuteAsync(null);
Assert.True(vm.HasSent);
Assert.False(vm.SendCommand.CanExecute(null));
Assert.Equal("Devis envoyé", vm.SendLabel);
Assert.Equal(StatusSeverity.Info, vm.ActionStatus.Severity);
Assert.Contains("#7", vm.ActionStatus.Message);
}
[Fact]
public async Task Send_surfaces_server_errors_as_error_status()
{
var api = new StubEstimateApi { Failure = new HttpRequestException("boom", null, System.Net.HttpStatusCode.InternalServerError) };
var vm = CreateViewModel(api);
vm.EstimateTitle = "Devis coupe";
vm.Lines[0].Name = "Coupe";
vm.Lines[0].Description = "Coupe simple";
await vm.SendCommand.ExecuteAsync(null);
Assert.False(vm.HasSent);
Assert.Equal(StatusSeverity.Error, vm.ActionStatus.Severity);
Assert.True(vm.SendCommand.CanExecute(null));
}
[Fact]
public async Task Send_accepts_negative_amounts_for_discount_lines()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
vm.EstimateTitle = "Devis avec remise";
vm.Lines[0].Name = "Coupe";
vm.Lines[0].Description = "Coupe simple";
vm.Lines[0].UnitaryCost = 25m;
vm.AddLineCommand.Execute(null);
vm.Lines[1].Name = "Remise fidélité";
vm.Lines[1].Description = "Remise client régulier";
vm.Lines[1].UnitaryCost = -5m;
Assert.Equal(20m, vm.Total);
await vm.SendCommand.ExecuteAsync(null);
var payload = Assert.IsType<EstimateDto>(api.LastBody);
Assert.Equal(2, payload.Bill.Count);
Assert.Equal(-5m, payload.Bill[1].UnitaryCost);
Assert.True(vm.HasSent);
}
private sealed class StubEstimateApi : IYavscApiClient
{
public HttpClient Http { get; } = new();
public string? LastPath { get; private set; }
public HttpMethod? LastMethod { get; private set; }
public object? LastBody { get; private set; }
public Exception? Failure { get; init; }
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
LastMethod = method;
LastPath = path;
LastBody = body;
if (Failure is not null)
{
throw Failure;
}
if (typeof(T) == typeof(EstimateCreatedDto))
{
var payload = (EstimateDto)body!;
var created = new EstimateCreatedDto { Id = 7, Bill = payload.Bill };
return Task.FromResult((T)(object)created);
}
return Task.FromResult(default(T)!);
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
LastMethod = method;
LastPath = path;
LastBody = body;
return Task.CompletedTask;
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<T>(method, path, (object?)null, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -0,0 +1,15 @@
using PostIt.ViewModels;
namespace PostIt.Tests;
public class HomePageProviderFlowTests
{
[Fact]
public void HomePage_exposes_provider_requests_command()
{
var vm = new HomePageViewModel();
Assert.NotNull(vm.OpenProviderRequests);
Assert.True(vm.OpenProviderRequests.CanExecute(null));
}
}

View file

@ -8,6 +8,8 @@ using Yavsc.Blogspot;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
using PostIt.Views.Blogs;
using PostIt.Helpers;
namespace PostIt.Tests;
@ -72,13 +74,13 @@ public class MainPageButtonsTests
{ }
}
private static MainViewModel MakeViewModel(BlogPostDto? selectedPost = null)
private static BlogsViewModel MakeViewModel(BlogPostDto? selectedPost = null)
{
var api = new ThrowingApi();
var blog = new BlogApiClient(api, "http://localhost/");
var circle = new CircleApiClient(api, "http://localhost/");
var acl = new BlogAclApiClient(api, "http://localhost/");
// Minimal DI graph: only what MainPageViewModel resolves
// Minimal DI graph: only what BlogsViewModel resolves
// when the user clicks a navigation button. Today that's
// SignaturePageViewModel / CirclesPageViewModel / ACL
// dependencies. The graph intentionally stays local to this
@ -93,7 +95,7 @@ public class MainPageButtonsTests
services.AddTransient<SignaturePage>();
services.AddTransient<CirclesPage>();
services.AddTransient<PostAclDialog>();
var vm = new MainViewModel(blog, services: services.BuildServiceProvider());
var vm = new BlogsViewModel(blog, services: services.BuildServiceProvider());
if (selectedPost is not null) vm.SelectedPost = selectedPost;
return vm;
}
@ -101,7 +103,7 @@ public class MainPageButtonsTests
/// <summary>
/// Mount a real <see cref="MainView"/> (as
/// <c>SessionStatusBannerTests</c> does), push a
/// <see cref="MainPage"/> with the given VM onto
/// <see cref="BlogsPage"/> with the given VM onto
/// <c>NavRoot</c>. <c>PushAsync</c> is awaited (via
/// <c>GetAwaiter().GetResult()</c>) so the page is on the
/// nav stack before the test tries to interact with its
@ -109,13 +111,17 @@ public class MainPageButtonsTests
/// realised and <c>KeyPressQwerty</c> has a real
/// <see cref="TopLevel"/> to dispatch against.
/// </summary>
private static (MainView window, MainPage page) MountMainPage(MainViewModel vm)
private static (MainView window, BlogsPage page) MountMainPage(BlogsViewModel vm)
{
var window = new MainView();
var page = new MainPage { DataContext = vm };
var page = new BlogsPage { DataContext = vm };
var app = (PostIt.App)Application.Current!;
app.AttachMainWindow(window);
window.NavRoot.PushAsync(page).GetAwaiter().GetResult();
var mainWindow = new Window { Content = window };
mainWindow.Show();
return (window, page);
}
@ -145,7 +151,7 @@ public class MainPageButtonsTests
}
[AvaloniaFact]
public void Acl_button_click_pushes_a_page_onto_nav_stack()
public async Task Acl_button_click_pushes_a_page_onto_nav_stack()
{
// Arrange: a VM whose SelectedPost is non-null so
// CanManageAcl evaluates to true and the button is
@ -178,7 +184,7 @@ public class MainPageButtonsTests
}
[AvaloniaFact]
public void Circles_button_click_pushes_a_page_onto_nav_stack()
public async Task Circles_button_click_pushes_a_page_onto_nav_stack()
{
// Arrange: OpenCircles has no CanExecute guard today —
// any click should fire it and push the page.
@ -203,7 +209,7 @@ public class MainPageButtonsTests
public void Signature_dev_button_click_pushes_a_page_onto_nav_stack()
{
// Arrange: the "[DEV] Signature" button is bound to the
// MainPageViewModel.OpenSignatureDevCommand [RelayCommand].
// BlogsViewModel.OpenSignatureDevCommand [RelayCommand].
// The click must push SignaturePage on top of NavRoot.
// The ServiceCollection registered in MakeViewModel provides
// SignaturePageViewModel so the command can resolve it via

View file

@ -5,10 +5,11 @@ using Yavsc.Blogspot;
using Yavsc.Api.Client;
using PostIt.ViewModels;
using PostIt.Views;
using PostIt.Views.Blogs;
namespace PostIt.Tests;
/// <summary>
/// Headless UI tests for the "Save" flow in <see cref="MainPage"/>.
/// Headless UI tests for the "Save" flow in <see cref="BlogsPage"/>.
/// The pattern is the one <c>SessionStatusBannerTests</c>
/// established: <c>[AvaloniaFact]</c>, a <see cref="Window"/>
/// hosting the page (via a <see cref="Frame"/> because
@ -40,9 +41,9 @@ public class MainPageSaveTests
var recorder = new CallRecorder();
var api = new RecordingYavscApiClient(recorder);
var blog = new BlogApiClient(api, "http://localhost/");
var viewModel = new MainViewModel(blog);
var viewModel = new BlogsViewModel(blog);
var page = new MainPage { DataContext = viewModel };
var page = new BlogsPage { DataContext = viewModel };
// MainPage is a ContentPage (a Page, not a Control), so it
// must be hosted in a navigation surface. The production
// MainWindow.axaml uses NavigationPage, and the API is the
@ -63,9 +64,7 @@ public class MainPageSaveTests
const string typed = "Mon premier billet";
titleBox.Text = typed;
var saveButton = window.GetVisualDescendants()
.OfType<Button>()
.Single(b => b.Content as string == "Save");
var saveButton = page.SaveButton;
saveButton.Command!.Execute(null);
// The Save command is async (RelayCommand over Task) but

View file

@ -270,6 +270,12 @@ public class PostAclDialogTests
return Task.CompletedTask;
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<T>(method, path, (object?)null, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -11,25 +11,23 @@ public class PostItViewModelTests
[Fact]
public void SearchCommand_filters_posts_by_title_article_or_author()
{
// MainPageViewModel no longer owns a BlogApiClient instance by
// BlogsViewModel no longer owns a BlogApiClient instance by
// default; tests construct one with a fake YavscApiClient that
// throws on any call (we never call the API in this test).
var fakeApi = new ThrowingYavscApiClient();
var blog = new BlogApiClient(fakeApi, "http://localhost/");
var viewModel = new MainViewModel(blog);
var viewModel = new BlogsViewModel(blog);
viewModel.Posts.Add(new BlogPostDto { Id = 1, Title = "First post", Article = "Hello world", AuthorId = "alice" });
viewModel.Posts.Add(new BlogPostDto { Id = 2, Title = "Second post", Article = "Nothing here", AuthorId = "bob" });
viewModel.Posts.Add(new BlogPostDto { Id = 3, Title = "Third post", Article = "Search me", AuthorId = "carol" });
viewModel.SearchText = "search";
viewModel.SearchCommand.Execute(null);
Assert.Single(viewModel.FilteredPosts);
Assert.Equal(3, viewModel.FilteredPosts[0].Id);
viewModel.SearchText = "bob";
viewModel.SearchCommand.Execute(null);
Assert.Single(viewModel.FilteredPosts);
Assert.Equal(2, viewModel.FilteredPosts[0].Id);
@ -60,7 +58,7 @@ public class PostItViewModelTests
{
var api = new RecordingPublishApi();
var blog = new BlogApiClient(api, "http://localhost/");
var viewModel = new MainViewModel(blog);
var viewModel = new BlogsViewModel(blog);
viewModel.SelectedPost = new BlogPostDto { Id = 42, IsPublished = false };
@ -146,6 +144,12 @@ public class PostItViewModelTests
return Task.CompletedTask;
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<T>(method, path, (object?)null, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -0,0 +1,238 @@
using System.Net.Http;
using PostIt.ViewModels;
using Yavsc;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public class ProviderOngoingRequestsPageViewModelTests
{
[Fact]
public async Task RefreshAsync_calls_provider_endpoint_and_filters_out_unknown_billing_codes()
{
var api = new StubProviderApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm = new ProviderOngoingRequestsPageViewModel(client);
await vm.InitializeAsync();
Assert.Contains("https://business.example/api/v1/bill/provider/ongoing", api.Paths);
Assert.Equal(3, vm.Queries.Count);
Assert.Equal(12, vm.Queries[0].Id);
Assert.Equal(11, vm.Queries[1].Id);
Assert.Equal(10, vm.Queries[2].Id);
}
[Fact]
public async Task FilterText_filters_by_activity_code_and_status()
{
var api = new StubProviderApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm = new ProviderOngoingRequestsPageViewModel(client);
await vm.InitializeAsync();
vm.FilterText = "mbrush";
Assert.Single(vm.Queries);
Assert.Equal("MBrush", vm.Queries[0].BillingCode);
vm.FilterText = "accepted";
Assert.Single(vm.Queries);
Assert.Equal(QueryStatus.Accepted, vm.Queries[0].Status);
}
[Fact]
public async Task OpenSelectedEditorCommand_can_execute_only_when_selection_exists()
{
var api = new StubProviderApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm = new ProviderOngoingRequestsPageViewModel(client);
await vm.InitializeAsync();
Assert.False(vm.OpenSelectedEditorCommand.CanExecute(null));
vm.SelectedQuery = vm.Queries[0];
Assert.True(vm.OpenSelectedEditorCommand.CanExecute(null));
}
[Fact]
public async Task SelectedSortOption_date_keeps_most_recent_first()
{
var api = new StubProviderApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm = new ProviderOngoingRequestsPageViewModel(client);
await vm.InitializeAsync();
vm.SelectedSortOption = ProviderOngoingRequestsPageViewModel.SortByDate;
Assert.Equal(new long[] { 12, 11, 10 }, vm.Queries.Select(q => q.Id).ToArray());
}
[Fact]
public async Task SelectedSortOption_date_ascending_keeps_oldest_first()
{
var api = new StubProviderApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm = new ProviderOngoingRequestsPageViewModel(client);
await vm.InitializeAsync();
vm.SelectedSortOption = ProviderOngoingRequestsPageViewModel.SortByDateAsc;
Assert.Equal(new long[] { 10, 11, 12 }, vm.Queries.Select(q => q.Id).ToArray());
}
[Fact]
public async Task SelectedSortOption_status_prioritizes_inprogress_then_accepted_then_inserted()
{
var api = new StubProviderApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm = new ProviderOngoingRequestsPageViewModel(client);
await vm.InitializeAsync();
vm.SelectedSortOption = ProviderOngoingRequestsPageViewModel.SortByStatus;
Assert.Equal(new long[] { 11, 12, 10 }, vm.Queries.Select(q => q.Id).ToArray());
}
[Fact]
public void Constructor_reads_saved_sort_option_from_settings()
{
var api = new StubProviderApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var settings = new Settings
{
ProviderOngoingRequestsSortOption = ProviderOngoingRequestsPageViewModel.SortByStatus,
};
var vm = new ProviderOngoingRequestsPageViewModel(client, settings);
Assert.Equal(ProviderOngoingRequestsPageViewModel.SortByStatus, vm.SelectedSortOption);
}
[Fact]
public void Constructor_falls_back_to_default_when_saved_sort_is_invalid()
{
var api = new StubProviderApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var settings = new Settings
{
ProviderOngoingRequestsSortOption = "invalide",
};
var vm = new ProviderOngoingRequestsPageViewModel(client, settings);
Assert.Equal(ProviderOngoingRequestsPageViewModel.SortByDate, vm.SelectedSortOption);
}
[Fact]
public async Task CreateEstimateForSelectedCommand_requires_a_selection()
{
var api = new StubProviderApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var estimateClient = new EstimateApiClient(api, "https://business.example/api/v1/");
var vm = new ProviderOngoingRequestsPageViewModel(client, estimateClient: estimateClient);
await vm.InitializeAsync();
Assert.False(vm.CreateEstimateForSelectedCommand.CanExecute(null));
vm.SelectedQuery = vm.Queries[0];
Assert.True(vm.CreateEstimateForSelectedCommand.CanExecute(null));
}
[Fact]
public async Task CreateEstimateForSelectedCommand_is_disabled_without_estimate_client()
{
var api = new StubProviderApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm = new ProviderOngoingRequestsPageViewModel(client);
await vm.InitializeAsync();
vm.SelectedQuery = vm.Queries[0];
Assert.False(vm.CreateEstimateForSelectedCommand.CanExecute(null));
}
private sealed class StubProviderApi : IYavscApiClient
{
public HttpClient Http { get; } = new();
public List<string> Paths { get; } = new();
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Paths.Add(path);
if (typeof(T) == typeof(List<BillingQuerySummaryDto>))
{
var data = new List<BillingQuerySummaryDto>
{
new()
{
Id = 10,
BillingCode = "Rdv",
ActivityCode = "dev",
PerformerId = "perf-1",
ClientId = "cli-1",
Status = QueryStatus.Inserted,
Description = "Rendez-vous",
EventDate = new DateTime(2026, 9, 10, 10, 0, 0, DateTimeKind.Utc),
},
new()
{
Id = 11,
BillingCode = "MBrush",
ActivityCode = "hair",
PerformerId = "perf-1",
ClientId = "cli-2",
Status = QueryStatus.InProgress,
Description = "Coupe multiple",
EventDate = new DateTime(2026, 9, 11, 10, 0, 0, DateTimeKind.Utc),
},
new()
{
Id = 12,
BillingCode = "Brush",
ActivityCode = "hair",
PerformerId = "perf-1",
ClientId = "cli-3",
Status = QueryStatus.Accepted,
Description = "Coupe simple",
EventDate = new DateTime(2026, 9, 12, 10, 0, 0, DateTimeKind.Utc),
},
new()
{
Id = 13,
BillingCode = "",
ActivityCode = "unknown",
PerformerId = "perf-1",
ClientId = "cli-4",
Status = QueryStatus.Accepted,
Description = "Doit être filtrée",
EventDate = new DateTime(2026, 9, 13, 10, 0, 0, DateTimeKind.Utc),
}
};
return Task.FromResult((T)(object)data);
}
return Task.FromResult(default(T)!);
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Paths.Add(path);
return Task.CompletedTask;
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<T>(method, path, (object?)null, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -74,6 +74,12 @@ public class RdvPageHeadlessTests
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
=> Task.CompletedTask;
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<T>(method, path, (object?)null, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -46,35 +46,48 @@ public partial class App : Application
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var window = ServiceProvider.GetRequiredService<MainWindow>();
desktop.MainWindow = window;
View = window.MainView;
this.ConfigureRootView(window.MainView);
ApplyDarkMode(settings);
InitializeClassicDesktopLifetime(settings, desktop);
}
else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime)
{
singleViewFactoryApplicationLifetime.MainViewFactory =
() =>
{
View = ServiceProvider.GetRequiredService<MainView>();
this.ConfigureRootView(View);
ApplyDarkMode(settings);
return View;
};
InitializeActivityLifetime(settings, singleViewFactoryApplicationLifetime);
}
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform)
{
singleViewPlatform.MainView = View = ServiceProvider.GetRequiredService<MainView>();
ConfigureRootView(View);
ApplyDarkMode(settings);
InitializeSingleViewLifetime(settings, singleViewPlatform);
}
base.OnFrameworkInitializationCompleted();
}
private void ConfigureRootView(MainView rootView)
public void InitializeSingleViewLifetime(Settings settings, ISingleViewApplicationLifetime singleViewPlatform)
{
singleViewPlatform.MainView = View = ServiceProvider!.GetRequiredService<MainView>();
ConfigureRootView(View);
ApplyDarkMode(settings);
}
public void InitializeActivityLifetime(Settings settings, IActivityApplicationLifetime singleViewFactoryApplicationLifetime)
{
singleViewFactoryApplicationLifetime.MainViewFactory =
() =>
{
this.ConfigureRootView(ServiceProvider!.GetRequiredService<MainView>());
ApplyDarkMode(settings);
return View!;
};
}
public void InitializeClassicDesktopLifetime(Settings settings, IClassicDesktopStyleApplicationLifetime desktop)
{
var window = ServiceProvider!.GetRequiredService<MainWindow>();
desktop.MainWindow = window;
this.ConfigureRootView(window.MainView);
ApplyDarkMode(settings);
}
public void ConfigureRootView(MainView rootView)
{
// Déclencher le Boot une seule fois lors du chargement du contrôle à l'écran.
rootView.AttachedToVisualTree += async (_, _) => await BootOnceAsync();
@ -87,6 +100,7 @@ private void ConfigureRootView(MainView rootView)
};
rootView.SessionBanner.DataContext = sessionStatus;
this.View = rootView;
}
private async Task BootOnceAsync()
@ -148,7 +162,7 @@ private void ConfigureRootView(MainView rootView)
public static async Task PushBlogsPageAsync()
{
var app = (App)Current!;
var mainVm = app.ServiceProvider!.GetRequiredService<MainViewModel>();
var mainVm = app.ServiceProvider!.GetRequiredService<BlogsViewModel>();
await mainVm.InitializeAsync();
await app.PushPageAsync(mainVm);
}

View file

@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
using PostIt.Views.Blogs;
using PostIt.Views.Commands;
using Yavsc.Api.Client;
@ -29,12 +30,13 @@ public static class ServiceCollectionHelpers
() => settings.ApiUrl,
() => settings.Authentication?.Authority);
var billingClient = new BillingApiClient(api, () => settings.ApiUrl);
var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl);
var userDirectory = new UserDirectory(userSearchClient);
var reverseGeocoding = new NominatimReverseGeocodingService();
// Vues
services.AddSingleton<MainView>();
services.AddSingleton<MainPage>();
services.AddSingleton<BlogsPage>();
services.AddSingleton<MainWindow>();
// SettingsPage is a singleton: there must be one and only one
@ -58,6 +60,9 @@ public static class ServiceCollectionHelpers
services.AddTransient<BrushPage>();
services.AddTransient<BillingQueriesPage>();
services.AddTransient<BillingQueryDetailsPage>();
services.AddTransient<ProviderOngoingRequestsPage>();
services.AddTransient<EstimateEditionPage>();
// ViewModels
services.AddSingleton(settings);
services.AddSingleton<YavscApiClient>(api);
@ -67,6 +72,7 @@ public static class ServiceCollectionHelpers
services.AddSingleton(userSearchClient);
services.AddSingleton(activityClient);
services.AddSingleton(billingClient);
services.AddSingleton(estimateClient);
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
services.AddSingleton<IUserDirectory>(userDirectory);
services.AddSingleton<HomePageViewModel>();
@ -89,7 +95,7 @@ public static class ServiceCollectionHelpers
sessionStatus.Refresh();
services.AddSingleton(sessionStatus);
services.AddSingleton<SessionStatusBanner>();
services.AddSingleton<MainViewModel>();
services.AddSingleton<BlogsViewModel>();
return services.BuildServiceProvider();
}
}

View file

@ -8,7 +8,7 @@ namespace PostIt.Helpers;
public static class ViewModelBaseHelpers
{
public static async Task PushPageAsync(this App app, ViewModelBase vm)
public static async Task<Page> PushPageAsync(this App app, ViewModelBase vm)
{
var window = app.View;
if (window is null)
@ -43,9 +43,11 @@ public static class ViewModelBaseHelpers
var stack = window.NavRoot.NavigationStack;
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page))
{
return;
return page;
}
await window.NavRoot.PushAsync(page);
return page;
}
}

View file

@ -191,7 +191,28 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
object? body = null,
CancellationToken ct = default)
{
using var response = await SendAsync(method, path, body, ct).ConfigureAwait(false);
using var response = await SendAsync(method, path, body is null ? null : () => JsonContent.Create(body), ct).ConfigureAwait(false);
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
var dto = await JsonSerializer.DeserializeAsync<T>(stream,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }, ct).ConfigureAwait(false);
return dto!;
}
/// <summary>
/// Call a multipart endpoint, transparently refreshing the token if needed.
/// </summary>
public virtual async Task<T> CallAsync<T>(
HttpMethod method,
string path,
Func<HttpContent> contentFactory,
CancellationToken ct = default)
{
if (contentFactory is null)
throw new ArgumentNullException(nameof(contentFactory));
using var response = await SendAsync(method, path, contentFactory, ct).ConfigureAwait(false);
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
@ -217,7 +238,23 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
object? body = null,
CancellationToken ct = default)
{
using var response = await SendAsync(method, path, body, ct).ConfigureAwait(false);
using var response = await SendAsync(method, path, body is null ? null : () => JsonContent.Create(body), ct).ConfigureAwait(false);
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
}
/// <summary>
/// Call a multipart endpoint that returns no useful body (DELETE, etc.).
/// </summary>
public async Task CallAsync(
HttpMethod method,
string path,
Func<HttpContent> contentFactory,
CancellationToken ct = default)
{
if (contentFactory is null)
throw new ArgumentNullException(nameof(contentFactory));
using var response = await SendAsync(method, path, contentFactory, ct).ConfigureAwait(false);
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
}
@ -232,7 +269,7 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
=> CallAsync(method, path, body: null, ct);
private async Task<HttpResponseMessage> SendAsync(
HttpMethod method, string path, object? body, CancellationToken ct)
HttpMethod method, string path, Func<HttpContent>? contentFactory, CancellationToken ct)
{
if (_tokens is null)
throw new InvalidOperationException("Not logged in. Call LoginInteractiveAsync first.");
@ -240,8 +277,8 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
await EnsureFreshTokenAsync(ct).ConfigureAwait(false);
using var req = new HttpRequestMessage(method, path);
if (body is not null)
req.Content = JsonContent.Create(body);
if (contentFactory is not null)
req.Content = contentFactory();
var response = await Http.SendAsync(req, ct).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.Unauthorized)
@ -252,8 +289,8 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
await ForceRefreshAsync(ct).ConfigureAwait(false);
using var retry = new HttpRequestMessage(method, path);
if (body is not null)
retry.Content = JsonContent.Create(body);
if (contentFactory is not null)
retry.Content = contentFactory();
response = await Http.SendAsync(retry, ct).ConfigureAwait(false);
}

View file

@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
using PostIt.ViewModels;
using PostIt.ViewModels.Commands;
using PostIt.Views;
using PostIt.Views.Blogs;
using PostIt.Views.Commands;
namespace PostIt;
@ -38,7 +39,7 @@ public class ViewLocator : IDataTemplate
var services = app!.ServiceProvider!;
return data switch
{
MainViewModel => services.GetRequiredService<MainPage>(),
BlogsViewModel => services.GetRequiredService<BlogsPage>(),
Settings => services.GetRequiredService<SettingsPage>(),
HomePageViewModel => services.GetRequiredService<HomePage>(),
ActivitiesPageViewModel => services.GetRequiredService<ActivitiesPage>(),
@ -51,6 +52,8 @@ public class ViewLocator : IDataTemplate
PostAclDialogViewModel => services.GetRequiredService<PostAclDialog>(),
BillingQueriesPageViewModel => services.GetRequiredService<BillingQueriesPage>(),
BillingQueryDetailsPageViewModel => services.GetRequiredService<BillingQueryDetailsPage>(),
ProviderOngoingRequestsPageViewModel => services.GetRequiredService<ProviderOngoingRequestsPage>(),
EstimateEditionPageViewModel => services.GetRequiredService<EstimateEditionPage>(),
null => new TextBlock { Text = "No view for <null>" },
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
};

View file

@ -43,7 +43,7 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV
? $"Demandes en cours ({Form.Title})"
: $"Commandes {Form.Title}";
public string ContextLabel => $"{Performer.UserName} · {Activity.Name}";
public bool CanOpenDetails => true;
public bool CanOpenDetails => !IsReadOnly;
public override bool CanNavigateNext
{
@ -75,7 +75,7 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV
public Task InitializeAsync() => RefreshAsync();
private bool CanOpenSelectedQuery() => SelectedQuery is not null;
private bool CanOpenSelectedQuery() => CanOpenDetails && SelectedQuery is not null;
[RelayCommand]
public async Task RefreshAsync()

View file

@ -0,0 +1,293 @@
using System;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
/// <summary>
/// Edition d'un devis (<c>Estimate</c>) créé en réponse à une demande
/// client (<see cref="BillingQuerySummaryDto"/>) consultée depuis la
/// page « Mes demandes en cours ». L'envoi poste le devis sur
/// <c>api/v1/estimate</c>; côté serveur, la commande liée
/// (<see cref="BillingQuerySummaryDto.Id"/>) est alors marquée comme
/// validée par le prestataire.
/// </summary>
public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatusViewModel
{
private readonly EstimateApiClient _estimateClient;
private readonly BillingQuerySummaryDto _query;
public long QueryId => _query.Id;
public string ClientId => _query.ClientId;
public string BillingCode => _query.BillingCode;
public string Title => $"Devis — demande #{_query.Id}";
public string ContextLabel
=> $"Demande #{_query.Id} · {BillingCode} · client {ClientId}";
public string QueryDescription => string.IsNullOrWhiteSpace(_query.Description)
? "(sans description)"
: _query.Description;
[ObservableProperty]
public partial string EstimateTitle { get; set; } = string.Empty;
[ObservableProperty]
public partial string EstimateDescription { get; set; } = string.Empty;
[ObservableProperty]
public partial ObservableCollection<EstimateLineItemViewModel> Lines { get; set; } = new();
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(RemoveLineCommand))]
public partial EstimateLineItemViewModel? SelectedLine { get; set; }
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(SendCommand))]
public partial bool IsBusy { get; set; }
/// <summary>
/// True une fois le devis accepté par le serveur: l'envoi est
/// désactivé pour éviter les doublons, il ne reste que « Retour ».
/// </summary>
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(SendCommand))]
public partial bool HasSent { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; } = "Prêt.";
[ObservableProperty]
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt.");
public decimal Total => Lines.Sum(line => line.LineTotal);
public string TotalLabel => $"{Total:0.00} {Lines.FirstOrDefault()?.Currency ?? "EUR"}";
public string SendLabel => HasSent ? "Devis envoyé" : "Envoyer le devis";
public override bool CanNavigateNext
{
get => false;
protected set { _ = value; }
}
public override bool CanNavigatePrevious
{
get => true;
protected set { _ = value; }
}
public EstimateEditionPageViewModel(BillingQuerySummaryDto query, EstimateApiClient estimateClient)
{
_query = query ?? throw new ArgumentNullException(nameof(query));
_estimateClient = estimateClient ?? throw new ArgumentNullException(nameof(estimateClient));
EstimateDescription = query.Description ?? string.Empty;
Lines.CollectionChanged += OnLinesCollectionChanged;
AddLine();
this.SetInfoStatus("Complétez le devis puis envoyez-le. La demande associée sera validée.");
}
[RelayCommand]
private void AddLine()
{
var line = new EstimateLineItemViewModel();
Lines.Add(line);
SelectedLine = line;
}
private bool CanRemoveLine() => SelectedLine is not null && !IsBusy && !HasSent;
[RelayCommand(CanExecute = nameof(CanRemoveLine))]
private void RemoveLine()
{
if (SelectedLine is null)
{
return;
}
var index = Lines.IndexOf(SelectedLine);
Lines.Remove(SelectedLine);
SelectedLine = Lines.Count == 0
? null
: Lines[Math.Min(index, Lines.Count - 1)];
}
private bool CanSend() => !IsBusy && !HasSent;
[RelayCommand(CanExecute = nameof(CanSend))]
private async Task SendAsync()
{
if (!TryValidate(out var validationMessage))
{
this.SetWarningStatus(validationMessage);
return;
}
IsBusy = true;
try
{
var payload = BuildPayload();
var created = await _estimateClient.CreateAsync(payload).ConfigureAwait(true);
HasSent = true;
OnPropertyChanged(nameof(SendLabel));
this.SetInfoStatus(
$"Devis #{created.Id} envoyé ({created.Bill.Count} ligne(s)). La demande #{QueryId} est validée.");
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
this.SetWarningStatus("Accès refusé à l'API devis (scope 'api'). Déconnectez puis reconnectez-vous.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Erreur lors de l'envoi du devis: {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task BackAsync()
{
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
await app.GoBackAsync().ConfigureAwait(true);
}
internal EstimateDto BuildPayload()
{
return new EstimateDto
{
CommandId = QueryId,
ClientId = ClientId,
CommandType = BillingCode,
Title = EstimateTitle.Trim(),
Description = EstimateDescription.Trim(),
Bill = Lines.Select(line => new EstimateLineDto
{
Id = line.Id,
Name = line.Name.Trim(),
Description = line.Description.Trim(),
Count = Math.Max(1, (int)Math.Round(line.Count)),
UnitaryCost = line.UnitaryCost,
Currency = string.IsNullOrWhiteSpace(line.Currency) ? "EUR" : line.Currency.Trim(),
}).ToList(),
};
}
private bool TryValidate(out string message)
{
if (string.IsNullOrWhiteSpace(EstimateTitle))
{
message = "Le titre du devis est requis.";
return false;
}
if (string.IsNullOrWhiteSpace(ClientId))
{
message = "La demande sélectionnée n'identifie pas de client.";
return false;
}
if (string.IsNullOrWhiteSpace(BillingCode))
{
message = "La demande sélectionnée n'a pas de code de facturation.";
return false;
}
if (Lines.Count == 0)
{
message = "Ajoutez au moins une ligne au devis.";
return false;
}
foreach (var line in Lines)
{
if (string.IsNullOrWhiteSpace(line.Name))
{
message = "Chaque ligne doit avoir un nom.";
return false;
}
if (line.Name.Trim().Length > 256)
{
message = $"Le nom de la ligne « {line.Name.Trim()[..20]}… » dépasse 256 caractères.";
return false;
}
if (string.IsNullOrWhiteSpace(line.Description))
{
message = $"La ligne « {line.Name.Trim()} » doit avoir une description.";
return false;
}
if (line.Description.Trim().Length > 512)
{
message = $"La description de la ligne « {line.Name.Trim()} » dépasse 512 caractères.";
return false;
}
if (line.Count < 1)
{
message = $"La quantité de la ligne « {line.Name.Trim()} » doit être d'au moins 1.";
return false;
}
}
message = string.Empty;
return true;
}
private void OnLinesCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.OldItems is not null)
{
foreach (var item in e.OldItems.OfType<EstimateLineItemViewModel>())
{
item.PropertyChanged -= OnLinePropertyChanged;
}
}
if (e.NewItems is not null)
{
foreach (var item in e.NewItems.OfType<EstimateLineItemViewModel>())
{
item.PropertyChanged += OnLinePropertyChanged;
}
}
RaiseTotalsChanged();
}
private void OnLinePropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(EstimateLineItemViewModel.LineTotal)
or nameof(EstimateLineItemViewModel.Currency))
{
RaiseTotalsChanged();
}
}
private void RaiseTotalsChanged()
{
OnPropertyChanged(nameof(Total));
OnPropertyChanged(nameof(TotalLabel));
}
}

View file

@ -0,0 +1,35 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace PostIt.ViewModels;
/// <summary>
/// Editable estimate line. <see cref="Count"/> is exposed as a
/// <see cref="decimal"/> so it binds directly to
/// <c>NumericUpDown.Value</c> (<c>decimal?</c>); it is rounded back
/// to an integer when the DTO is built.
/// </summary>
public partial class EstimateLineItemViewModel : ObservableObject
{
public long Id { get; set; }
[ObservableProperty]
public partial string Name { get; set; } = string.Empty;
[ObservableProperty]
public partial string Description { get; set; } = string.Empty;
[ObservableProperty, NotifyPropertyChangedFor(nameof(LineTotal))]
[NotifyPropertyChangedFor(nameof(LineTotalLabel))]
public partial decimal Count { get; set; } = 1m;
[ObservableProperty, NotifyPropertyChangedFor(nameof(LineTotal))]
[NotifyPropertyChangedFor(nameof(LineTotalLabel))]
public partial decimal UnitaryCost { get; set; }
[ObservableProperty]
public partial string Currency { get; set; } = "EUR";
public decimal LineTotal => Count * UnitaryCost;
public string LineTotalLabel => $"{LineTotal:0.00}";
}

View file

@ -0,0 +1,390 @@
using System;
using System.Collections.ObjectModel;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers;
using Yavsc;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActionStatusViewModel
{
public const string SortByDate = "Date (plus récent d'abord)";
public const string SortByDateAsc = "Date (plus ancien d'abord)";
public const string SortByStatus = "Statut (en cours d'abord)";
private readonly BillingApiClient _billingClient;
private readonly EstimateApiClient? _estimateClient;
private readonly Settings? _settings;
private List<BillingQuerySummaryDto> _allQueries = new();
[ObservableProperty]
public partial ObservableCollection<BillingQuerySummaryDto> Queries { get; set; } = new();
[ObservableProperty]
public partial string FilterText { get; set; } = string.Empty;
public IReadOnlyList<string> SortOptions { get; } = new[]
{
SortByDate,
SortByDateAsc,
SortByStatus,
};
[ObservableProperty]
public partial string SelectedSortOption { get; set; } = SortByDate;
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(OpenSelectedQueryCommand))]
[NotifyCanExecuteChangedFor(nameof(OpenSelectedEditorCommand))]
[NotifyCanExecuteChangedFor(nameof(CreateEstimateForSelectedCommand))]
public partial BillingQuerySummaryDto? SelectedQuery { get; set; }
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; } = "Chargement des demandes fournisseur...";
[ObservableProperty]
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Chargement des demandes fournisseur...");
public string Title => "Mes demandes en cours";
public override bool CanNavigateNext
{
get => false;
protected set { _ = value; }
}
public override bool CanNavigatePrevious
{
get => true;
protected set { _ = value; }
}
public ProviderOngoingRequestsPageViewModel(
BillingApiClient billingClient,
Settings? settings = null,
EstimateApiClient? estimateClient = null)
{
_billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient));
_estimateClient = estimateClient;
_settings = settings;
if (_settings is not null)
{
var preferredSort = NormalizeSortOption(_settings.ProviderOngoingRequestsSortOption);
if (!string.Equals(preferredSort, SelectedSortOption, StringComparison.Ordinal))
{
SelectedSortOption = preferredSort;
}
}
}
public Task InitializeAsync() => RefreshAsync();
[RelayCommand]
public async Task RefreshAsync()
{
IsBusy = true;
try
{
var items = await _billingClient.GetProviderOngoingQueriesAsync().ConfigureAwait(true) ?? new();
_allQueries = items
.Where(x => !string.IsNullOrWhiteSpace(x.BillingCode))
.OrderByDescending(x => x.EventDate ?? DateTime.MinValue)
.ThenByDescending(x => x.Id)
.ToList();
ApplyFilter();
this.SetInfoStatus(_allQueries.Count == 0
? "Aucune demande en cours pour votre profil fournisseur."
: $"{_allQueries.Count} demande(s) en cours chargée(s).");
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
_allQueries = new List<BillingQuerySummaryDto>();
Queries = new ObservableCollection<BillingQuerySummaryDto>();
this.SetWarningStatus("Accès refusé au billing (scope 'api'). Déconnectez puis reconnectez-vous.");
}
catch (Exception ex)
{
_allQueries = new List<BillingQuerySummaryDto>();
Queries = new ObservableCollection<BillingQuerySummaryDto>();
this.SetErrorStatus($"Erreur: {ex.Message}");
}
finally
{
IsBusy = false;
}
}
private bool CanOpenSelectedQuery() => SelectedQuery is not null;
private bool CanOpenSelectedEditor() => SelectedQuery is not null;
[RelayCommand(CanExecute = nameof(CanOpenSelectedQuery))]
public async Task OpenSelectedQueryAsync()
{
if (SelectedQuery is null)
{
this.SetWarningStatus("Sélectionnez une demande.");
return;
}
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
IsBusy = true;
try
{
var details = await _billingClient
.GetQueryAsync(SelectedQuery.BillingCode, SelectedQuery.Id)
.ConfigureAwait(true);
var (activity, performer, form) = BuildNavigationContext(SelectedQuery);
var vm = new BillingQueryDetailsPageViewModel(
activity,
performer,
form,
_billingClient,
details,
isReadOnly: false);
await app.PushPageAsync(vm).ConfigureAwait(true);
}
catch (Exception ex)
{
this.SetErrorStatus($"Erreur lors de l'ouverture: {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand(CanExecute = nameof(CanOpenSelectedEditor))]
public async Task OpenSelectedEditorAsync()
{
if (SelectedQuery is null)
{
this.SetWarningStatus("Sélectionnez une demande.");
return;
}
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
IsBusy = true;
try
{
var details = await _billingClient
.GetQueryAsync(SelectedQuery.BillingCode, SelectedQuery.Id)
.ConfigureAwait(true);
var (activity, performer, form) = BuildNavigationContext(SelectedQuery);
var vm = form.CreateCommandPageViewModel(activity, performer, _billingClient);
if (vm is null)
{
this.SetWarningStatus($"Le formulaire '{form.ActionName}' n'est pas pris en charge en édition.");
return;
}
await vm.InitializeAsync(details).ConfigureAwait(true);
await app.PushPageAsync(vm).ConfigureAwait(true);
}
catch (Exception ex)
{
this.SetErrorStatus($"Erreur lors de l'ouverture en édition: {ex.Message}");
}
finally
{
IsBusy = false;
}
}
private bool CanCreateEstimateForSelected() => SelectedQuery is not null && _estimateClient is not null;
[RelayCommand(CanExecute = nameof(CanCreateEstimateForSelected))]
public async Task CreateEstimateForSelectedAsync()
{
if (SelectedQuery is null)
{
this.SetWarningStatus("Sélectionnez une demande.");
return;
}
if (_estimateClient is null)
{
this.SetWarningStatus("Le client devis n'est pas disponible.");
return;
}
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var vm = new EstimateEditionPageViewModel(SelectedQuery, _estimateClient);
await app.PushPageAsync(vm).ConfigureAwait(true);
}
partial void OnFilterTextChanged(string value)
{
ApplyFilter();
}
partial void OnSelectedSortOptionChanged(string value)
{
var normalized = NormalizeSortOption(value);
if (!string.Equals(normalized, value, StringComparison.Ordinal))
{
SelectedSortOption = normalized;
return;
}
PersistSortPreference(value);
ApplyFilter();
}
private void ApplyFilter()
{
var query = FilterText?.Trim();
var filtered = string.IsNullOrWhiteSpace(query)
? _allQueries
: _allQueries.Where(x =>
ContainsInsensitive(x.Description, query)
|| ContainsInsensitive(x.ActivityCode, query)
|| ContainsInsensitive(x.BillingCode, query)
|| ContainsInsensitive(x.ClientId, query)
|| ContainsInsensitive(x.Status.ToString(), query))
.ToList();
var sorted = ApplySort(filtered);
Queries = new ObservableCollection<BillingQuerySummaryDto>(sorted);
}
private List<BillingQuerySummaryDto> ApplySort(IEnumerable<BillingQuerySummaryDto> source)
{
if (string.Equals(SelectedSortOption, SortByStatus, StringComparison.Ordinal))
{
return source
.OrderBy(x => GetStatusRank(x.Status))
.ThenByDescending(x => x.EventDate ?? DateTime.MinValue)
.ThenByDescending(x => x.Id)
.ToList();
}
if (string.Equals(SelectedSortOption, SortByDateAsc, StringComparison.Ordinal))
{
return source
.OrderBy(x => x.EventDate ?? DateTime.MinValue)
.ThenBy(x => x.Id)
.ToList();
}
return source
.OrderByDescending(x => x.EventDate ?? DateTime.MinValue)
.ThenByDescending(x => x.Id)
.ToList();
}
private void PersistSortPreference(string selectedSort)
{
if (_settings is null)
{
return;
}
if (string.Equals(_settings.ProviderOngoingRequestsSortOption, selectedSort, StringComparison.Ordinal))
{
return;
}
_settings.ProviderOngoingRequestsSortOption = selectedSort;
try
{
_settings.Save();
}
catch
{
this.SetWarningStatus("Le tri a été appliqué, mais sa sauvegarde a échoué.");
}
}
private static string NormalizeSortOption(string? sortOption)
{
if (string.Equals(sortOption, SortByDate, StringComparison.Ordinal)
|| string.Equals(sortOption, SortByDateAsc, StringComparison.Ordinal)
|| string.Equals(sortOption, SortByStatus, StringComparison.Ordinal))
{
return sortOption!;
}
return SortByDate;
}
private static int GetStatusRank(QueryStatus status)
=> status switch
{
QueryStatus.InProgress => 0,
QueryStatus.Accepted => 1,
QueryStatus.Inserted => 2,
QueryStatus.Success => 3,
QueryStatus.Rejected => 4,
QueryStatus.Failed => 5,
_ => 99,
};
private static bool ContainsInsensitive(string? source, string query)
=> !string.IsNullOrWhiteSpace(source)
&& source.Contains(query, StringComparison.OrdinalIgnoreCase);
private static (ActivityInfo activity, ActivityUserDisplayItem performer, CommandFormSummary form)
BuildNavigationContext(BillingQuerySummaryDto query)
{
var activity = new ActivityInfo
{
Code = query.ActivityCode,
Name = string.IsNullOrWhiteSpace(query.ActivityCode)
? "Activité"
: query.ActivityCode,
};
var performer = new ActivityUserDisplayItem
{
PerformerId = query.PerformerId,
UserName = "Mon profil fournisseur",
AvatarFallbackLabel = "M",
IsPerformerActive = true,
PerformerStatusBadgeLabel = "Actif",
PerformerStatusBadgeBackground = "#E6F7EC",
PerformerStatusBadgeBorder = "#2E7D32",
PerformerStatusBadgeForeground = "#1B5E20",
};
var form = new CommandFormSummary
{
ActionName = query.BillingCode,
Title = query.BillingCode,
};
return (activity, performer, form);
}
}

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.ObjectModel;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
@ -8,11 +9,12 @@ using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Blogspot;
using Yavsc.Api.Client;
using Yavsc.Abstract.Files;
using PostIt.Helpers;
namespace PostIt.ViewModels;
public partial class MainViewModel : ViewModelBase, IActionStatusViewModel
public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
{
/// <summary>Window/tab title. Cosmetic — bound by
/// <c>MainPage.axaml</c> if at all. Not the post title.</summary>
@ -66,6 +68,9 @@ public partial class MainViewModel : ViewModelBase, IActionStatusViewModel
[ObservableProperty]
public partial ObservableCollection<BlogPostDto> FilteredPosts { get; set; }
[ObservableProperty]
public partial ObservableCollection<BlogUploadFile> DraftAttachments { get; set; }
[ObservableProperty]
public partial BlogPostDto? SelectedPost { get; set; }
@ -91,12 +96,6 @@ public partial class MainViewModel : ViewModelBase, IActionStatusViewModel
});
}
[RelayCommand]
internal async Task SearchAsync() {
await RefreshAsync();
ApplyFilter();
}
[RelayCommand]
internal async Task SaveAsync()
{
@ -113,6 +112,8 @@ public partial class MainViewModel : ViewModelBase, IActionStatusViewModel
await ExecuteAsync(async () =>
{
var attachments = DraftAttachments.ToArray();
// Build a fresh BlogPostDto from the editor buffer on
// every Save — we no longer mutate SelectedPost in
// place. The previous behaviour copied the buffer
@ -133,11 +134,28 @@ public partial class MainViewModel : ViewModelBase, IActionStatusViewModel
DateModified = DateTime.UtcNow,
IsPublished = DraftIsPublished
};
var created = await BlogClient!.CreatePostAsync(draft);
var created = await BlogClient!.CreatePostAsync(draft, attachments);
if (created is not null)
{
SelectedPost = created;
if (TryAppendAttachmentLinks(created, attachments))
{
var linkUpdate = new BlogPostDto
{
Id = created.Id,
AuthorId = created.AuthorId,
Photo = created.Photo,
Title = DraftTitle,
Article = DraftArticle ?? string.Empty,
DateCreated = created.DateCreated,
DateModified = DateTime.UtcNow,
};
await BlogClient.UpdatePostAsync(created.Id, linkUpdate);
}
this.SetInfoStatus($"Billet {created.Id} créé.");
DraftAttachments.Clear();
}
}
else
@ -152,8 +170,26 @@ public partial class MainViewModel : ViewModelBase, IActionStatusViewModel
DateCreated = SelectedPost.DateCreated,
DateModified = DateTime.UtcNow,
};
await BlogClient!.UpdatePostAsync(SelectedPost.Id, update);
await BlogClient!.UpdatePostAsync(SelectedPost.Id, update, attachments);
if (TryAppendAttachmentLinks(SelectedPost, attachments))
{
var linkUpdate = new BlogPostDto
{
Id = SelectedPost.Id,
AuthorId = SelectedPost.AuthorId,
Photo = SelectedPost.Photo,
Title = DraftTitle,
Article = DraftArticle ?? string.Empty,
DateCreated = SelectedPost.DateCreated,
DateModified = DateTime.UtcNow,
};
await BlogClient.UpdatePostAsync(SelectedPost.Id, linkUpdate);
}
this.SetInfoStatus($"Billet {SelectedPost.Id} enregistré.");
DraftAttachments.Clear();
}
await RefreshPostsAsync();
@ -336,7 +372,7 @@ public partial class MainViewModel : ViewModelBase, IActionStatusViewModel
}
public MainViewModel()
public BlogsViewModel()
{
SettingsModel = new Settings();
Init(SettingsModel);
@ -347,6 +383,7 @@ public partial class MainViewModel : ViewModelBase, IActionStatusViewModel
{
Posts = new ObservableCollection<BlogPostDto>();
FilteredPosts = new ObservableCollection<BlogPostDto>();
DraftAttachments = new ObservableCollection<BlogUploadFile>();
SelectedPost = null;
IsBusy = false;
this.SetInfoStatus("Prêt.");
@ -396,7 +433,7 @@ public partial class MainViewModel : ViewModelBase, IActionStatusViewModel
/// <see cref="BlogApiClient"/>. Production code uses the
/// (Settings, BlogApiClient) overload below.
/// </summary>
public MainViewModel(BlogApiClient blogClient, Settings? settings = null, IServiceProvider? services = null)
public BlogsViewModel(BlogApiClient blogClient, Settings? settings = null, IServiceProvider? services = null)
{
SettingsModel = new Settings();
BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient)); ;
@ -427,6 +464,7 @@ public partial class MainViewModel : ViewModelBase, IActionStatusViewModel
// Mirror publication state too. Defaults to false on
// null selection so a fresh draft starts unpublished.
DraftIsPublished = value?.IsPublished ?? false;
DraftAttachments.Clear();
UpdateCommandStates();
}
@ -508,4 +546,55 @@ public partial class MainViewModel : ViewModelBase, IActionStatusViewModel
IsLoaded = true;
}
}
private bool TryAppendAttachmentLinks(BlogPostDto post, IReadOnlyCollection<BlogUploadFile> attachments)
{
if (attachments.Count == 0)
return false;
var ownerSegment = post.Author?.UserName;
if (string.IsNullOrWhiteSpace(ownerSegment))
ownerSegment = post.AuthorId;
if (string.IsNullOrWhiteSpace(ownerSegment))
return false;
var article = DraftArticle ?? string.Empty;
var links = new List<string>();
foreach (var attachment in attachments)
{
var relativePath = $"{EscapePathSegment(ownerSegment)}/blogs/{post.Id}/{EscapePathSegment(attachment.FileName)}";
var fileUrl = ResolveUserFileUrl(relativePath);
var markdownLine = $"- [{attachment.FileName}]({fileUrl})";
if (!article.Contains(markdownLine, StringComparison.Ordinal))
links.Add(markdownLine);
}
if (links.Count == 0)
return false;
var prefix = article.Length == 0
? ""
: (article.EndsWith("\n", StringComparison.Ordinal) ? "\n" : "\n\n");
DraftArticle = article + prefix + string.Join("\n", links);
return true;
}
private string ResolveUserFileUrl(string relativePath)
{
var authority = Settings?.Authentication?.Authority;
if (!string.IsNullOrWhiteSpace(authority)
&& Uri.TryCreate(authority, UriKind.Absolute, out var baseUri))
{
return FileServerUrlHelpers.GetUserFilesUri(baseUri, relativePath).ToString();
}
return $"{Yavsc.Constants.UserFilesPath}/{relativePath}";
}
private static string EscapePathSegment(string segment)
=> Uri.EscapeDataString(segment);
}

View file

@ -12,6 +12,9 @@ namespace PostIt.ViewModels.Commands;
public partial class RdvViewModel : BillingCommandPageViewModel
{
private long? _existingLocationId;
private bool _hydratingExistingQuery;
public override string SupportMessage => "Complétez les informations du rendez-vous puis postez la commande.";
[ObservableProperty]
@ -56,6 +59,7 @@ public partial class RdvViewModel : BillingCommandPageViewModel
protected override void ApplyExistingQuery(BillingQueryDetailsDto existingQuery)
{
_hydratingExistingQuery = true;
ExistingQueryId = existingQuery.Id;
CommandStatus = existingQuery.Status;
Consent = existingQuery.Consent;
@ -70,11 +74,18 @@ public partial class RdvViewModel : BillingCommandPageViewModel
if (existingQuery.Location is not null)
{
_existingLocationId = existingQuery.Location.Id;
Address = existingQuery.Location.Address ?? string.Empty;
SuggestedAddress = string.Empty;
Latitude = existingQuery.Location.Latitude;
Longitude = existingQuery.Location.Longitude;
}
else
{
_existingLocationId = null;
}
_hydratingExistingQuery = false;
this.SetInfoStatus($"Commande #{existingQuery.Id} chargée.");
}
@ -117,20 +128,22 @@ public partial class RdvViewModel : BillingCommandPageViewModel
}
}
protected static object BuildLocationPayload(string address, double? latitude, double? longitude)
protected static BillingLocationDto BuildLocationPayload(string address, double? latitude, double? longitude, long? locationId = null)
{
if (latitude.HasValue && longitude.HasValue)
{
return new
return new BillingLocationDto
{
Id = locationId,
Address = address,
Latitude = latitude.Value,
Longitude = longitude.Value,
};
}
return new
return new BillingLocationDto
{
Id = locationId,
Address = address,
};
}
@ -223,6 +236,30 @@ public partial class RdvViewModel : BillingCommandPageViewModel
OnPropertyChanged(nameof(EventDateSelection));
}
partial void OnAddressChanged(string value)
{
if (_hydratingExistingQuery)
return;
_existingLocationId = null;
}
partial void OnLatitudeChanged(double? value)
{
if (_hydratingExistingQuery)
return;
_existingLocationId = null;
}
partial void OnLongitudeChanged(double? value)
{
if (_hydratingExistingQuery)
return;
_existingLocationId = null;
}
protected override async Task SubmitAsync()
{
@ -257,7 +294,7 @@ public partial class RdvViewModel : BillingCommandPageViewModel
try
{
var address = Address.Trim();
var locationPayload = BuildLocationPayload(address, Latitude, Longitude);
var locationPayload = BuildLocationPayload(address, Latitude, Longitude, IsEditingExisting ? _existingLocationId : null);
var payload = new BillingQueryDetailsDto
{
@ -270,12 +307,7 @@ public partial class RdvViewModel : BillingCommandPageViewModel
Status = CommandStatus,
Reason = Reason.Trim(),
AdditionalInfo = string.IsNullOrWhiteSpace(AdditionalInfo) ? string.Empty : AdditionalInfo.Trim(),
Location = new BillingLocationDto
{
Address = address,
Latitude = Latitude,
Longitude = Longitude,
}
Location = locationPayload
};
if (IsEditingExisting)

View file

@ -5,6 +5,7 @@ using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Helpers;
using PostIt.Services;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
public class HomePageViewModel : ViewModelBase
@ -30,10 +31,12 @@ public class HomePageViewModel : ViewModelBase
SessionStatus = sessionStatus;
OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync);
OpenProviderRequests = new AsyncRelayCommand(OpenProviderRequestsAsync);
OpenBlogs = new AsyncRelayCommand(App.PushBlogsPageAsync);
}
public IAsyncRelayCommand OpenBlogs { get; } = new AsyncRelayCommand(App.PushBlogsPageAsync);
public IAsyncRelayCommand OpenBlogs { get; }
public IAsyncRelayCommand OpenActivities { get; }
public IAsyncRelayCommand OpenProviderRequests { get; }
private async Task OpenActivitiesAsync()
{
@ -48,6 +51,27 @@ public class HomePageViewModel : ViewModelBase
await app.PushPageAsync(vm);
}
private async Task OpenProviderRequestsAsync()
{
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var billingClient = app.ServiceProvider?.GetRequiredService<BillingApiClient>();
if (billingClient is null)
{
throw new InvalidOperationException("Client billing indisponible.");
}
var estimateClient = app.ServiceProvider?.GetRequiredService<EstimateApiClient>();
var vm = new ProviderOngoingRequestsPageViewModel(billingClient, Settings, estimateClient);
await vm.InitializeAsync();
await app.PushPageAsync(vm);
}
/// <summary>
/// Avalonia designer constructor. Builds a self-contained VM
/// with a freshly-constructed Settings so the XAML preview can

View file

@ -23,8 +23,8 @@ public sealed class StatusNotice
(Glyph, Background, BorderBrush, Foreground) = severity switch
{
StatusSeverity.Error => ("!", "#FDECEA", "#C62828", "#7F1D1D"),
StatusSeverity.Warning => ("~", "#FFF8E1", "#E6A700", "#7C4A03"),
StatusSeverity.Error => ("!", "#7F1D1D", "#C62828", "#e1f0f6"),
StatusSeverity.Warning => ("~", "#7C4A03", "#E6A700", "#eaeaea"),
_ => ("i", "#E8F0FE", "#5B8DEF", "#1E3A8A"),
};
}

View file

@ -15,7 +15,7 @@ namespace PostIt.ViewModels;
public partial class Settings : ViewModelBase
{
const string SettingsFileName = "postit-settings.json";
public string SettingsFileName {get; private set;} = "postit-settings.json";
[ObservableProperty]
public partial AuthenticationSettings Authentication { get; set; } = new();
@ -32,6 +32,9 @@ public partial class Settings : ViewModelBase
[ObservableProperty]
public partial string SearchText { get; set; } = string.Empty;
[ObservableProperty]
public partial string ProviderOngoingRequestsSortOption { get; set; } = string.Empty;
[ObservableProperty]
[JsonIgnore]
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Pret.");
@ -62,6 +65,7 @@ public partial class Settings : ViewModelBase
partial void OnBlogsApiUrlChanged(string value) => MarkDirty();
partial void OnApiUrlChanged(string value) => MarkDirty();
partial void OnSearchTextChanged(string value) => MarkDirty();
partial void OnProviderOngoingRequestsSortOptionChanged(string value) => MarkDirty();
/// <summary>
/// Authentication can be reassigned wholesale by
@ -247,12 +251,18 @@ public partial class Settings : ViewModelBase
return;
}
}
if (Environment.GetEnvironmentVariable("POSTIT_SETTINGS_JSON") is string envJson
&& !string.IsNullOrWhiteSpace(envJson))
{
Console.WriteLine("🔎 Loading settings from POSTIT_SETTINGS_JSON environment variable.");
ApplyJson(envJson, "POSTIT_SETTINGS_JSON");
Loaded = true;
return;
}
string configDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"PostIt"
);
Directory.CreateDirectory(configDir);
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"PostIt"
);
string configPath = Path.Combine(configDir, SettingsFileName);
@ -336,7 +346,7 @@ public partial class Settings : ViewModelBase
// → our overridden dispatcher-safe marshaller below.
else lock (_mutationGate)
{
var legacyApiUrl = TryReadLegacyApiUrl(json);
var legacyApiUrl = TryReadApiUrl(json);
this.Authentication = settings.Authentication;
this.DarkMode = settings.DarkMode;
this.BlogsApiUrl = !string.IsNullOrWhiteSpace(settings.BlogsApiUrl)
@ -346,6 +356,7 @@ public partial class Settings : ViewModelBase
? settings.ApiUrl
: this.ApiUrl;
this.SearchText = settings.SearchText ?? string.Empty;
this.ProviderOngoingRequestsSortOption = settings.ProviderOngoingRequestsSortOption ?? string.Empty;
if (!(settings.Authentication is null))
{
this.Authentication = new AuthenticationSettings();
@ -391,7 +402,7 @@ public partial class Settings : ViewModelBase
}
}
private static string? TryReadLegacyApiUrl(string json)
private static string? TryReadApiUrl(string json)
{
try
{
@ -424,6 +435,7 @@ public partial class Settings : ViewModelBase
this.BlogsApiUrl = "https://blogs.pschneider.fr/api/v1/";
this.ApiUrl = "https://api.pschneider.fr/api/v1/";
this.SearchText = string.Empty;
this.ProviderOngoingRequestsSortOption = string.Empty;
}
/// <summary>

View file

@ -34,13 +34,13 @@
<Grid Grid.Row="3" Margin="0,12,0,0" ColumnDefinitions="Auto,8,Auto,8,Auto,12,*">
<Button Grid.Column="0"
Content="Ouvrir le formulaire"
Content="Ouvrir le formulaire de demande"
Command="{Binding OpenSelectedFormCommand}" />
<Button Grid.Column="2"
Content="Voir les commandes"
Content="Voir les demandes non validées"
Command="{Binding OpenQueriesCommand}" />
<Button Grid.Column="4"
Content="Demandes en cours (lecture seule)"
Content="Demandes en cours (validées, en lecture seule)"
Command="{Binding OpenOngoingQueriesCommand}" />
<postitControls:StatusBar Grid.Column="6"
DataContext="{Binding ActionStatus}" />

View file

@ -0,0 +1,151 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:postitControls="using:PostIt.Controls"
x:Class="PostIt.Views.EstimateEditionPage"
x:DataType="vm:EstimateEditionPageViewModel"
Header="Edition de devis">
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="12">
<StackPanel Grid.Row="0" Spacing="3">
<TextBlock Text="{Binding Title}"
FontSize="20"
FontWeight="Bold" />
<TextBlock Text="{Binding ContextLabel}" Opacity="0.75" />
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="Auto,8,*" Margin="0,10,0,8">
<Button Grid.Column="0"
Content="Retour"
Command="{Binding BackCommand}" />
</Grid>
<ScrollViewer Grid.Row="2">
<StackPanel Spacing="10">
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="8"
Padding="10">
<Grid ColumnDefinitions="Auto,12,*" RowDefinitions="Auto,Auto,Auto" RowSpacing="8">
<TextBlock Grid.Row="0"
Grid.Column="0"
Text="Demande"
FontWeight="SemiBold"
VerticalAlignment="Center" />
<TextBlock Grid.Row="0"
Grid.Column="2"
Text="{Binding QueryDescription}"
TextWrapping="Wrap"
Opacity="0.75" />
<TextBlock Grid.Row="1"
Grid.Column="0"
Text="Titre"
FontWeight="SemiBold"
VerticalAlignment="Center" />
<TextBox Grid.Row="1"
Grid.Column="2"
Text="{Binding EstimateTitle}"
PlaceholderText="Titre du devis" />
<TextBlock Grid.Row="2"
Grid.Column="0"
Text="Description"
FontWeight="SemiBold"
VerticalAlignment="Top" />
<TextBox Grid.Row="2"
Grid.Column="2"
Text="{Binding EstimateDescription}"
PlaceholderText="Description détaillée du devis"
AcceptsReturn="True"
TextWrapping="Wrap"
MinHeight="70" />
</Grid>
</Border>
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="8"
Padding="10">
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto,8,Auto">
<TextBlock Grid.Column="0"
Text="Lignes du devis"
FontWeight="SemiBold"
VerticalAlignment="Center" />
<Button Grid.Column="1"
Content="Ajouter une ligne"
Command="{Binding AddLineCommand}" />
<Button Grid.Column="3"
Content="Retirer la ligne"
Command="{Binding RemoveLineCommand}" />
</Grid>
<ListBox ItemsSource="{Binding Lines}"
SelectedItem="{Binding SelectedLine, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:EstimateLineItemViewModel">
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="6"
Padding="8"
Margin="0,0,0,8">
<Grid ColumnDefinitions="*,8,110,8,130,8,90"
RowDefinitions="Auto,Auto"
RowSpacing="6">
<TextBox Grid.Row="0"
Grid.Column="0"
Text="{Binding Name}"
PlaceholderText="Nom de la ligne" />
<NumericUpDown Grid.Row="0"
Grid.Column="2"
Value="{Binding Count}"
Minimum="1"
Increment="1"
FormatString="0" />
<NumericUpDown Grid.Row="0"
Grid.Column="4"
Value="{Binding UnitaryCost}"
Increment="0.5"
FormatString="0.00" />
<TextBlock Grid.Row="0"
Grid.Column="6"
Text="{Binding LineTotalLabel}"
VerticalAlignment="Center"
HorizontalAlignment="Right"
FontWeight="SemiBold" />
<TextBox Grid.Row="1"
Grid.Column="0"
Grid.ColumnSpan="7"
Text="{Binding Description}"
PlaceholderText="Description de la ligne" />
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="1"
Text="{Binding TotalLabel, StringFormat='Total : {0}'}"
FontSize="16"
FontWeight="Bold"
HorizontalAlignment="Right" />
</Grid>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
<Grid Grid.Row="3" ColumnDefinitions="Auto,8,*,Auto" Margin="0,12,0,0">
<Button Grid.Column="0"
Content="{Binding SendLabel}"
Command="{Binding SendCommand}" />
<postitControls:StatusBar Grid.Column="2"
DataContext="{Binding ActionStatus}" />
<ProgressBar Grid.Column="3"
Width="120"
IsIndeterminate="True"
IsVisible="{Binding IsBusy}" />
</Grid>
</Grid>
</ContentPage>

View file

@ -0,0 +1,17 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace PostIt.Views;
public partial class EstimateEditionPage : ContentPage
{
public EstimateEditionPage()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}

View file

@ -0,0 +1,102 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:dto="using:Yavsc.Api.Client"
xmlns:postitControls="using:PostIt.Controls"
x:Class="PostIt.Views.ProviderOngoingRequestsPage"
x:DataType="vm:ProviderOngoingRequestsPageViewModel"
Header="Mes demandes en cours">
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto" Margin="12">
<StackPanel Grid.Row="0" Spacing="3">
<TextBlock Text="{Binding Title}" FontSize="20" FontWeight="Bold" />
<TextBlock Text="Point de vue fournisseur" Opacity="0.75" />
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="Auto,8,Auto,8,*,8,Auto" Margin="0,10,0,8">
<Button Grid.Column="0" Content="Rafraîchir" Command="{Binding RefreshCommand}" />
<ComboBox Grid.Column="2"
Width="220"
ItemsSource="{Binding SortOptions}"
SelectedItem="{Binding SelectedSortOption, Mode=TwoWay}" />
<TextBox Grid.Column="4"
PlaceholderText="Filtrer: activité, code, client, statut..."
Text="{Binding FilterText, Mode=TwoWay}" />
<TextBlock Grid.Column="6"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Text="{Binding Queries.Count, StringFormat='Résultats : {0}'}"
Opacity="0.7" />
</Grid>
<TextBlock Grid.Row="2"
Text="Astuce: sélectionnez une ligne puis ouvrez le détail ou l'édition directe."
Opacity="0.65"
FontSize="11"
Margin="0,0,0,8" />
<Grid Grid.Row="3" RowDefinitions="*,Auto">
<ListBox Grid.Row="0"
ItemsSource="{Binding Queries}"
SelectedItem="{Binding SelectedQuery, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="dto:BillingQuerySummaryDto">
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="8"
Padding="10"
Margin="0,0,0,8">
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto">
<TextBlock Grid.Row="0"
Grid.Column="0"
Text="{Binding Description}"
FontWeight="Bold"
TextWrapping="Wrap" />
<TextBlock Grid.Row="0"
Grid.Column="1"
Text="{Binding Status}"
FontSize="11"
Opacity="0.75"
HorizontalAlignment="Right" />
<TextBlock Grid.Row="1"
Grid.Column="0"
Margin="0,6,0,0"
Text="{Binding ActivityCode, StringFormat='Activité : {0}'}"
FontSize="11"
Opacity="0.75" />
<TextBlock Grid.Row="1"
Grid.Column="1"
Margin="0,6,0,0"
Text="{Binding BillingCode, StringFormat='Code : {0}'}"
FontSize="11"
Opacity="0.65"
HorizontalAlignment="Right" />
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<StackPanel Grid.Row="1"
Orientation="Horizontal"
HorizontalAlignment="Right"
Margin="0,8,0,0"
Spacing="8">
<Button Content="Ouvrir le détail"
Command="{Binding OpenSelectedQueryCommand}" />
<Button Content="Ouvrir en édition"
Command="{Binding OpenSelectedEditorCommand}"
/>
<Button Content="Créer un devis"
Command="{Binding CreateEstimateForSelectedCommand}"
/>
</StackPanel>
</Grid>
<Grid Grid.Row="4" ColumnDefinitions="*,Auto" Margin="0,12,0,0">
<postitControls:StatusBar Grid.Column="0"
DataContext="{Binding ActionStatus}" />
<ProgressBar Grid.Column="1" Width="120" IsIndeterminate="True" IsVisible="{Binding IsBusy}" />
</Grid>
</Grid>
</ContentPage>

View file

@ -0,0 +1,17 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace PostIt.Views;
public partial class ProviderOngoingRequestsPage : ContentPage
{
public ProviderOngoingRequestsPage()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}

View file

@ -0,0 +1,151 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:PostIt.ViewModels"
xmlns:postitControls="using:PostIt.Controls"
xmlns:models="using:Yavsc.Blogspot"
xmlns:views="using:PostIt.Views.Blogs"
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
mc:Ignorable="d"
x:Class="PostIt.Views.Blogs.BlogsPage"
x:DataType="vm:BlogsViewModel"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<Design.DataContext>
<vm:BlogsViewModel />
</Design.DataContext>
<ScrollViewer
VerticalScrollBarVisibility="Visible"
HorizontalScrollBarVisibility="Disabled"
Padding="5"
Margin="5"
>
<StackPanel HorizontalAlignment="Stretch">
<StackPanel Orientation="Horizontal" Spacing="10" Margin="5">
<TextBlock Text="Blog Posts" FontSize="20" FontWeight="SemiBold" />
<TextBox HorizontalAlignment="Right"
Text="{Binding SearchText, Mode=TwoWay}" PlaceholderText="Filter by title or author..." />
</StackPanel>
<CommandBar IsDynamicOverflowEnabled="True"
HorizontalAlignment="Right" >
<CommandBar.PrimaryCommands>
<CommandBarButton Label="Refresh" Command="{Binding RefreshAsync}">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource RefreshIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton x:Name="SaveButton" Label="Save" Command="{Binding SaveAsync}">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource SaveIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton Label="Delete" Command="{Binding DeleteAsync}">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource DeleteIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton Label="Ajouter fichiers" Click="AddAttachment_Click"
x:Name="AddAttachmentButton">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource AddIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton x:Name="ManageAclButton"
Label="ACL"
Command="{Binding ManageAclAsync}">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource AclIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton x:Name="OpenCirclesButton"
Label="Mes cercles" Command="{Binding OpenCirclesAsync}">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource CircleIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarToggleButton Label="Publié"
IsChecked="{Binding DraftIsPublished, Mode=TwoWay}">
<CommandBarToggleButton.Icon>
<PathIcon Data="{StaticResource PublishedIcon}" />
</CommandBarToggleButton.Icon>
</CommandBarToggleButton>
<CommandBarButton x:Name="OpenSignatureDevButton"
Label="[DEV] Signature" Command="{Binding OpenSignatureDevAsync}">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource SignIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
</CommandBar.PrimaryCommands>
</CommandBar>
<Grid Margin="12" RowSpacing="12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<WrapPanel Grid.Row="0" ItemSpacing="10">
</WrapPanel>
<Border Grid.Row="1" BorderBrush="Gray" BorderThickness="1" Padding="8">
<ListBox ItemsSource="{Binding FilteredPosts}"
SelectedItem="{Binding SelectedPost, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="models:BlogPostDto">
<StackPanel Spacing="4">
<TextBlock Text="{Binding Title}" FontWeight="SemiBold" />
<TextBlock Text="{Binding DateModified, StringFormat='Updated: {0:yyyy-MM-dd HH:mm}'}" FontSize="10" Foreground="Gray" />
<TextBlock Text="{Binding AuthorId}" FontSize="10" Foreground="DarkSlateGray" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
<Border Grid.Row="2" BorderBrush="Gray" BorderThickness="1" Padding="8">
<Grid RowSpacing="10"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Row="0" Text="Post detail" FontWeight="SemiBold" />
<TextBox x:Name="DraftTitleTextBox"
Grid.Row="1" Text="{Binding DraftTitle, Mode=TwoWay}" PlaceholderText="Title" />
<TextBox Grid.Row="2" PlaceholderText="Write something here !"
Text="{Binding DraftArticle, Mode=TwoWay}"
MinHeight="320"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" />
<Border Grid.Row="3" BorderBrush="LightGray" BorderThickness="1" Padding="8">
<StackPanel Spacing="4">
<TextBlock Text="Pièces jointes" FontWeight="SemiBold" />
<ItemsControl ItemsSource="{Binding DraftAttachments}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding FileName}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<postitControls:StatusBar Grid.Row="4"
DataContext="{Binding ActionStatus}" />
</Grid>
</Border>
</Grid>
</StackPanel>
</ScrollViewer>
</ContentPage>

View file

@ -0,0 +1,71 @@
using System;
using System.IO;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Platform.Storage;
using Avalonia.Controls.Primitives;
namespace PostIt.Views.Blogs;
public partial class BlogsPage : ContentPage
{
public BlogsPage()
{
InitializeComponent();
}
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
if (DataContext is ViewModels.BlogsViewModel vm)
{
if (!vm.IsLoaded)
{
vm.RefreshAsync().Wait();
}
}
}
private async void AddAttachment_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null || DataContext is not ViewModels.BlogsViewModel vm)
return;
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Choisir des fichiers à joindre au billet",
AllowMultiple = true,
});
if (files.Count == 0)
return;
var uploads = new System.Collections.Generic.List<Yavsc.Api.Client.BlogUploadFile>(files.Count);
foreach (var file in files)
{
await using var stream = await file.OpenReadAsync();
using var memory = new MemoryStream();
await stream.CopyToAsync(memory);
uploads.Add(new Yavsc.Api.Client.BlogUploadFile(file.Name, memory.ToArray(), GetMimeType(file.Name)));
}
vm.DraftAttachments.Clear();
foreach (var upload in uploads)
vm.DraftAttachments.Add(upload);
}
private static string GetMimeType(string fileName)
{
var ext = Path.GetExtension(fileName)?.ToLowerInvariant();
return ext switch
{
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".webp" => "image/webp",
".gif" => "image/gif",
".pdf" => "application/pdf",
_ => "application/octet-stream"
};
}
}

View file

@ -1,114 +0,0 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:PostIt.ViewModels"
xmlns:postitControls="using:PostIt.Controls"
xmlns:models="using:Yavsc.Blogspot"
xmlns:views="using:PostIt.Views"
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
mc:Ignorable="d"
x:Class="PostIt.Views.MainPage"
x:DataType="vm:MainViewModel"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<Design.DataContext>
<vm:MainViewModel />
</Design.DataContext>
<Grid Margin="12" RowSpacing="12"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Border Grid.Row="0"
HorizontalAlignment="Stretch"
VerticalAlignment="Top"
Padding="0">
<WrapPanel Orientation="Horizontal">
<Button Command="{Binding RefreshAsync}" Content="🗘 Refresh" />
<Button Command="{Binding SearchAsync}" Content="🔍 Filter" />
<Button Command="{Binding SaveAsync}" Content="Save" />
<Button Command="{Binding DeleteAsync}" Content="Delete" />
<Button x:Name="ManageAclButton"
Command="{Binding ManageAclAsync}"
Content="ACL" />
<Button x:Name="OpenCirclesButton"
Command="{Binding OpenCirclesAsync}"
Content="Mes cercles" />
<!-- Publication toggle: a CheckBox wired to
DraftIsPublished. Clicking it fires
TogglePublishCommand, which pushes the
new state to /api/blog/{id}/publish.
The CheckBox is the canonical
AvaloniaXaml 'toggle' surface; binding
IsChecked TwoWay keeps the visual state
and the buffer in sync. -->
<CheckBox Content="Publié"
IsChecked="{Binding DraftIsPublished, Mode=TwoWay}"
Command="{Binding TogglePublishAsync}"
VerticalAlignment="Center"/>
<!--
DEV ONLY: temporary shortcut to open the signature
capture page. Production entry point is a SignalR
push from Yavsc.Org ("devis received, sign here").
Remove this button and its Click handler in
MainPage.axaml.cs once the SignalR handler lands.
-->
<Button x:Name="OpenSignatureDevButton"
Command="{Binding OpenSignatureDevAsync}"
Content="[DEV] Signature"
ToolTip.Tip="DEV ONLY — to remove when SignalR handler lands" />
</WrapPanel>
</Border>
<Border Grid.Row="1" BorderBrush="Gray" BorderThickness="1" Padding="8">
<ListBox ItemsSource="{Binding FilteredPosts}" SelectedItem="{Binding SelectedPost, Mode=TwoWay}"
HorizontalAlignment="Stretch" VerticalAlignment="Stretch" MinHeight="40">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="models:BlogPostDto">
<StackPanel Spacing="4">
<TextBlock Text="{Binding Title}" FontWeight="SemiBold" />
<TextBlock Text="{Binding DateModified, StringFormat='Updated: {0:yyyy-MM-dd HH:mm}'}" FontSize="10" Foreground="Gray" />
<TextBlock Text="{Binding AuthorId}" FontSize="10" Foreground="DarkSlateGray" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
<Border Grid.Row="2" BorderBrush="Gray" BorderThickness="1" Padding="8">
<Grid RowSpacing="10"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Row="0" Text="Post detail" FontWeight="SemiBold" />
<TextBox Grid.Row="1" Text="{Binding DraftTitle, Mode=TwoWay}" PlaceholderText="Title" />
<TextBox Grid.Row="2" PlaceholderText="Write something here !"
Text="{Binding DraftArticle, Mode=TwoWay}"
MinHeight="320"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
</TextBox>
<postitControls:StatusBar Grid.Row="3"
DataContext="{Binding ActionStatus}" />
</Grid>
</Border>
</Grid>
</ContentPage>

View file

@ -1,25 +0,0 @@
using System;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
namespace PostIt.Views;
public partial class MainPage : ContentPage
{
public MainPage()
{
InitializeComponent();
}
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
if (DataContext is ViewModels.MainViewModel vm)
{
if (!vm.IsLoaded)
{
vm.RefreshAsync().Wait();
}
}
}
}

View file

@ -22,5 +22,9 @@
Command="{Binding OpenActivities}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
<Button Content="Mes demandes en cours"
Command="{Binding OpenProviderRequests}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
</StackPanel>
</ContentPage>

View file

@ -6,11 +6,11 @@
xmlns:views="using:PostIt.Views"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="PostIt.Views.MainView"
x:DataType="vm:MainViewModel">
x:DataType="vm:HomePageViewModel">
<Design.DataContext>
<!-- This only sets the DataContext for the previewer in an IDE,
to set the actual DataContext for runtime, set the DataContext property in code (look at App.axaml.cs) -->
<vm:MainViewModel />
<vm:HomePageViewModel />
</Design.DataContext>
<DockPanel LastChildFill="True">

View file

@ -13,7 +13,7 @@ public partial class MainView : UserControl
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is ViewModels.MainViewModel vm)
if (DataContext is ViewModels.BlogsViewModel vm)
{
if (!vm.IsLoaded)
{

View file

@ -0,0 +1,67 @@
namespace Yavsc.Abstract.Files;
/// <summary>
/// Helpers pour dériver les URL publiques des fichiers statiques à partir
/// d'une URL d'autorité OIDC ou d'un autre point d'entrée racine.
/// </summary>
public static class FileServerUrlHelpers
{
/// <summary>
/// Dérive la racine publique des fichiers utilisateur en alignant
/// le chemin sur <see cref="Yavsc.Constants.UserFilesPath"/>.
/// </summary>
/// <param name="authorityBaseUrl">
/// URL absolue de base, typiquement l'autorité OIDC de Yavsc.Org.
/// </param>
/// <returns>Une URL absolue pointant vers la racine des fichiers utilisateur.</returns>
public static Uri GetUserFilesBaseUri(Uri authorityBaseUrl)
{
ArgumentNullException.ThrowIfNull(authorityBaseUrl);
if (!authorityBaseUrl.IsAbsoluteUri)
{
throw new ArgumentException(
"The authority base URL must be absolute.",
nameof(authorityBaseUrl));
}
var baseString = authorityBaseUrl.GetLeftPart(UriPartial.Authority);
return new Uri(new Uri(baseString, UriKind.Absolute), EnsureTrailingSlash(Yavsc.Constants.UserFilesPath));
}
/// <summary>
/// Dérive la racine publique des fichiers utilisateur en alignant
/// le chemin sur <see cref="Yavsc.Constants.UserFilesPath"/>.
/// </summary>
/// <param name="authorityBaseUrl">
/// URL absolue de base, typiquement l'autorité OIDC de Yavsc.Org.
/// </param>
/// <returns>Une URL absolue pointant vers la racine des fichiers utilisateur.</returns>
public static Uri GetUserFilesBaseUri(string authorityBaseUrl)
=> GetUserFilesBaseUri(new Uri(authorityBaseUrl, UriKind.Absolute));
/// <summary>
/// Construit l'URL d'un fichier utilisateur à partir de la base d'autorité
/// et d'un chemin relatif sous la racine des fichiers.
/// </summary>
public static Uri GetUserFilesUri(Uri authorityBaseUrl, string relativePath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(relativePath);
var baseUri = GetUserFilesBaseUri(authorityBaseUrl);
return new Uri(baseUri, NormalizeRelativePath(relativePath));
}
/// <summary>
/// Construit l'URL d'un fichier utilisateur à partir de la base d'autorité
/// et d'un chemin relatif sous la racine des fichiers.
/// </summary>
public static Uri GetUserFilesUri(string authorityBaseUrl, string relativePath)
=> GetUserFilesUri(new Uri(authorityBaseUrl, UriKind.Absolute), relativePath);
private static string EnsureTrailingSlash(string path)
=> path.EndsWith("/", StringComparison.Ordinal) ? path : path + "/";
private static string NormalizeRelativePath(string relativePath)
=> relativePath.TrimStart('/');
}

View file

@ -44,7 +44,6 @@ namespace Yavsc.Abstract.IT
public bool Validate()
{
// this is a n*n task
throw new NotImplementedException();
}

View file

@ -77,6 +77,14 @@ public sealed class BillingApiClient
return items;
}
public Task<List<BillingQuerySummaryDto>> GetProviderOngoingQueriesAsync(CancellationToken ct = default)
{
return _api.CallAsync<List<BillingQuerySummaryDto>>(
HttpMethod.Get,
Absolute("bill/provider/ongoing"),
ct: ct);
}
public async Task<BillingQueryDetailsDto> GetQueryAsync(string billingCode, long queryId, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(billingCode))
@ -156,6 +164,7 @@ public sealed class BillingApiClient
? null
: new BillingLocationDto
{
Id = dto.Location.Id > 0 ? dto.Location.Id : null,
Address = dto.Location.Address ?? string.Empty,
Latitude = dto.Location.Latitude,
Longitude = dto.Location.Longitude,
@ -183,6 +192,7 @@ public sealed class BillingApiClient
? null
: new BillingLocationDto
{
Id = dto.Location.Id > 0 ? dto.Location.Id : null,
Address = dto.Location.Address ?? string.Empty,
Latitude = dto.Location.Latitude,
Longitude = dto.Location.Longitude,
@ -212,6 +222,7 @@ public sealed class BillingApiClient
? null
: new BillingLocationDto
{
Id = dto.Location.Id > 0 ? dto.Location.Id : null,
Address = dto.Location.Address ?? string.Empty,
Latitude = dto.Location.Latitude,
Longitude = dto.Location.Longitude,
@ -305,6 +316,11 @@ public sealed class BillingApiClient
["Address"] = location.Address,
};
if (location.Id.HasValue && location.Id.Value > 0)
{
payload["Id"] = location.Id.Value;
}
if (location.Latitude.HasValue)
{
payload["Latitude"] = location.Latitude.Value;
@ -320,6 +336,7 @@ public sealed class BillingApiClient
private sealed class BillingLocationResponse
{
public long Id { get; set; }
public string? Address { get; set; }
public double Latitude { get; set; }
public double Longitude { get; set; }

View file

@ -1,8 +1,5 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using System.Net.Http.Headers;
using System.Text.Json;
using Yavsc.Blogspot;
namespace Yavsc.Api.Client;
@ -62,11 +59,18 @@ public sealed class BlogApiClient
public Task<BlogPostDto?> GetPostAsync(long id, CancellationToken ct = default)
=> _api.CallAsync<BlogPostDto?>(HttpMethod.Get, $"{_pathPrefix}/{id}", ct: ct);
public Task<BlogPostDto?> CreatePostAsync(BlogPostDto post, CancellationToken ct = default)
=> _api.CallAsync<BlogPostDto?>(HttpMethod.Post, _pathPrefix, body: post, ct: ct);
public Task<BlogPostDto?> CreatePostAsync(
BlogPostDto post,
IReadOnlyCollection<BlogUploadFile>? files = null,
CancellationToken ct = default)
=> SendPostAsync(HttpMethod.Post, _pathPrefix, post, files, ct);
public Task UpdatePostAsync(long id, BlogPostDto post, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Put, $"{_pathPrefix}/{id}", body: post, ct: ct);
public Task UpdatePostAsync(
long id,
BlogPostDto post,
IReadOnlyCollection<BlogUploadFile>? files = null,
CancellationToken ct = default)
=> SendPostAsync(HttpMethod.Put, $"{_pathPrefix}/{id}", post, files, ct);
public Task DeletePostAsync(long id, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Delete, $"{_pathPrefix}/{id}", ct: ct);
@ -81,4 +85,39 @@ public sealed class BlogApiClient
public Task SetPublishAsync(long id, bool publish, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Put, $"{_pathPrefix}/{id}/publish",
body: new { publish }, ct: ct);
private async Task<BlogPostDto?> SendPostAsync(
HttpMethod method,
string path,
BlogPostDto post,
IReadOnlyCollection<BlogUploadFile>? files,
CancellationToken ct)
{
if (files is null || files.Count == 0)
return await _api.CallAsync<BlogPostDto?>(method, path, body: post, ct: ct);
return await _api.CallAsync<BlogPostDto?>(method, path, () => CreateMultipartContent(post, files), ct: ct);
}
private static HttpContent CreateMultipartContent(BlogPostDto post, IReadOnlyCollection<BlogUploadFile> files)
{
var content = new MultipartFormDataContent();
var blogJson = JsonSerializer.Serialize(post, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
});
content.Add(new StringContent(blogJson), "blog");
foreach (var file in files)
{
var fileContent = new ByteArrayContent(file.Content);
fileContent.Headers.ContentType = new MediaTypeHeaderValue(
string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType);
content.Add(fileContent, "file", file.FileName);
}
return content;
}
}

View file

@ -0,0 +1,6 @@
namespace Yavsc.Api.Client;
/// <summary>
/// Buffered file payload for multipart blog uploads.
/// </summary>
public sealed record BlogUploadFile(string FileName, byte[] Content, string? ContentType = null);

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
using Yavsc;
namespace Yavsc.Api.Client;
@ -29,7 +30,14 @@ public sealed class BillingQueryDetailsDto
public sealed class BillingLocationDto
{
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public long? Id { get; set; }
public string Address { get; set; } = string.Empty;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public double? Latitude { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public double? Longitude { get; set; }
}

View file

@ -0,0 +1,52 @@
using System;
using System.Collections.Generic;
namespace Yavsc.Api.Client;
/// <summary>
/// A single billable line of an estimate, mirroring the JSON shape of
/// the server-side <c>Yavsc.Models.Billing.CommandLine</c> entity.
/// </summary>
public sealed class EstimateLineDto
{
public long Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public int Count { get; set; } = 1;
public decimal UnitaryCost { get; set; }
public long EstimateId { get; set; }
public string Currency { get; set; } = "EUR";
}
/// <summary>
/// Estimate payload exchanged with the <c>api/v1/estimate</c> routes
/// (<c>EstimateApiController</c>). <see cref="AttachedGraphics"/> and
/// <see cref="AttachedFiles"/> are always initialised: the server-side
/// entity reads them from non-nullable string properties and a null
/// list would break its serialisation.
/// </summary>
public sealed class EstimateDto
{
public long Id { get; set; }
public long? CommandId { get; set; }
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public List<EstimateLineDto> Bill { get; set; } = new();
public List<string> AttachedGraphics { get; set; } = new();
public List<string> AttachedFiles { get; set; } = new();
public string? OwnerId { get; set; }
public string ClientId { get; set; } = string.Empty;
public string CommandType { get; set; } = string.Empty;
public DateTime ProviderValidationDate { get; set; }
public DateTime ClientValidationDate { get; set; }
}
/// <summary>
/// Response of a successful estimate creation
/// (<c>Ok(new { estimate.Id, estimate.Bill })</c>).
/// </summary>
public sealed class EstimateCreatedDto
{
public long Id { get; set; }
public List<EstimateLineDto> Bill { get; set; } = new();
}

View file

@ -0,0 +1,110 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace Yavsc.Api.Client;
/// <summary>
/// HTTP client for the estimate routes (<c>api/v1/estimate</c>,
/// served by <c>EstimateApiController</c>). Follows the same
/// DTO↔path mapper shape as <see cref="BillingApiClient"/>: all
/// transport concerns (base URL, JSON, Bearer auth, silent refresh
/// on 401) are delegated to <see cref="IYavscApiClient"/>.
/// </summary>
public sealed class EstimateApiClient
{
private const string PathPrefix = "estimate";
private readonly IYavscApiClient _api;
private readonly Func<string> _businessBaseAddress;
public EstimateApiClient(IYavscApiClient api, string businessBaseAddress)
: this(api, () => businessBaseAddress)
{
}
public EstimateApiClient(IYavscApiClient api, Func<string> businessBaseAddress)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
_businessBaseAddress = businessBaseAddress ?? throw new ArgumentNullException(nameof(businessBaseAddress));
// Validate initial value early to fail fast on invalid setup.
_ = ResolveBusinessBaseAddress();
}
/// <summary>
/// Lists the estimates of the given owner; when <paramref name="ownerId"/>
/// is null, the server falls back to the current user.
/// </summary>
public Task<List<EstimateDto>> GetEstimatesAsync(string? ownerId = null, CancellationToken ct = default)
{
var path = string.IsNullOrWhiteSpace(ownerId)
? PathPrefix
: $"{PathPrefix}?ownerId={Uri.EscapeDataString(ownerId)}";
return _api.CallAsync<List<EstimateDto>>(HttpMethod.Get, Absolute(path), ct: ct);
}
public Task<EstimateDto> GetEstimateAsync(long id, CancellationToken ct = default)
{
if (id <= 0)
throw new ArgumentOutOfRangeException(nameof(id));
return _api.CallAsync<EstimateDto>(HttpMethod.Get, Absolute($"{PathPrefix}/{id}"), ct: ct);
}
/// <summary>
/// Creates an estimate. When <see cref="EstimateDto.CommandId"/> is set,
/// the server also stamps the linked command as validated.
/// </summary>
public async Task<EstimateCreatedDto> CreateAsync(EstimateDto estimate, CancellationToken ct = default)
{
if (estimate is null)
throw new ArgumentNullException(nameof(estimate));
var created = await _api.CallAsync<EstimateCreatedDto>(
HttpMethod.Post,
Absolute(PathPrefix),
body: estimate,
ct: ct).ConfigureAwait(false);
return created ?? new EstimateCreatedDto();
}
public Task UpdateAsync(long id, EstimateDto estimate, CancellationToken ct = default)
{
if (id <= 0)
throw new ArgumentOutOfRangeException(nameof(id));
if (estimate is null)
throw new ArgumentNullException(nameof(estimate));
estimate.Id = id;
return _api.CallAsync(
HttpMethod.Put,
Absolute($"{PathPrefix}/{id}"),
body: estimate,
ct: ct);
}
public Task DeleteAsync(long id, CancellationToken ct = default)
{
if (id <= 0)
throw new ArgumentOutOfRangeException(nameof(id));
return _api.CallAsync(HttpMethod.Delete, Absolute($"{PathPrefix}/{id}"), ct: ct);
}
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
private Uri ResolveBusinessBaseAddress()
{
var raw = _businessBaseAddress();
if (string.IsNullOrWhiteSpace(raw))
throw new InvalidOperationException("Business base address is required.");
return new Uri(raw, UriKind.Absolute);
}
}

View file

@ -53,10 +53,24 @@ public interface IYavscApiClient : IAsyncDisposable
object? body = null,
CancellationToken ct = default);
/// <summary>Call a multipart endpoint with a typed return value.</summary>
Task<T> CallAsync<T>(
HttpMethod method,
string path,
Func<HttpContent> contentFactory,
CancellationToken ct = default);
/// <summary>Call a JSON endpoint that returns no useful body (DELETE, 204, etc.).</summary>
Task CallAsync(
HttpMethod method,
string path,
object? body = null,
CancellationToken ct = default);
/// <summary>Call a multipart endpoint that returns no useful body.</summary>
Task CallAsync(
HttpMethod method,
string path,
Func<HttpContent> contentFactory,
CancellationToken ct = default);
}

View file

@ -0,0 +1,236 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Api.Test.Fixtures;
using Yavsc.Helpers;
using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Models.Haircut;
using Yavsc.Models.Workflow;
using Yavsc.Tests.Shared;
namespace Yavsc.Api.Test;
[Collection("Yavsc Api")]
public sealed class BillingControllerTests : IClassFixture<ApiWebServerFixture>
{
private readonly ApiWebServerFixture _fixture;
public BillingControllerTests(ApiWebServerFixture fixture)
{
_fixture = fixture;
}
private HttpClient NewClient(string subject = "alice", string scope = "api")
{
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
var http = new HttpClient(handler)
{
BaseAddress = new Uri(_fixture.BaseAddress)
};
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, scope));
return http;
}
[Fact]
public async Task GetProviderOngoingCommands_returns_current_provider_requests()
{
WorkflowHelpers.ConfigureBillingService();
_fixture.ResetAndSeedActivityGraph();
using (var scope = _fixture.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var location = db.Locations.Single();
db.RdvQueries.Add(new RdvQuery
{
ActivityCode = "dev",
ClientId = "bob",
PerformerId = "alice",
Consent = true,
UserCreated = "alice",
UserModified = "alice",
DateCreated = DateTime.UtcNow.AddMinutes(-10),
DateModified = DateTime.UtcNow.AddMinutes(-8),
EventDate = DateTime.UtcNow.AddDays(1),
Location = location,
Reason = "Rendez-vous fournisseur",
Status = QueryStatus.InProgress,
Description = "Commande fournisseur en cours",
});
db.RdvQueries.Add(new RdvQuery
{
ActivityCode = "dev",
ClientId = "alice",
PerformerId = "bob",
Consent = true,
UserCreated = "bob",
UserModified = "bob",
DateCreated = DateTime.UtcNow.AddMinutes(-20),
DateModified = DateTime.UtcNow.AddMinutes(-20),
EventDate = DateTime.UtcNow.AddDays(2),
Location = location,
Reason = "Commande d'un autre prestataire",
Status = QueryStatus.Accepted,
Description = "Autre prestataire",
});
db.SaveChanges();
}
using var http = NewClient();
var response = await http.GetAsync("/api/v1/bill/provider/ongoing", TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
var payload = await response.Content.ReadFromJsonAsync<List<ProviderOngoingCommandDto>>(TestContext.Current.CancellationToken);
Assert.NotNull(payload);
Assert.NotEmpty(payload!);
Assert.All(payload!, item => Assert.Equal("alice", item.PerformerId));
Assert.Contains(payload!, item => item.BillingCode == BillingCodes.Rdv);
}
[Fact]
public async Task GetProviderOngoingCommands_ignores_rows_with_invalid_discriminator()
{
WorkflowHelpers.ConfigureBillingService();
_fixture.ResetAndSeedActivityGraph();
using (var scope = _fixture.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.Database.ExecuteSqlInterpolated($@"
INSERT INTO ""NominativeServiceCommand""
(""ActivityCode"", ""ClientId"", ""Consent"", ""DateCreated"", ""DateModified"", ""Description"", ""Discriminator"", ""PerformerId"", ""Status"", ""UserCreated"", ""UserModified"")
VALUES
({"dev"}, {"bob"}, {true}, {DateTime.UtcNow.AddMinutes(-5)}, {DateTime.UtcNow.AddMinutes(-4)}, {"Legacy malformed row"}, {""}, {"alice"}, {(int)QueryStatus.Accepted}, {"alice"}, {"alice"});
");
var location = db.Locations.Single();
db.RdvQueries.Add(new RdvQuery
{
ActivityCode = "dev",
ClientId = "bob",
PerformerId = "alice",
Consent = true,
UserCreated = "alice",
UserModified = "alice",
DateCreated = DateTime.UtcNow.AddMinutes(-3),
DateModified = DateTime.UtcNow.AddMinutes(-2),
EventDate = DateTime.UtcNow.AddDays(1),
Location = location,
Reason = "Commande valide",
Status = QueryStatus.InProgress,
Description = "Commande fournisseur valide",
});
db.SaveChanges();
}
using var http = NewClient();
var response = await http.GetAsync("/api/v1/bill/provider/ongoing", TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
var payload = await response.Content.ReadFromJsonAsync<List<ProviderOngoingCommandDto>>(TestContext.Current.CancellationToken);
Assert.NotNull(payload);
Assert.NotEmpty(payload!);
Assert.Contains(payload!, item => item.BillingCode == BillingCodes.Rdv && item.PerformerId == "alice");
Assert.DoesNotContain(payload!, item => string.IsNullOrWhiteSpace(item.BillingCode));
}
[Fact]
public async Task GetProviderOngoingCommands_returns_haircut_and_grouped_haircut_requests()
{
WorkflowHelpers.ConfigureBillingService();
_fixture.ResetAndSeedHaircutGraph();
using (var scope = _fixture.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.UserActivities.Add(new UserActivity
{
UserId = "alice",
DoesCode = "brush",
Weight = 50,
});
db.UserActivities.Add(new UserActivity
{
UserId = "alice",
DoesCode = "mbrush",
Weight = 50,
});
db.CommandForm.Add(new CommandForm
{
ActivityCode = "brush",
ActionName = BillingCodes.Brush,
Title = "Brush",
});
db.CommandForm.Add(new CommandForm
{
ActivityCode = "mbrush",
ActionName = BillingCodes.MBrush,
Title = "MBrush",
});
db.SaveChanges();
}
using var http = NewClient();
var response = await http.GetAsync("/api/v1/bill/provider/ongoing", TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
var payload = await response.Content.ReadFromJsonAsync<List<ProviderOngoingCommandDto>>(TestContext.Current.CancellationToken);
Assert.NotNull(payload);
Assert.Contains(payload!, item => item.BillingCode == BillingCodes.Brush && item.PerformerId == "alice");
Assert.Contains(payload!, item => item.BillingCode == BillingCodes.MBrush && item.PerformerId == "alice");
}
[Fact]
public async Task GetProviderOngoingCommands_excludes_requests_outside_performer_declared_activities()
{
WorkflowHelpers.ConfigureBillingService();
_fixture.ResetAndSeedHaircutGraph();
using var http = NewClient();
var response = await http.GetAsync("/api/v1/bill/provider/ongoing", TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
var payload = await response.Content.ReadFromJsonAsync<List<ProviderOngoingCommandDto>>(TestContext.Current.CancellationToken);
Assert.NotNull(payload);
Assert.DoesNotContain(payload!, item => item.BillingCode == BillingCodes.Brush);
Assert.DoesNotContain(payload!, item => item.BillingCode == BillingCodes.MBrush);
}
private sealed class ProviderOngoingCommandDto
{
public long Id { get; set; }
public string BillingCode { get; set; } = string.Empty;
public string ActivityCode { get; set; } = string.Empty;
public string PerformerId { get; set; } = string.Empty;
public string ClientId { get; set; } = string.Empty;
public QueryStatus Status { get; set; }
public string Description { get; set; } = string.Empty;
}
}

View file

@ -0,0 +1,209 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Api.Test.Fixtures;
using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Models.Workflow;
using Yavsc.Tests.Shared;
namespace Yavsc.Api.Test;
[Collection("Yavsc Api")]
public sealed class EstimateApiControllerTests : IClassFixture<ApiWebServerFixture>
{
private readonly ApiWebServerFixture _fixture;
public EstimateApiControllerTests(ApiWebServerFixture fixture)
{
_fixture = fixture;
}
private HttpClient NewClient(string subject = "alice", string scope = "api")
{
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
var http = new HttpClient(handler)
{
BaseAddress = new Uri(_fixture.BaseAddress)
};
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, scope));
return http;
}
/// <summary>
/// Seed a provider (alice) and a client (bob) with a pending
/// <see cref="RdvQuery"/> from bob to alice, and return the
/// command id.
/// </summary>
private long SeedPendingCommand()
{
_fixture.ResetAndSeedActivityGraph();
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var location = db.Locations.Single();
var query = new RdvQuery
{
ActivityCode = "dev",
ClientId = "bob",
PerformerId = "alice",
Consent = true,
UserCreated = "bob",
UserModified = "bob",
DateCreated = DateTime.UtcNow.AddMinutes(-10),
DateModified = DateTime.UtcNow.AddMinutes(-10),
EventDate = DateTime.UtcNow.AddDays(1),
Location = location,
Reason = "Demande de devis",
Status = QueryStatus.InProgress,
Description = "Demande en attente de devis",
};
db.RdvQueries.Add(query);
db.SaveChanges();
return query.Id;
}
private static object NewEstimatePayload(long? commandId, string clientId, string? ownerId = null)
=> new
{
CommandId = commandId,
ClientId = clientId,
OwnerId = ownerId,
CommandType = BillingCodes.Rdv,
Title = "Devis prestation",
Description = "Devis détaillé",
AttachedFiles = Array.Empty<string>(),
AttachedGraphics = Array.Empty<string>(),
Bill = new[]
{
new { Name = "Prestation", Description = "Prestation de base", Count = 1, UnitaryCost = 120m, Currency = "EUR" },
new { Name = "Remise", Description = "Remise fidélité", Count = 1, UnitaryCost = -20m, Currency = "EUR" },
},
};
[Fact]
public async Task PostEstimate_creates_the_estimate_and_validates_the_linked_command()
{
var commandId = SeedPendingCommand();
using var http = NewClient("alice");
var response = await http.PostAsJsonAsync(
"/api/v1/estimate",
NewEstimatePayload(commandId, clientId: "bob", ownerId: "alice"),
TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
using var doc = JsonDocument.Parse(body);
var estimateId = doc.RootElement.GetProperty("id").GetInt64();
Assert.True(estimateId > 0);
Assert.Equal(2, doc.RootElement.GetProperty("bill").GetArrayLength());
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var estimate = db.Estimates.Include(e => e.Bill).Single(e => e.Id == estimateId);
Assert.Equal("alice", estimate.OwnerId);
Assert.Equal("bob", estimate.ClientId);
Assert.Equal(commandId, estimate.CommandId);
Assert.Equal(BillingCodes.Rdv, estimate.CommandType);
Assert.Equal(2, estimate.Bill.Count);
Assert.Contains(estimate.Bill, line => line.UnitaryCost == -20m);
// PostEstimate stamps the linked command as validated.
var query = db.RdvQueries.Single(q => q.Id == commandId);
Assert.NotNull(query.ValidationDate);
}
[Fact]
public async Task PostEstimate_without_command_creates_a_standalone_estimate()
{
_fixture.ResetAndSeedActivityGraph();
using var http = NewClient("alice");
var response = await http.PostAsJsonAsync(
"/api/v1/estimate",
NewEstimatePayload(commandId: null, clientId: "bob"),
TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
using var doc = JsonDocument.Parse(body);
var estimateId = doc.RootElement.GetProperty("id").GetInt64();
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var estimate = db.Estimates.Single(e => e.Id == estimateId);
Assert.Null(estimate.CommandId);
Assert.Equal("alice", estimate.OwnerId);
}
[Fact]
public async Task PostEstimate_for_another_owner_is_rejected()
{
_fixture.ResetAndSeedActivityGraph();
using var http = NewClient("alice");
var response = await http.PostAsJsonAsync(
"/api/v1/estimate",
NewEstimatePayload(commandId: null, clientId: "bob", ownerId: "bob"),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
Assert.Empty(db.Estimates);
}
[Fact]
public async Task PostEstimate_with_an_unknown_command_id_is_rejected()
{
_fixture.ResetAndSeedActivityGraph();
using var http = NewClient("alice");
var response = await http.PostAsJsonAsync(
"/api/v1/estimate",
NewEstimatePayload(commandId: 999999, clientId: "bob"),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
Assert.Empty(db.Estimates);
}
[Fact]
public async Task PostEstimate_without_token_is_unauthorized()
{
_fixture.ResetAndSeedActivityGraph();
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
using var http = new HttpClient(handler) { BaseAddress = new Uri(_fixture.BaseAddress) };
var response = await http.PostAsJsonAsync(
"/api/v1/estimate",
NewEstimatePayload(commandId: null, clientId: "bob"),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
}

View file

@ -1,45 +1,70 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using Npgsql;
using Yavsc.Controllers;
using Yavsc.Interfaces.Workflow;
using Yavsc.Models;
using Yavsc.Models.Google.Messaging;
using Yavsc.Models.Haircut;
using Yavsc.Models.Messaging;
using Yavsc.Models.Relationship;
using Yavsc.Models.Workflow;
using Yavsc.Services;
using Yavsc.Tests.Shared;
namespace Yavsc.Api.Test.Fixtures;
public sealed class ApiWebServerFixture : WebHostFixture
{
private const string DbProviderEnvVar = "YAVSC_API_TEST_DB_PROVIDER";
private const string NpgsqlAdminConnectionEnvVar = "YAVSC_API_TEST_NPGSQL_ADMIN_CONNECTION";
private const string DedicatedNpgsqlDatabaseName = "yavscTestDb";
private const string DefaultDevelopmentConnectionString = "Server=localhost;Port=5432;Database=yavscdev;Username=yavscdev;Password=8*5idas;Include Error Detail=true";
protected override int HttpsPort => 5104;
private static SqliteConnection? _sharedSqliteConnection;
private static readonly object _sqliteLock = new();
private static readonly object _npgsqlLock = new();
private static string? _sharedNpgsqlConnectionString;
protected override WebApplication BuildApp(WebApplicationBuilder builder)
{
SqliteConnection sharedConnection;
lock (_sqliteLock)
if (UseNpgsqlProvider())
{
if (_sharedSqliteConnection is null)
{
_sharedSqliteConnection = new SqliteConnection(
"Data Source=YavscApiTests;Mode=Memory;Cache=Shared");
_sharedSqliteConnection.Open();
}
sharedConnection = _sharedSqliteConnection;
var npgsqlConnectionString = EnsureNpgsqlDatabaseCreated();
builder.Services.AddDbContext<ApplicationDbContext>(opt =>
opt.UseNpgsql(npgsqlConnectionString));
}
else
{
SqliteConnection sharedConnection;
lock (_sqliteLock)
{
if (_sharedSqliteConnection is null)
{
_sharedSqliteConnection = new SqliteConnection(
"Data Source=YavscApiTests;Mode=Memory;Cache=Shared");
_sharedSqliteConnection.Open();
}
sharedConnection = _sharedSqliteConnection;
}
builder.Services.AddDbContext<ApplicationDbContext>(opt =>
opt.UseSqlite(sharedConnection));
builder.Services.AddDbContext<ApplicationDbContext>(opt =>
opt.UseSqlite(sharedConnection));
}
builder.Services.AddControllers()
.AddApplicationPart(typeof(ActivityApiController).Assembly);
builder.Services.AddLocalization();
builder.Services.Configure<GoogleAuthSettings>(_ => { });
builder.Services.AddTransient<IBillingService, BillingService>();
builder.Services.AddTransient<IYavscMessageSender, NoopMessageSender>();
builder.Services.AddAuthorization();
builder.Services.AddAuthentication("Bearer")
@ -83,12 +108,97 @@ public sealed class ApiWebServerFixture : WebHostFixture
public string BaseAddress => Addresses.First(a => a.StartsWith("https://", StringComparison.Ordinal));
public static bool UseNpgsqlProvider()
=> string.Equals(
Environment.GetEnvironmentVariable(DbProviderEnvVar),
"npgsql",
StringComparison.OrdinalIgnoreCase);
private static string EnsureNpgsqlDatabaseCreated()
{
lock (_npgsqlLock)
{
if (!string.IsNullOrWhiteSpace(_sharedNpgsqlConnectionString))
{
return _sharedNpgsqlConnectionString;
}
var adminConnectionString = BuildAdminConnectionString();
var databaseName = DedicatedNpgsqlDatabaseName;
using (var adminConnection = new NpgsqlConnection(adminConnectionString))
{
adminConnection.Open();
using var existsCommand = adminConnection.CreateCommand();
existsCommand.CommandText = "SELECT 1 FROM pg_database WHERE datname = @databaseName";
existsCommand.Parameters.AddWithValue("databaseName", databaseName);
if (existsCommand.ExecuteScalar() is null)
{
using var createCommand = adminConnection.CreateCommand();
createCommand.CommandText = $"CREATE DATABASE \"{databaseName}\"";
createCommand.ExecuteNonQuery();
}
}
var testConnectionBuilder = new NpgsqlConnectionStringBuilder(adminConnectionString)
{
Database = databaseName,
Pooling = false,
IncludeErrorDetail = true
};
_sharedNpgsqlConnectionString = testConnectionBuilder.ToString();
return _sharedNpgsqlConnectionString;
}
}
private static string BuildAdminConnectionString()
{
var configured = Environment.GetEnvironmentVariable(NpgsqlAdminConnectionEnvVar);
var source = string.IsNullOrWhiteSpace(configured)
? DefaultDevelopmentConnectionString
: configured;
var builder = new NpgsqlConnectionStringBuilder(source)
{
Pooling = false,
IncludeErrorDetail = true
};
if (string.IsNullOrWhiteSpace(configured))
{
builder.Database = "postgres";
}
else if (string.IsNullOrWhiteSpace(builder.Database))
{
builder.Database = "postgres";
}
return builder.ToString();
}
private sealed class NoopMessageSender : IYavscMessageSender
{
public Task<MessageWithPayloadResponse> NotifyBookQueryAsync(IEnumerable<string> connectionIds, RdvQueryEvent ev)
=> Task.FromResult(new MessageWithPayloadResponse());
public Task<MessageWithPayloadResponse> NotifyEstimateAsync(IEnumerable<string> connectionIds, EstimationEvent ev)
=> Task.FromResult(new MessageWithPayloadResponse());
public Task<MessageWithPayloadResponse> NotifyHairCutQueryAsync(IEnumerable<string> connectionIds, HairCutQueryEvent ev)
=> Task.FromResult(new MessageWithPayloadResponse());
public Task<MessageWithPayloadResponse> NotifyAsync(IEnumerable<string> connectionIds, IEvent yaev)
=> Task.FromResult(new MessageWithPayloadResponse());
}
public void ResetAndSeedActivityGraph()
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.Database.EnsureDeleted();
ResetDatabase(db);
db.Database.EnsureCreated();
var user = new ApplicationUser
@ -189,6 +299,94 @@ public sealed class ApiWebServerFixture : WebHostFixture
db.SaveChanges();
}
private static void ResetDatabase(ApplicationDbContext db)
{
if (UseNpgsqlProvider())
{
db.Set<UserActivity>().RemoveRange(db.Set<UserActivity>());
db.Set<Activity>().RemoveRange(db.Set<Activity>());
db.Set<PerformerProfile>().RemoveRange(db.Set<PerformerProfile>());
db.Set<ApplicationUser>().RemoveRange(db.Set<ApplicationUser>());
db.Set<Location>().RemoveRange(db.Set<Location>());
db.Set<RdvQuery>().RemoveRange(db.Set<RdvQuery>());
db.Set<HairCutQuery>().RemoveRange(db.Set<HairCutQuery>());
db.Set<HairMultiCutQuery>().RemoveRange(db.Set<HairMultiCutQuery>());
db.Set<HairPrestation>().RemoveRange(db.Set<HairPrestation>());
db.Set<HairPrestationCollectionItem>().RemoveRange(db.Set<HairPrestationCollectionItem>());
db.SaveChanges();
return;
}
db.Database.EnsureDeleted();
}
private static IReadOnlyList<IEntityType> GetDeletionOrder(IModel model)
{
var entityTypes = model
.GetEntityTypes()
.Where(et =>
et.ClrType is not null &&
!et.IsOwned() &&
et.FindPrimaryKey() is not null)
.ToArray();
var included = new HashSet<IEntityType>(entityTypes);
var dependencies = new Dictionary<IEntityType, HashSet<IEntityType>>();
foreach (var entityType in entityTypes)
{
var principals = entityType
.GetForeignKeys()
.Where(fk => !fk.IsOwnership)
.Select(fk => fk.PrincipalEntityType)
.Where(included.Contains)
.ToHashSet();
dependencies[entityType] = principals;
}
var queue = new Queue<IEntityType>(
dependencies.Where(kvp => kvp.Value.Count == 0).Select(kvp => kvp.Key));
var order = new List<IEntityType>(entityTypes.Length);
while (queue.Count > 0)
{
var current = queue.Dequeue();
if (!order.Contains(current))
{
order.Add(current);
}
foreach (var kvp in dependencies)
{
if (!kvp.Value.Remove(current) || kvp.Value.Count != 0)
{
continue;
}
if (!order.Contains(kvp.Key) && !queue.Contains(kvp.Key))
{
queue.Enqueue(kvp.Key);
}
}
}
// If cycles remain (rare), append unresolved types last and rely on DB cascades.
foreach (var entityType in entityTypes)
{
if (!order.Contains(entityType))
{
order.Add(entityType);
}
}
return order;
}
public void ResetAndSeedRdvQueryGraph()
{
ResetAndSeedActivityGraph();
@ -335,21 +533,9 @@ public sealed class ApiWebServerFixture : WebHostFixture
public override void Dispose()
{
try
{
base.Dispose();
}
finally
{
lock (_sqliteLock)
{
if (_sharedSqliteConnection is not null)
{
_sharedSqliteConnection.Close();
_sharedSqliteConnection.Dispose();
_sharedSqliteConnection = null;
}
}
}
// Keep the shared in-memory SQLite connection alive for the
// whole test process. Closing it from one fixture instance can
// drop the schema while other tests are still running.
base.Dispose();
}
}

View file

@ -143,4 +143,87 @@ public sealed class RdvQueryApiControllerTests : IClassFixture<ApiWebServerFixtu
Assert.NotNull(created);
Assert.Equal(DateTimeKind.Utc, created!.EventDate.Kind);
}
[Fact]
public async Task PostQuery_with_unknown_location_id_creates_location_and_succeeds()
{
_fixture.ResetAndSeedRdvQueryGraph();
using var http = NewClient(subject: "alice");
var createPayload = new
{
ActivityCode = "dev",
PerformerId = "alice",
Consent = true,
EventDate = DateTime.UtcNow.AddDays(3),
Location = new
{
Id = 999999L,
Address = "4 rue du Test",
Latitude = 48.8569,
Longitude = 2.3525,
},
Reason = "Rendez-vous id location inconnu",
Status = QueryStatus.Inserted,
};
var createResponse = await http.PostAsJsonAsync("/api/v1/billing/Rdv", createPayload, TestContext.Current.CancellationToken);
var body = await createResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(createResponse.StatusCode == HttpStatusCode.Created, $"Unexpected status {(int)createResponse.StatusCode} ({createResponse.StatusCode}): {body}");
var created = await createResponse.Content.ReadFromJsonAsync<RdvQuery>(TestContext.Current.CancellationToken);
Assert.NotNull(created);
Assert.NotNull(created!.Location);
Assert.True(created.Location.Id > 0);
Assert.NotEqual(999999L, created.Location.Id);
Assert.Equal("alice", created.ClientId);
}
[Fact]
public async Task PostQuery_without_location_returns_bad_request()
{
_fixture.ResetAndSeedRdvQueryGraph();
using var http = NewClient(subject: "alice");
var createPayload = new
{
ActivityCode = "dev",
PerformerId = "alice",
Consent = true,
EventDate = DateTime.UtcNow.AddDays(1),
Reason = "Rendez-vous sans location",
Status = QueryStatus.Inserted,
};
var createResponse = await http.PostAsJsonAsync("/api/v1/billing/Rdv", createPayload, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, createResponse.StatusCode);
}
[Fact]
public async Task PostQuery_with_unknown_location_id_and_missing_address_returns_bad_request()
{
_fixture.ResetAndSeedRdvQueryGraph();
using var http = NewClient(subject: "alice");
var createPayload = new
{
ActivityCode = "dev",
PerformerId = "alice",
Consent = true,
EventDate = DateTime.UtcNow.AddDays(1),
Location = new
{
Id = 777777L,
Address = "",
Latitude = 0.0,
Longitude = 0.0,
},
Reason = "Rendez-vous location invalide",
Status = QueryStatus.Inserted,
};
var createResponse = await http.PostAsJsonAsync("/api/v1/billing/Rdv", createPayload, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, createResponse.StatusCode);
}
}

View file

@ -3,9 +3,12 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
using Newtonsoft.Json;
using System.Security.Claims;
using Yavsc.Billing;
using Yavsc.Helpers;
using Yavsc.ViewModels;
using Yavsc.Models.Billing;
using Yavsc.Models.Haircut;
using Yavsc.Models.Workflow;
using Yavsc.Server.Models.FileSystem;
namespace Yavsc.ApiControllers
@ -100,6 +103,114 @@ namespace Yavsc.ApiControllers
return ViewComponent("Bill",new object[] { billingCode, bill, OutputFormat.Pdf, true } );
}
/// <summary>
/// Lists ongoing service commands for the authenticated performer.
/// This endpoint is tailored for the PostIt provider homepage flow
/// ("Mes demandes en cours").
/// </summary>
[HttpGet("provider/ongoing")]
[Produces("application/json")]
public IActionResult GetProviderOngoingCommands()
{
var uid = User.GetUserId();
if (string.IsNullOrWhiteSpace(uid))
{
return Unauthorized();
}
if (billingService.BillingMap.Count == 0)
{
WorkflowHelpers.ConfigureBillingService();
}
var allowedActivityCodes = dbContext.UserActivities
.AsNoTracking()
.Where(a => a.UserId == uid)
.Select(a => a.DoesCode)
.Distinct()
.ToList();
if (allowedActivityCodes.Count == 0)
{
return Ok(Array.Empty<object>());
}
var allowedBillingCodes = dbContext.CommandForm
.AsNoTracking()
.Where(form => allowedActivityCodes.Contains(form.ActivityCode))
.Select(form => form.ActionName)
.Where(actionName => !string.IsNullOrWhiteSpace(actionName))
.Distinct()
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var fallbackToActivityFilteringOnly = allowedBillingCodes.Count == 0;
// Query only the command types allowed by the performer's declared
// activities; this avoids touching unrelated legacy slices.
var rdvCommands = fallbackToActivityFilteringOnly || allowedBillingCodes.Contains(BillingCodes.Rdv)
? dbContext.Set<RdvQuery>()
.AsNoTracking()
.Where(q => q.PerformerId == uid)
.Where(q => allowedActivityCodes.Contains(q.ActivityCode))
.Where(q => q.Status == QueryStatus.Inserted
|| q.Status == QueryStatus.Accepted
|| q.Status == QueryStatus.InProgress)
.Cast<NominativeServiceCommand>()
.ToList()
: new List<NominativeServiceCommand>();
var hairCommands = fallbackToActivityFilteringOnly || allowedBillingCodes.Contains(BillingCodes.Brush)
? dbContext.Set<HairCutQuery>()
.AsNoTracking()
.Where(q => q.PerformerId == uid)
.Where(q => allowedActivityCodes.Contains(q.ActivityCode))
.Where(q => q.Status == QueryStatus.Inserted
|| q.Status == QueryStatus.Accepted
|| q.Status == QueryStatus.InProgress)
.Cast<NominativeServiceCommand>()
.ToList()
: new List<NominativeServiceCommand>();
var hairMultiCommands = fallbackToActivityFilteringOnly || allowedBillingCodes.Contains(BillingCodes.MBrush)
? dbContext.Set<HairMultiCutQuery>()
.AsNoTracking()
.Where(q => q.PerformerId == uid)
.Where(q => allowedActivityCodes.Contains(q.ActivityCode))
.Where(q => q.Status == QueryStatus.Inserted
|| q.Status == QueryStatus.Accepted
|| q.Status == QueryStatus.InProgress)
.Cast<NominativeServiceCommand>()
.ToList()
: new List<NominativeServiceCommand>();
var commands = rdvCommands
.Concat(hairCommands)
.Concat(hairMultiCommands)
.OrderByDescending(q => q.DateModified)
.ThenByDescending(q => q.Id)
.ToList();
var payload = commands
.Select(q => new
{
Id = q.Id,
BillingCode = ResolveBillingCode(q),
ActivityCode = q.ActivityCode,
PerformerId = q.PerformerId,
ClientId = q.ClientId,
Status = q.Status,
Description = q.Description,
EventDate = ResolveEventDate(q),
Reason = q is Models.Workflow.RdvQuery rdv ? rdv.Reason : string.Empty,
AdditionalInfo = q is Models.Haircut.HairCutQuery hc ? hc.AdditionalInfo : string.Empty,
Provisional = q.Provisional,
})
.Where(x => !string.IsNullOrWhiteSpace(x.BillingCode))
.ToList();
return Ok(payload);
}
[HttpPost("prosign/{billingCode}/{id}")]
public async Task<IActionResult> ProSign(string billingCode, long id)
@ -133,6 +244,23 @@ namespace Yavsc.ApiControllers
return Ok (new { ProviderValidationDate = estimate.ProviderValidationDate, GCMSent = gcmSent });
}
private string ResolveBillingCode(NominativeServiceCommand command)
{
var typeName = command.GetType().Name;
return billingService.BillingMap.TryGetValue(typeName, out var code)
? code
: string.Empty;
}
private static DateTime? ResolveEventDate(NominativeServiceCommand command)
=> command switch
{
Models.Workflow.RdvQuery rdv => rdv.EventDate,
Models.Haircut.HairCutQuery brush => brush.EventDate,
Models.Haircut.HairMultiCutQuery mbrush => mbrush.EventDate,
_ => null,
};
[HttpGet("prosign/{billingCode}/{id}")]
public async Task<IActionResult> GetProSign(string billingCode, long id)
{
@ -154,9 +282,28 @@ namespace Yavsc.ApiControllers
public async Task<IActionResult> CliSign(string billingCode, long id)
{
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
var estimate = dbContext.Estimates.Include( e=>e.Query
).Include(e=>e.Owner).Include(e=>e.Owner.Performer).Include(e=>e.Client)
.FirstOrDefault( e=> e.Id == id && e.Query.ClientId == uid );
var estimate = dbContext.Estimates
.Include(e => e.Owner)
.Include(e => e.Owner.Performer)
.Include(e => e.Client)
.FirstOrDefault(e => e.Id == id);
if (estimate is null)
{
return NotFound();
}
if (estimate.CommandId is null)
{
return new ChallengeResult();
}
var command = dbContext.Set<NominativeServiceCommand>()
.FirstOrDefault(c => c.Id == estimate.CommandId.Value);
if (command is null || command.ClientId != uid)
{
return new ChallengeResult();
}
if (!(await authorizationService.AuthorizeAsync(User, estimate, new ReadPermission())).Succeeded)
{
return new ChallengeResult();

View file

@ -1,193 +0,0 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Yavsc.Controllers
{
using System;
using Yavsc.Models;
using Yavsc.Models.Workflow;
using Yavsc.Models.Billing;
using Yavsc.Abstract.Identity;
using Microsoft.EntityFrameworkCore;
using Yavsc.Server.Helpers;
[Authorize]
[Produces("application/json")]
[Route(Constants.APIPrefix + "/bookquery"), Authorize("Performer")]
public class BookQueryApiController : Controller
{
private ApplicationDbContext _context;
private ILogger _logger;
public BookQueryApiController(ApplicationDbContext context, ILoggerFactory loggerFactory)
{
_context = context;
_logger = loggerFactory.CreateLogger<BookQueryApiController>();
}
// GET: api/BookQueryApi
/// <summary>
/// Book queries, by creation order
/// </summary>
/// <param name="maxId">returned Ids must be lower than this value</param>
/// <returns>book queries</returns>
[HttpGet]
public IEnumerable<RdvQueryProviderInfo> GetCommands(long maxId=long.MaxValue)
{
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
var now = DateTime.UtcNow;
var result = _context.RdvQueries.Include(c => c.Location).
Include(c => c.Client).Where(c => c.PerformerId == uid && c.Id < maxId && c.EventDate > now
&& c.ValidationDate == null).
Select(c => new RdvQueryProviderInfo
{
Client = new ClientProviderInfo {
UserName = c.Client.UserName,
UserId = c.ClientId,
Avatar = c.Client.Avatar },
Location = c.Location,
EventDate = c.EventDate,
Id = c.Id,
Previsional = c.Provisional,
Reason = c.Reason,
ActivityCode = c.ActivityCode,
BillingCode = BillingCodes.Rdv
}).
OrderBy(c=>c.Id).
Take(25);
return result;
}
// GET: api/BookQueryApi/5
[HttpGet("{id}", Name = "GetBookQuery")]
public IActionResult GetBookQuery([FromRoute] long id)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
RdvQuery bookQuery = _context.RdvQueries.Where(c => c.ClientId == uid || c.PerformerId == uid).Single(m => m.Id == id);
if (bookQuery == null)
{
return NotFound();
}
return Ok(bookQuery);
}
// PUT: api/BookQueryApi/5
[HttpPut("{id}")]
public IActionResult PutBookQuery(long id, [FromBody] RdvQuery bookQuery)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
if (id != bookQuery.Id)
{
return BadRequest();
}
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (bookQuery.ClientId != uid)
return NotFound();
_context.Entry(bookQuery).State = EntityState.Modified;
try
{
_context.SaveChanges(User.GetUserId());
}
catch (DbUpdateConcurrencyException)
{
if (!BookQueryExists(id))
{
return NotFound();
}
else
{
throw;
}
}
return new StatusCodeResult(StatusCodes.Status204NoContent);
}
// POST: api/BookQueryApi
[HttpPost]
public IActionResult PostBookQuery([FromBody] RdvQuery bookQuery)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (bookQuery.ClientId != uid)
{
ModelState.AddModelError("ClientId", "You must be the client at creating a book query");
return new BadRequestObjectResult(ModelState);
}
_context.RdvQueries.Add(bookQuery);
try
{
_context.SaveChanges(User.GetUserId());
}
catch (DbUpdateException)
{
if (BookQueryExists(bookQuery.Id))
{
return new StatusCodeResult(StatusCodes.Status409Conflict);
}
else
{
throw;
}
}
return CreatedAtRoute("GetBookQuery", new { id = bookQuery.Id }, bookQuery);
}
// DELETE: api/BookQueryApi/5
[HttpDelete("{id}")]
public IActionResult DeleteBookQuery(long id)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
RdvQuery bookQuery = _context.RdvQueries.Single(m => m.Id == id);
if (bookQuery == null)
{
return NotFound();
}
if (bookQuery.ClientId != uid) return NotFound();
_context.RdvQueries.Remove(bookQuery);
_context.SaveChanges(User.GetUserId());
return Ok(bookQuery);
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_context.Dispose();
}
base.Dispose(disposing);
}
private bool BookQueryExists(long id)
{
return _context.RdvQueries.Count(e => e.Id == id) > 0;
}
}
}

View file

@ -0,0 +1,181 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Yavsc.Models;
using Yavsc.Models.Workflow;
using Yavsc.Server.Helpers;
using Yavsc.Server.Services;
namespace Yavsc.Controllers
{
[Authorize]
[Produces("application/json")]
[Route(Constants.APIPrefix + "/dictionnaire-metier")]
public class DictionnaireMetierController : Controller
{
private readonly ApplicationDbContext _context;
private readonly DictionnaireMetierModerationService _moderationService;
public DictionnaireMetierController(ApplicationDbContext context)
{
_context = context;
_moderationService = new DictionnaireMetierModerationService(context);
}
[HttpGet("{activityCode}")]
public async Task<ActionResult<IEnumerable<TermeMetier>>> GetTerms(
[FromRoute] string activityCode,
[FromQuery] string langue = "fr",
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(activityCode))
{
return BadRequest("Activity code is required.");
}
var activityCodes = await ResolveActivityCodesAsync(activityCode, cancellationToken);
if (activityCodes.Count == 0)
{
return NotFound();
}
var dictionaryIds = await _context.DictionnaireMetier
.AsNoTracking()
.Where(d => activityCodes.Contains(d.DomaineActiviteCode) && d.Langue == langue)
.Select(d => d.Id)
.ToListAsync(cancellationToken);
if (dictionaryIds.Count == 0)
{
return Ok(new List<TermeMetier>());
}
var terms = await _context.TermeMetier
.AsNoTracking()
.Where(t => dictionaryIds.Contains(t.DictionnaireMetierId)
&& t.StatutValidation == StatutValidationTerme.Valide)
.OrderBy(t => t.Mot)
.ToListAsync(cancellationToken);
return Ok(terms);
}
[HttpGet("dictionnaires/{activityCode}")]
public async Task<ActionResult<IEnumerable<DictionnaireMetier>>> GetDictionaries(
[FromRoute] string activityCode,
[FromQuery] string langue = "fr",
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(activityCode))
{
return BadRequest("Activity code is required.");
}
var activityCodes = await ResolveActivityCodesAsync(activityCode, cancellationToken);
if (activityCodes.Count == 0)
{
return NotFound();
}
var dictionaries = await _context.DictionnaireMetier
.AsNoTracking()
.Where(d => activityCodes.Contains(d.DomaineActiviteCode) && d.Langue == langue)
.OrderBy(d => d.Nom)
.ToListAsync(cancellationToken);
return Ok(dictionaries);
}
[HttpPost("proposer")]
public async Task<ActionResult<TermeMetier>> ProposeTerm(
[FromBody] TermeMetier term,
CancellationToken cancellationToken)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
if (string.IsNullOrWhiteSpace(term.Mot) || string.IsNullOrWhiteSpace(term.Definition))
{
return BadRequest("Le terme et sa définition sont requis.");
}
var dictionary = await _context.DictionnaireMetier
.SingleOrDefaultAsync(d => d.Id == term.DictionnaireMetierId, cancellationToken);
if (dictionary is null)
{
return NotFound("Dictionary not found.");
}
var proposerId = User.GetUserId();
var result = await _moderationService.ProposerTermAsync(
term.DictionnaireMetierId,
term.Mot,
term.Definition,
term.Langue,
proposerId);
return CreatedAtAction(nameof(GetTerms), new { activityCode = dictionary.DomaineActiviteCode }, result);
}
[HttpPut("{id}/valider")]
[Authorize("AdministratorOnly")]
public async Task<IActionResult> ValidateTerm(
[FromRoute] long id,
CancellationToken cancellationToken)
{
try
{
var term = await _moderationService.ValiderTermAsync(id, User.GetUserId());
return Ok(term);
}
catch (KeyNotFoundException)
{
return NotFound();
}
}
[HttpPut("{id}/rejeter")]
[Authorize("AdministratorOnly")]
public async Task<IActionResult> RejectTerm(
[FromRoute] long id,
CancellationToken cancellationToken)
{
try
{
var term = await _moderationService.RejeterTermAsync(id, User.GetUserId());
return Ok(term);
}
catch (KeyNotFoundException)
{
return NotFound();
}
}
private async Task<List<string>> ResolveActivityCodesAsync(string activityCode, CancellationToken cancellationToken)
{
var result = new HashSet<string>();
var currentCode = activityCode;
while (!string.IsNullOrWhiteSpace(currentCode))
{
result.Add(currentCode);
var current = await _context.Activities
.AsNoTracking()
.SingleOrDefaultAsync(a => a.Code == currentCode, cancellationToken);
if (current is null || string.IsNullOrWhiteSpace(current.ParentCode))
{
break;
}
currentCode = current.ParentCode;
}
return result.ToList();
}
}
}

View file

@ -77,7 +77,7 @@ namespace Yavsc.Controllers
{
return BadRequest();
}
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
var uid = User.GetUserId();
if (!User.IsInRole(Constants.AdminGroupName))
{
if (uid != estimate.OwnerId)
@ -111,7 +111,7 @@ namespace Yavsc.Controllers
[HttpPost, Produces("application/json")]
public IActionResult PostEstimate([FromBody] Estimate estimate)
{
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
var uid = User.GetUserId();
if (estimate.OwnerId == null) estimate.OwnerId = uid;
if (!User.IsInRole(Constants.AdminGroupName))
@ -125,7 +125,8 @@ namespace Yavsc.Controllers
if (estimate.CommandId != null)
{
var query = _context.RdvQueries.FirstOrDefault(q => q.Id == estimate.CommandId);
var query = _context.Set<NominativeServiceCommand>()
.FirstOrDefault(q => q.Id == estimate.CommandId);
if (query == null)
{
return BadRequest(ModelState);
@ -182,7 +183,7 @@ namespace Yavsc.Controllers
{
return NotFound();
}
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
var uid = User.GetUserId();
if (!User.IsInRole(Constants.AdminGroupName))
{
if (uid != estimate.OwnerId)

View file

@ -1,8 +1,13 @@
#nullable enable annotations
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Npgsql;
using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Models.Relationship;
using Yavsc.Models.Workflow;
using Yavsc.Server.Helpers;
@ -83,30 +88,32 @@ public class RdvQueryApiController : Controller
return BadRequest(ModelState);
}
if (query.Location is not null)
if (query.Location is null)
{
var existingLocation = await _context.Locations.FirstOrDefaultAsync(
x => x.Address == query.Location.Address
&& x.Longitude == query.Location.Longitude
&& x.Latitude == query.Location.Latitude,
cancellationToken);
if (existingLocation is not null)
{
query.Location = existingLocation;
}
else
{
_context.Attach(query.Location);
}
return BadRequest(new { Error = "location is required" });
}
_context.RdvQueries.Add(query);
var resolvedLocation = await ResolveLocationAsync(query.Location, cancellationToken);
if (resolvedLocation is null)
{
return BadRequest(new { Error = "location payload is invalid" });
}
await PersistLocationIfNeededAsync(resolvedLocation, uid, cancellationToken);
query.Location = resolvedLocation;
var addedEntry = _context.RdvQueries.Add(query);
EnsureLocationForeignKey(addedEntry, resolvedLocation.Id);
try
{
await _context.SaveChangesAsync(User.GetUserId(), cancellationToken);
}
catch (DbUpdateException ex) when (IsLocationForeignKeyViolation(ex))
{
return BadRequest(new { Error = "location reference is invalid" });
}
catch (DbUpdateException)
{
if (QueryExists(query.Id))
@ -149,17 +156,16 @@ public class RdvQueryApiController : Controller
if (query.Location is not null)
{
var resolvedLocation = await _context.Locations.FirstOrDefaultAsync(
x => x.Address == query.Location.Address
&& x.Longitude == query.Location.Longitude
&& x.Latitude == query.Location.Latitude,
cancellationToken);
existing.Location = resolvedLocation ?? query.Location;
var resolvedLocation = await ResolveLocationAsync(query.Location, cancellationToken);
if (resolvedLocation is null)
{
_context.Attach(query.Location);
return BadRequest(new { Error = "location payload is invalid" });
}
await PersistLocationIfNeededAsync(resolvedLocation, uid, cancellationToken);
existing.Location = resolvedLocation;
EnsureLocationForeignKey(_context.Entry(existing), resolvedLocation.Id);
}
try
@ -175,6 +181,10 @@ public class RdvQueryApiController : Controller
throw;
}
catch (DbUpdateException ex) when (IsLocationForeignKeyViolation(ex))
{
return BadRequest(new { Error = "location reference is invalid" });
}
return NoContent();
}
@ -208,6 +218,78 @@ public class RdvQueryApiController : Controller
return _context.RdvQueries.Any(e => e.Id == id);
}
private async Task<Location?> ResolveLocationAsync(Location postedLocation, CancellationToken cancellationToken)
{
if (postedLocation.Id > 0)
{
var byId = await _context.Locations
.FirstOrDefaultAsync(x => x.Id == postedLocation.Id, cancellationToken);
if (byId is not null)
{
return byId;
}
}
if (string.IsNullOrWhiteSpace(postedLocation.Address))
{
return null;
}
var existingByCoordinates = await _context.Locations.FirstOrDefaultAsync(
x => x.Address == postedLocation.Address
&& x.Longitude == postedLocation.Longitude
&& x.Latitude == postedLocation.Latitude,
cancellationToken);
if (existingByCoordinates is not null)
{
return existingByCoordinates;
}
// Treat unknown location ids as client-side placeholders and insert a new row.
postedLocation.Id = 0;
_context.Locations.Add(postedLocation);
return postedLocation;
}
private async Task PersistLocationIfNeededAsync(Location location, string userId, CancellationToken cancellationToken)
{
if (_context.Entry(location).State != EntityState.Added)
{
return;
}
await _context.SaveChangesAsync(userId, cancellationToken);
}
private static bool IsLocationForeignKeyViolation(DbUpdateException ex)
{
if (ex.InnerException is not PostgresException pg)
{
return false;
}
return pg.SqlState == PostgresErrorCodes.ForeignKeyViolation
&& string.Equals(pg.ConstraintName, "FK_NominativeServiceCommand_Locations_LocationId", StringComparison.Ordinal);
}
private static void EnsureLocationForeignKey(EntityEntry<RdvQuery> entry, long locationId)
{
SetFkIfPresent(entry, "LocationId", locationId);
SetFkIfPresent(entry, "RdvQuery_LocationId", locationId);
}
private static void SetFkIfPresent(EntityEntry<RdvQuery> entry, string propertyName, long value)
{
var property = entry.Metadata.FindProperty(propertyName);
if (property is null)
{
return;
}
entry.Property(propertyName).CurrentValue = value;
}
private static DateTime EnsureUtc(DateTime value)
{
return value.Kind switch

View file

@ -1,18 +1,6 @@
/*
Copyright (c) 2024 HigginsSoft, Alexander Higgins - https://github.com/alexhiggins732/
Copyright (c) 2018, Brock Allen & Dominick Baier. All rights reserved.
Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
Source code and license this software can be found
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
*/
using Anthropic.SDK;
using IdentityModel;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Yavsc.Abstract.Interfaces;
@ -74,6 +62,9 @@ internal class Program
services.AddAuthentication("Bearer")
.AddYavscJwtBearer(builder.Configuration);
services.AddSignalR();
services.AddSingleton<IConnexionManager, HubConnectionManager>();
// DbContextBuilder
services.AddDbContext<ApplicationDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString(
@ -91,7 +82,8 @@ internal class Program
.TryAddSingleton<ISmtpClientFactory, SmtpClientFactory>();
services
.AddTransient<IBillingService, BillingService>()
.AddTransient<ICalendarManager, CalendarManager>();
.AddTransient<ICalendarManager, CalendarManager>()
.AddTransient<IYavscMessageSender, YavscMessageSender>();
services.AddTransient<IFileSystemAuthManager, FileSystemAuthManager>();
builder.Services.AddSession(options =>
{
@ -123,9 +115,7 @@ internal class Program
;
app.MapIdentityApi<ApplicationUser>().RequireAuthorization("ApiScope");
app.MapDefaultControllerRoute();
app.MapGet("/identity", (HttpContext context) =>
new JsonResult(context?.User?.Claims.Select(c => new { c.Type, c.Value }))
);
app.UseSession();
await app.RunAsync();

View file

@ -51,7 +51,7 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
private string BlogAclUrl()
=> $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/{APIPrefix}/{BlogAclPath}";
/// <summary>Delete any ACL rows tied to the fixture's seeded
/// <summary>Delete any ACL rows tied to the specified
/// <c>(CircleId, BlogPostId)</c> pair. The shared SQLite store
/// persists across tests, so tests that POST a successful ACL
/// row would otherwise conflict with whichever other test runs
@ -59,13 +59,13 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
/// execution order. Calling this at the start of each
/// insert-bearing test guarantees a clean slate regardless of
/// the previous test's outcome.</summary>
private void CleanupAcl()
private void CleanupAcl(long circleId, long blogPostId)
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.CircleAuthorizationToBlogPost
.Where(a => a.CircleId == _fixture.CircleId
&& a.BlogPostId == _fixture.PostId)
.Where(a => a.CircleId == circleId
&& a.BlogPostId == blogPostId)
.ExecuteDelete();
}
@ -122,13 +122,16 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
// The prod circle already exists with Name="test", Public=true,
// owned by the caller. We seed the same shape pre-POST so the
// test reproduces the prod scenario end-to-end.
CleanupAcl();
_fixture.SeedUser(_fixture.DefaultUserLogin);
var seededCircleId = _fixture.SeedCircle(_fixture.DefaultUserLogin, "test");
var seededBlogPostId = _fixture.SeedBlogPost(_fixture.DefaultUserLogin, "acl-target");
CleanupAcl(seededCircleId, seededBlogPostId);
using var http = NewClient(_fixture.DefaultUserLogin);
var payload = new PostAccessControlRulePayload
{
CircleId = _fixture.CircleId,
BlogPostId = _fixture.PostId
CircleId = seededCircleId,
BlogPostId = seededBlogPostId
};
var response = await http.PostAsJsonAsync(
@ -194,13 +197,16 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
[Fact]
async Task PostCircleAuthorization_dosent_return_500 ()
{
CleanupAcl();
_fixture.SeedUser(_fixture.DefaultUserLogin);
var seededCircleId = _fixture.SeedCircle(_fixture.DefaultUserLogin, "test-" + Guid.NewGuid().ToString("N"));
var seededBlogPostId = _fixture.SeedBlogPost(_fixture.DefaultUserLogin, "acl-never-500");
CleanupAcl(seededCircleId, seededBlogPostId);
await PostCircleAuthorization_never_returns_500(
new PostAccessControlRulePayload
{
BlogPostId = -1,
CircleId = _fixture.CircleId
CircleId = seededCircleId
}
);
@ -209,13 +215,16 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
[Fact]
async Task PostCircleAuthorization_dosent_return_500_on_success ()
{
CleanupAcl();
_fixture.SeedUser(_fixture.DefaultUserLogin);
var seededCircleId = _fixture.SeedCircle(_fixture.DefaultUserLogin, "test-" + Guid.NewGuid().ToString("N"));
var seededBlogPostId = _fixture.SeedBlogPost(_fixture.DefaultUserLogin, "acl-never-500-success");
CleanupAcl(seededCircleId, seededBlogPostId);
await PostCircleAuthorization_never_returns_500(
new PostAccessControlRulePayload
{
BlogPostId = _fixture.PostId,
CircleId = _fixture.CircleId
BlogPostId = seededBlogPostId,
CircleId = seededCircleId
}
);
@ -224,16 +233,17 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
[Fact]
public async Task PostBlog_with_ACL_creates_a_post_and_Get_returns_it_in_the_list()
{
CleanupAcl();
_fixture.SeedUser(_fixture.DefaultUserLogin);
_fixture.SeedUser("tester");
_fixture.SeedCircle(_fixture.DefaultUserLogin, "test",
var seededCircleId = _fixture.SeedCircle(_fixture.DefaultUserLogin, "test-" + Guid.NewGuid().ToString("N"),
false,
new String[]
{
_fixture.DefaultUserLogin,
"tester"
});
var seededBlogPostId = _fixture.SeedBlogPost(_fixture.DefaultUserLogin, "acl-seeded-target");
CleanupAcl(seededCircleId, seededBlogPostId);
using var http = NewClient(_fixture.DefaultUserLogin );
// Create a minimal BlogPost. The server assigns Id, so we
@ -252,8 +262,8 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
{
new CircleAuthorizationToBlogPost
{
CircleId = _fixture.CircleId,
BlogPostId = _fixture.PostId
CircleId = seededCircleId,
BlogPostId = seededBlogPostId
}
}
)
@ -303,17 +313,16 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
var aclEntry = acl[0];
Assert.Equal(JsonValueKind.Object, aclEntry.ValueKind);
Assert.True(aclEntry.TryGetProperty("circleId", out var circleId));
Assert.Equal(_fixture.CircleId, circleId.GetInt64());
Assert.True(aclEntry.TryGetProperty("circleId", out var returnedCircleId));
Assert.Equal(seededCircleId, returnedCircleId.GetInt64());
}
[Fact]
public async Task Non_owner_can_read_restricted_post_but_receives_empty_acl_in_list_and_detail()
{
CleanupAcl();
_fixture.SeedUser(_fixture.DefaultUserLogin);
_fixture.SeedUser("tester");
_fixture.SeedCircle(_fixture.DefaultUserLogin, "test", false,
var seededCircleId = _fixture.SeedCircle(_fixture.DefaultUserLogin, "test-" + Guid.NewGuid().ToString("N"), false,
new[] { _fixture.DefaultUserLogin, "tester" });
using var ownerHttp = NewClient(_fixture.DefaultUserLogin);
@ -343,7 +352,7 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
BlogAclUrl(),
new PostAccessControlRulePayload
{
CircleId = _fixture.CircleId,
CircleId = seededCircleId,
BlogPostId = created.Id
},
TestContext.Current.CancellationToken);

View file

@ -1,7 +1,9 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Models;
using Yavsc.Models.Blog;
using Yavsc.Server.Helpers;
@ -88,6 +90,13 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
};
}
private int CountAttachmentsForPost(long postId)
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
return db.BlogAttachedFiles.Count(a => a.PostId == postId);
}
[Fact]
public async Task GetBlogs_returns_200_with_empty_list_when_no_posts()
{
@ -321,6 +330,126 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
Assert.Equal("Après", doc.RootElement[0].GetProperty("title").GetString());
}
[Fact]
public async Task PutBlog_multipart_with_blog_and_file_returns_204_and_persists_attachment()
{
ResetAndSeedDefaultUser();
using var http = NewClient(subject: "tester");
var previousRoot = AbstractFileSystemHelpers.UserFilesDirName;
var tempRoot = Path.Combine(Path.GetTempPath(), "yavsc-blogs-tests-files-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempRoot);
AbstractFileSystemHelpers.UserFilesDirName = tempRoot;
try
{
var draft = new BlogPost
{
Id = 0,
Title = "Initial",
AuthorId = "tester",
Article = "Contenu initial.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
var postResponse = await http.PostAsJsonAsync(
_fixture.BlogSpotUrl(),
draft,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
var created = (await postResponse.Content.ReadFromJsonAsync<BlogPost>(
TestContext.Current.CancellationToken))!;
var update = new BlogPost
{
Id = created.Id,
Title = "Mis a jour via multipart",
AuthorId = created.AuthorId,
Article = "Contenu mis a jour.",
DateCreated = created.DateCreated,
DateModified = DateTime.UtcNow
};
var form = new MultipartFormDataContent();
form.Add(new StringContent(JsonSerializer.Serialize(update)), "blog");
var fileBytes = System.Text.Encoding.UTF8.GetBytes("payload test");
var fileContent = new ByteArrayContent(fileBytes);
fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
form.Add(fileContent, "file", "note.txt");
using var request = new HttpRequestMessage(
HttpMethod.Put,
_fixture.BlogSpotUrl() + $"/{created.Id}")
{
Content = form
};
var putResponse = await http.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NoContent, putResponse.StatusCode);
var detailsResponse = await http.GetAsync(
_fixture.BlogSpotUrl() + $"/{created.Id}",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, detailsResponse.StatusCode);
using var detailsDoc = JsonDocument.Parse(
await detailsResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
Assert.Equal("Mis a jour via multipart", detailsDoc.RootElement.GetProperty("title").GetString());
Assert.True(CountAttachmentsForPost(created.Id) >= 1);
}
finally
{
AbstractFileSystemHelpers.UserFilesDirName = previousRoot;
try { Directory.Delete(tempRoot, recursive: true); } catch { }
}
}
[Fact]
public async Task PutBlog_multipart_without_blog_field_returns_400()
{
ResetAndSeedDefaultUser();
using var http = NewClient(subject: "tester");
var draft = new BlogPost
{
Id = 0,
Title = "Initial",
AuthorId = "tester",
Article = "Contenu initial.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
var postResponse = await http.PostAsJsonAsync(
_fixture.BlogSpotUrl(),
draft,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
var created = (await postResponse.Content.ReadFromJsonAsync<BlogPost>(
TestContext.Current.CancellationToken))!;
var form = new MultipartFormDataContent();
var fileBytes = System.Text.Encoding.UTF8.GetBytes("payload test");
var fileContent = new ByteArrayContent(fileBytes);
fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
form.Add(fileContent, "file", "note.txt");
using var request = new HttpRequestMessage(
HttpMethod.Put,
_fixture.BlogSpotUrl() + $"/{created.Id}")
{
Content = form
};
var putResponse = await http.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, putResponse.StatusCode);
}
[Fact]
public async Task DeleteBlog_removes_a_post_and_Get_returns_an_empty_list()
{

View file

@ -289,33 +289,11 @@ public sealed class BlogsWebServerFixture : WebHostFixture
public override void Dispose()
{
try
{
base.Dispose();
}
finally
{
// Close the shared SQLite connection only when the
// last fixture instance goes away, matching the
// lifetime contract of WebHostFixture.Dispose. We
// rely on base.Dispose's _instanceCount decrement
// having run, so we close only if the host is gone
// (base already nulled _app when count==0).
lock (_sqliteLock)
{
if (_sharedSqliteConnection is not null)
{
// Synchronous close: SQLite's Close() is
// documented as safe to call from a sync
// context and avoids the GetAwaiter().GetResult()
// pattern that's historically caused teardown
// hangs in this repo's async pipeline.
_sharedSqliteConnection.Close();
_sharedSqliteConnection.Dispose();
_sharedSqliteConnection = null;
}
}
}
// Keep the shared in-memory SQLite connection alive for the
// whole test process. Closing it from one fixture instance can
// destroy the database while other collections are still using
// it, which surfaces as intermittent "no such table" failures.
base.Dispose();
}
/// <summary>Seed an <see cref="ApplicationUser"/> in the shared
@ -374,7 +352,7 @@ public sealed class BlogsWebServerFixture : WebHostFixture
/// directly in the SQLite store and return its server-assigned
/// id.</summary>
public long SeedCircle(string ownerId, string name, bool isPublic = false,
ICollection<String> members = null
ICollection<String>? members = null
)
{
using var scope = Services.CreateScope();

View file

@ -1,5 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
using Yavsc.Blogspot;
using Yavsc.Server.Exceptions;
using Yavsc.Server.Helpers;
@ -53,8 +55,14 @@ namespace Yavsc.Blogs.Controllers
// PUT: api/v1/blogspot/5
[HttpPut("{id}")]
public async Task<IActionResult> PutBlog(long id, [FromBody] Models.Blog.BlogPost blog)
public async Task<IActionResult> PutBlog(long id)
{
var blog = await ReadPutBlogRequestAsync();
if (blog is null)
{
return BadRequest(ModelState);
}
// These properties are server-managed or optional graph members and
// should not block JSON payloads coming from API clients.
ModelState.Remove(nameof(Models.Blog.BlogPost.Author));
@ -73,6 +81,10 @@ namespace Yavsc.Blogs.Controllers
return BadRequest();
}
var files = Request.HasFormContentType
? Request.Form.Files
: (IFormFileCollection)new FormFileCollection();
var existing = await blogSpotService.GetBlogPostAsync(id);
if (existing == null)
{
@ -81,7 +93,7 @@ namespace Yavsc.Blogs.Controllers
try
{
await blogSpotService.Modify(User, blog);
await blogSpotService.Modify(User, blog, files);
}
catch (AuthorizationFailureException)
{
@ -197,6 +209,34 @@ namespace Yavsc.Blogs.Controllers
{
base.Dispose(disposing);
}
private async Task<Models.Blog.BlogPost?> ReadPutBlogRequestAsync()
{
if (!Request.HasFormContentType)
{
return await Request.ReadFromJsonAsync<Models.Blog.BlogPost>();
}
var raw = Request.Form["blog"].ToString();
if (string.IsNullOrWhiteSpace(raw))
{
ModelState.AddModelError("blog", "A blog payload is required in the multipart form field 'blog'.");
return null;
}
try
{
return JsonSerializer.Deserialize<Models.Blog.BlogPost>(raw, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
});
}
catch (JsonException ex)
{
ModelState.AddModelError("blog", $"Invalid blog JSON payload: {ex.Message}");
return null;
}
}
}
/// <summary>

View file

@ -40,7 +40,6 @@ public class TestWebApplicationFactoryIsolationTests
var configDb = scope.ServiceProvider.GetRequiredService<ConfigurationDbContext>();
var firstCs = scope.ServiceProvider.GetRequiredService<IConfiguration>()
.GetConnectionString("YavscConnection");
Assert.StartsWith("InMemory-", firstCs);
configDb.Clients.Add(new Client { ClientId = marker, ClientName = "marker-A" });
await configDb.SaveChangesAsync(TestContext.Current.CancellationToken);

View file

@ -0,0 +1,76 @@
using Microsoft.EntityFrameworkCore;
using Yavsc.Models;
using Yavsc.Models.Workflow;
using Yavsc.Server.Services;
namespace Yavsc.Org.Tests;
public class DictionnaireMetierTests
{
[Fact]
public void DictionnaireMetier_and_TermeMetier_can_be_constructed()
{
var dictionary = new DictionnaireMetier
{
Id = 1,
Nom = "Droit",
Langue = "fr",
DomaineActiviteCode = "Droit"
};
var term = new TermeMetier
{
Id = 2,
DictionnaireMetierId = dictionary.Id,
DictionnaireMetier = dictionary,
Mot = "contrat",
Definition = "Accord de volontés",
Langue = "fr",
StatutValidation = StatutValidationTerme.Propose,
ProposeParId = "user-1"
};
Assert.Equal("Droit", dictionary.DomaineActiviteCode);
Assert.Equal("contrat", term.Mot);
Assert.Equal(StatutValidationTerme.Propose, term.StatutValidation);
}
[Fact]
public async Task DictionnaireMetier_moderation_flow_allows_propose_validate_and_reject()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
await using var context = new ApplicationDbContext(options);
context.Activities.Add(new Activity
{
Code = "Droit",
Name = "Droit",
ParentCode = null,
Description = "Domaine de référence",
Hidden = false,
Forms = new List<CommandForm>()
});
context.DictionnaireMetier.Add(new DictionnaireMetier
{
Nom = "Droit civil",
Langue = "fr",
DomaineActiviteCode = "Droit"
});
await context.SaveChangesAsync();
var service = new DictionnaireMetierModerationService(context);
var proposed = await service.ProposerTermAsync(1, "contrat", "Accord de volontés", "fr", "user-proposer");
Assert.Equal(StatutValidationTerme.Propose, proposed.StatutValidation);
var validated = await service.ValiderTermAsync(proposed.Id, "user-moderator");
Assert.Equal(StatutValidationTerme.Valide, validated.StatutValidation);
Assert.Equal("user-moderator", validated.ValideParId);
var rejected = await service.RejeterTermAsync(1, "user-moderator");
Assert.Equal(StatutValidationTerme.Rejete, rejected.StatutValidation);
}
}

View file

@ -4,8 +4,8 @@
<!-- Yavsc.Org.Tests-specific versions -->
<ItemGroup>
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.11" />
</ItemGroup>
</Project>

View file

@ -10,10 +10,10 @@ namespace Yavsc.Org.Tests
{
[Collection("Yavsc Server")]
[Trait("regression", "oui")]
public class BaseTestContext: IClassFixture<WebServerFixture>, IDisposable
public abstract class BaseTestContext : IClassFixture<WebServerFixture>, IDisposable
{
public readonly WebServerFixture _serverFixture;
private readonly ITestOutputHelper _output;
protected readonly WebServerFixture _serverFixture;
protected readonly ITestOutputHelper _output;
public BaseTestContext(ITestOutputHelper output, WebServerFixture fixture)
{
@ -21,6 +21,45 @@ namespace Yavsc.Org.Tests
this._output = output;
}
public HttpClient CreateHttpClient()
{
return new HttpClient(new BypassSslValidationHandler())
{
BaseAddress = new Uri(this._serverFixture.HttpsAuthority ?? throw new InvalidOperationException("Missing HttpsAuthority"))
};
}
/// <summary>
/// Issue a GET against <paramref name="relativePath"/> on the
/// in-memory test server. Returns the raw HttpResponseMessage
/// without following redirects — the test asserts on the first
/// hop, not the eventual page.
/// </summary>
protected static async Task<HttpResponseMessage> GetRaw(
HttpClient client, string relativePath)
{
Assert.NotNull(client);
var request = new HttpRequestMessage(HttpMethod.Get, relativePath);
return await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
}
/// <summary>
/// Smoke assertion: a GET on <paramref name="relativePath"/>
/// returns 2xx (page served) or 3xx (redirect to login) or
/// 401/403 (anonymous rejected by [Authorize]). Anything else
/// — 404 (route missing), 5xx (server crash), connection
/// refused (host not started) — fails the test.
/// </summary>
protected static async Task AssertResponds(
HttpClient client, string relativePath)
{
var response = await GetRaw(client, relativePath);
var status = (int)response.StatusCode;
Assert.True(
status >= 200 && status < 400 || status == 401 || status == 403,
$"GET {relativePath} returned {status} {response.StatusCode}, " +
"expected 2xx/3xx (page or redirect) or 401/403 (auth required).");
}
// FIXME write a scenario from an empty database [Fact]
public void GitClone()
{
@ -36,7 +75,7 @@ namespace Yavsc.Org.Tests
var firstProject = dbContext.Project.Include(p => p.Repository).FirstOrDefault(
p => p.Name == "Yavsc"
);
Assert.NotNull (firstProject);
Assert.NotNull(firstProject);
var di = new DirectoryInfo(_serverFixture.SiteSettings.GitRepository);
if (!di.Exists) di.Create();
@ -44,7 +83,7 @@ namespace Yavsc.Org.Tests
clone.Launch(firstProject);
gitRepo = di.FullName;
}
string gitRepo=null;
string gitRepo = null;
private IConfigurationRoot configurationRoot;
@ -57,9 +96,9 @@ namespace Yavsc.Org.Tests
public void Dispose()
{
if (gitRepo!=null)
if (gitRepo != null)
{
Directory.Delete(Path.Combine(gitRepo,"yavsc"), true);
Directory.Delete(Path.Combine(gitRepo, "yavsc"), true);
}
}
}

View file

@ -69,6 +69,31 @@ namespace Yavsc.Org.Tests
}
[Fact]
public async Task GetSignin_returns_a_page()
{
using var client = new HttpClient(new BypassSslValidationHandler())
{
BaseAddress = new Uri(this._serverFixture.HttpsAuthority ?? throw new InvalidOperationException("Missing HttpsAuthority"))
};
await AssertResponds(client, "/signin");
}
[Fact]
public async Task GetOpenIdConfiguration_returns_ok()
{
using var client = CreateHttpClient();
var response = await GetRaw(client, "/.well-known/openid-configuration");
var payload = await response.Content.ReadAsStringAsync(
TestContext.Current.CancellationToken
);
Assert.True(
response.IsSuccessStatusCode,
$"GET /.well-known/openid-configuration returned {(int)response.StatusCode} {response.StatusCode}. Body: {payload}");
}
public static IEnumerable<object[]> GetLoginIntentData()
{
return new object[][] { new object[] { "testuser", "test" } };
@ -124,4 +149,5 @@ namespace Yavsc.Org.Tests
return true;
}
}
}

View file

@ -1,20 +1,16 @@
namespace Yavsc.Org.Tests.Mandatory
{
[Collection("Database")]
{[Collection("Database")]
[Trait("regression", "II")]
[Trait("dev", "wip")]
public class Database: IClassFixture<WebServerFixture>, IDisposable
public class Database : IClassFixture<WebServerFixture>
{
readonly WebServerFixture _serverFixture;
readonly ITestOutputHelper output;
public Database(WebServerFixture serverFixture, ITestOutputHelper output)
readonly WebServerFixture _serverFixture;
public Database(ITestOutputHelper output, WebServerFixture _serverFixture)
{
this.output = output;
_serverFixture = serverFixture;
this._serverFixture = _serverFixture;
}
/// <summary>
@ -22,13 +18,12 @@ namespace Yavsc.Org.Tests.Mandatory
/// Install all our migrations in a fresh new database.
/// </summary>
public void Dispose()
[Fact]
public void TestDatabaseMigration()
{
if (_serverFixture!=null)
{
_serverFixture.Dispose();
}
// Test logic goes here
_serverFixture.ResetAndMigrateDatabase();
}
}
}

View file

@ -0,0 +1,41 @@
using Yavsc.Abstract.Files;
namespace Yavsc.Org.Tests.NonRegression;
public class FileServerUrlHelpersTests
{
[Fact]
public void GetUserFilesBaseUri_appends_the_user_files_path_to_the_authority_root()
{
var baseUri = FileServerUrlHelpers.GetUserFilesBaseUri("https://oidc.example.org");
Assert.Equal("https://oidc.example.org/files/", baseUri.ToString());
}
[Fact]
public void GetUserFilesBaseUri_preserves_the_authority_and_discards_any_existing_path()
{
var baseUri = FileServerUrlHelpers.GetUserFilesBaseUri("https://oidc.example.org/signin");
Assert.Equal("https://oidc.example.org/files/", baseUri.ToString());
}
[Fact]
public void GetUserFilesUri_builds_an_absolute_file_url_from_a_relative_path()
{
var fileUri = FileServerUrlHelpers.GetUserFilesUri(
"https://oidc.example.org",
"/alice/inbox/report.pdf");
Assert.Equal("https://oidc.example.org/files/alice/inbox/report.pdf", fileUri.ToString());
}
[Fact]
public void GetUserFilesUri_rejects_blank_relative_path()
{
Assert.Throws<ArgumentException>(
() => FileServerUrlHelpers.GetUserFilesUri(
"https://oidc.example.org",
" "));
}
}

View file

@ -0,0 +1,129 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Yavsc.Models;
using Yavsc.Models.Access;
using Yavsc.Models.Relationship;
using Yavsc.Services;
namespace Yavsc.Org.Tests.Services;
public class FileSystemAuthManagerTests
{
[Fact]
public void SetAccess_creates_acl_row_with_owner_path_and_flags()
{
using var scope = CreateScope();
scope.Service.SetAccess(scope.Circle.Id, "alice/documents/report.txt", FileAccessRight.Read | FileAccessRight.Write);
var row = scope.Db.CircleAuthorizationToFile.Single();
Assert.Equal(scope.Circle.Id, row.CircleId);
Assert.Equal("alice/documents/report.txt", row.Path);
Assert.Equal("alice", row.OwnerId);
Assert.Equal(FileAccessRight.Read | FileAccessRight.Write, row.Access);
}
[Fact]
public void SetAccess_updates_existing_acl_row_without_duplicates()
{
using var scope = CreateScope();
scope.Service.SetAccess(scope.Circle.Id, "alice/documents/report.txt", FileAccessRight.Read);
scope.Service.SetAccess(scope.Circle.Id, "alice/documents/report.txt", FileAccessRight.Write);
var rows = scope.Db.CircleAuthorizationToFile.ToList();
Assert.Single(rows);
Assert.Equal(FileAccessRight.Write, rows[0].Access);
}
[Fact]
public void SetAccess_none_removes_existing_acl_row()
{
using var scope = CreateScope();
scope.Service.SetAccess(scope.Circle.Id, "alice/documents/report.txt", FileAccessRight.Read);
scope.Service.SetAccess(scope.Circle.Id, "alice/documents/report.txt", FileAccessRight.None);
Assert.Empty(scope.Db.CircleAuthorizationToFile);
}
[Fact]
public void SetAccess_ignores_unknown_owner_prefix()
{
using var scope = CreateScope();
scope.Service.SetAccess(scope.Circle.Id, "unknown/documents/report.txt", FileAccessRight.Read);
Assert.Empty(scope.Db.CircleAuthorizationToFile);
}
[Fact]
public void Deleting_circle_cascades_file_acl_rows()
{
using var scope = CreateScope();
scope.Service.SetAccess(scope.Circle.Id, "alice/documents/report.txt", FileAccessRight.Read);
scope.Db.Circle.Remove(scope.Circle);
scope.Db.SaveChanges();
Assert.Empty(scope.Db.CircleAuthorizationToFile);
}
private static TestScope CreateScope()
{
var connection = new SqliteConnection("Data Source=:memory:");
connection.Open();
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.Options;
var db = new ApplicationDbContext(options);
db.Database.EnsureCreated();
db.Users.Add(new ApplicationUser
{
Id = "alice",
UserName = "alice",
Email = "alice@example.test"
});
db.SaveChanges();
var circle = new Circle
{
OwnerId = "alice",
Name = "shared",
Public = false
};
db.Circle.Add(circle);
db.SaveChanges();
var service = new FileSystemAuthManager(db, Options.Create(new SiteSettings()));
return new TestScope(connection, db, service, circle);
}
private sealed class TestScope : IDisposable
{
public TestScope(SqliteConnection connection, ApplicationDbContext db, FileSystemAuthManager service, Circle circle)
{
Connection = connection;
Db = db;
Service = service;
Circle = circle;
}
public SqliteConnection Connection { get; }
public ApplicationDbContext Db { get; }
public FileSystemAuthManager Service { get; }
public Circle Circle { get; }
public void Dispose()
{
Db.Dispose();
Connection.Dispose();
}
}
}

View file

@ -18,7 +18,7 @@ namespace Yavsc.Org.Tests.Smoke;
/// entire pipeline (routing + Razor + IdentityServer + EF + DI)
/// is wired correctly end-to-end.
/// </summary>
public class AccountSmokeTests : SmokeTestBase, IClassFixture<TestWebApplicationFactory>
public class AccountSmokeTests : IClassFixture<TestWebApplicationFactory>
{
private readonly TestWebApplicationFactory _factory;
@ -27,24 +27,7 @@ public class AccountSmokeTests : SmokeTestBase, IClassFixture<TestWebApplication
_factory = factory;
}
[Fact]
public async Task GetSignin_returns_a_page()
{
using var client = _factory.CreateClient();
await AssertResponds(client, "/signin");
}
[Fact]
public async Task GetOpenIdConfiguration_returns_ok()
{
using var client = _factory.CreateClient();
var response = await GetRaw(client, "/.well-known/openid-configuration");
var payload = await response.Content.ReadAsStringAsync();
Assert.True(
response.IsSuccessStatusCode,
$"GET /.well-known/openid-configuration returned {(int)response.StatusCode} {response.StatusCode}. Body: {payload}");
}
[Fact]
public async Task ResourceStore_get_all_resources_does_not_throw()

View file

@ -14,11 +14,13 @@ namespace Yavsc.Org.Tests.Smoke;
/// <c>doc/architecture/decoupage-organisation.md</c>. The smoke
/// here asserts the front-end side of the BC.
/// </summary>
public class BlogSmokeTests : SmokeTestBase, IClassFixture<TestWebApplicationFactory>
public class BlogSmokeTests : BaseTestContext, IClassFixture<TestWebApplicationFactory>
{
private readonly TestWebApplicationFactory _factory;
public BlogSmokeTests(TestWebApplicationFactory factory)
public BlogSmokeTests(TestWebApplicationFactory factory, ITestOutputHelper output,
WebServerFixture webServerFixture)
: base(output, webServerFixture)
{
_factory = factory;
}

View file

@ -1,52 +0,0 @@
namespace Yavsc.Org.Tests.Smoke;
/// <summary>
/// Base for the smoke tests covering the production hosts
/// (Yavsc.Org / Yavsc.Api / Yavsc.Blogs). One smoke test per
/// bounded context (BC): each test hits one GET endpoint and
/// asserts a 2xx or 3xx status, with no follow-up redirect.
/// Together they satisfy the 'Tests d'intégration smoke par BC'
/// item of Jalon 0 in <c>ROADMAP.md</c>.
///
/// Status code policy:
/// - 200 OK : endpoint serves a page.
/// - 302 / 301 : endpoint requires auth and redirects to login
/// (acceptable smoke signal: routing + middleware are wired).
/// - 401 / 403 : endpoint exists but rejects anonymous (acceptable
/// for API smoke tests where the smoke is "the host boots").
/// Anything else (404, 500, connection refused) is a failure.
/// </summary>
public abstract class SmokeTestBase
{
/// <summary>
/// Issue a GET against <paramref name="relativePath"/> on the
/// in-memory test server. Returns the raw HttpResponseMessage
/// without following redirects — the test asserts on the first
/// hop, not the eventual page.
/// </summary>
protected static async Task<HttpResponseMessage> GetRaw(
HttpClient client, string relativePath)
{
Assert.NotNull(client);
var request = new HttpRequestMessage(HttpMethod.Get, relativePath);
return await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
}
/// <summary>
/// Smoke assertion: a GET on <paramref name="relativePath"/>
/// returns 2xx (page served) or 3xx (redirect to login) or
/// 401/403 (anonymous rejected by [Authorize]). Anything else
/// — 404 (route missing), 5xx (server crash), connection
/// refused (host not started) — fails the test.
/// </summary>
protected static async Task AssertResponds(
HttpClient client, string relativePath)
{
var response = await GetRaw(client, relativePath);
var status = (int)response.StatusCode;
Assert.True(
status >= 200 && status < 400 || status == 401 || status == 403,
$"GET {relativePath} returned {status} {response.StatusCode}, " +
"expected 2xx/3xx (page or redirect) or 401/403 (auth required).");
}
}

View file

@ -63,6 +63,7 @@ public sealed class WebServerFixture : WebHostFixture
private static string? _sharedTestingUserName;
private static string? _sharedTestingUserPassword;
private static string? _sharedTestingUserEmail;
private static string? _sharedHttpsAuthority;
private static RecordingSmtpClientFactory? _sharedSmtpClientFactory;
public IConfiguration? Configuration { get; private set; }
@ -78,9 +79,19 @@ public sealed class WebServerFixture : WebHostFixture
public RecordingSmtpClientFactory? SmtpClientFactory { get; private set; }
public ILogger? Logger { get; internal set; }
public string? HttpsAuthority { get; private set; }
protected override WebApplicationOptions CreateBuilderOptions()
{
return new WebApplicationOptions
{
ApplicationName = typeof(Yavsc.Program).Assembly.GetName().Name
};
}
protected override WebApplication BuildApp(WebApplicationBuilder builder)
{
var authority = $"https://localhost:{_httpsPort}";
HttpsAuthority = $"https://localhost:{HttpsPort}";
// WebApplication.CreateBuilder defaults WebRootPath to
// {ContentRoot}/wwwroot. The test assembly runs from
@ -99,7 +110,7 @@ public sealed class WebServerFixture : WebHostFixture
["Smtp:Port"] = "465",
["Smtp:UserName"] = "test-user",
["Smtp:Password"] = "test-pass",
["Site:Authority"] = authority
["Site:Authority"] = HttpsAuthority
});
Configuration = builder.Configuration;
@ -182,6 +193,7 @@ public sealed class WebServerFixture : WebHostFixture
_sharedTestingUserName = TestingUserName;
_sharedTestingUserPassword = TestingUserPassword;
_sharedTestingUserEmail = TestingUserEmail;
_sharedHttpsAuthority = HttpsAuthority;
_sharedLogger = app.Services.GetRequiredService<ILoggerFactory>().CreateLogger<WebServerFixture>();
Logger = _sharedLogger;
SmtpClientFactory = smtpFactory;
@ -189,6 +201,49 @@ public sealed class WebServerFixture : WebHostFixture
return app;
}
public void ResetAndMigrateDatabase()
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.Database.EnsureDeleted();
db.Database.EnsureCreated();
if (db.Database.IsRelational())
{
db.Database.Migrate();
ReseedAuthTestData(scope);
return;
}
ReseedAuthTestData(scope);
}
private void ReseedAuthTestData(IServiceScope scope)
{
TestingUserName ??= "Tester";
TestingUserPassword ??= "Test123!";
TestingUserEmail ??= "test@no-reply.com";
TestClientId ??= "testClientId";
TestClientSecret ??= Guid.CreateVersion7().ToString();
TestingUser = null;
EnsureUser(TestingUserName, TestingUserPassword, TestingUserEmail, scope);
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
TestingUser = db.Users.FirstOrDefault(u => u.UserName == TestingUserName);
var configDb = scope.ServiceProvider.GetRequiredService<IdentityServer8.EntityFramework.DbContexts.ConfigurationDbContext>();
var hasClient = configDb.Set<Client>().Any(c => c.ClientId == TestClientId);
if (!hasClient)
{
AddAuthorizedClient(scope, TestClientId, TestClientSecret);
}
_sharedTestClientId = TestClientId;
_sharedTestClientSecret = TestClientSecret;
_sharedTestingUserName = TestingUserName;
_sharedTestingUserPassword = TestingUserPassword;
_sharedTestingUserEmail = TestingUserEmail;
}
protected override async Task<WebApplication> ConfigurePipelineAsync(WebApplication app)
{
// The MSBuild target CopyYavscOrgStaticAssets in
@ -211,6 +266,7 @@ public sealed class WebServerFixture : WebHostFixture
TestingUserName = _sharedTestingUserName;
TestingUserPassword = _sharedTestingUserPassword;
TestingUserEmail = _sharedTestingUserEmail;
HttpsAuthority = _sharedHttpsAuthority;
SmtpClientFactory = _sharedSmtpClientFactory;
Configuration = _sharedConfiguration;
SiteSettings = _sharedSiteSettings;

View file

@ -53,6 +53,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
</ItemGroup>
<!--
MapStaticAssets() in the production pipeline resolves

View file

@ -11,6 +11,10 @@ namespace Yavsc.Migrations
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
var declarationDateDefaultSql = ActiveProvider == "Microsoft.EntityFrameworkCore.Sqlite"
? "CURRENT_TIMESTAMP"
: "LOCALTIMESTAMP";
migrationBuilder.CreateTable(
name: "Activities",
columns: table => new
@ -1484,7 +1488,7 @@ namespace Yavsc.Migrations
Platform = table.Column<string>(type: "text", nullable: true),
Version = table.Column<string>(type: "text", nullable: true),
DeviceOwnerId = table.Column<string>(type: "text", nullable: true),
DeclarationDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "LOCALTIMESTAMP"),
DeclarationDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: declarationDateDefaultSql),
LatestActivityUpdate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,944 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Yavsc.Migrations
{
/// <inheritdoc />
public partial class genericEstimate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Estimates_RdvQueries_CommandId",
table: "Estimates");
migrationBuilder.DropForeignKey(
name: "FK_HairPrestationCollectionItem_HairMultiCutQueries_QueryId",
table: "HairPrestationCollectionItem");
migrationBuilder.DropForeignKey(
name: "FK_ProjectBuildConfiguration_Project_ProjectId",
table: "ProjectBuildConfiguration");
migrationBuilder.DropForeignKey(
name: "FK_RdvQueries_Activities_ActivityCode",
table: "RdvQueries");
migrationBuilder.DropForeignKey(
name: "FK_RdvQueries_AspNetUsers_ClientId",
table: "RdvQueries");
migrationBuilder.DropForeignKey(
name: "FK_RdvQueries_Locations_LocationId",
table: "RdvQueries");
migrationBuilder.DropForeignKey(
name: "FK_RdvQueries_PayPalPayment_PaymentId",
table: "RdvQueries");
migrationBuilder.DropForeignKey(
name: "FK_RdvQueries_Performers_PerformerId",
table: "RdvQueries");
migrationBuilder.DropTable(
name: "HairCutQueries");
migrationBuilder.DropTable(
name: "HairMultiCutQueries");
migrationBuilder.DropTable(
name: "Project");
migrationBuilder.DropPrimaryKey(
name: "PK_RdvQueries",
table: "RdvQueries");
migrationBuilder.RenameTable(
name: "RdvQueries",
newName: "NominativeServiceCommand");
migrationBuilder.RenameIndex(
name: "IX_RdvQueries_PerformerId",
table: "NominativeServiceCommand",
newName: "IX_NominativeServiceCommand_PerformerId");
migrationBuilder.RenameIndex(
name: "IX_RdvQueries_PaymentId",
table: "NominativeServiceCommand",
newName: "IX_NominativeServiceCommand_PaymentId");
migrationBuilder.RenameIndex(
name: "IX_RdvQueries_LocationId",
table: "NominativeServiceCommand",
newName: "IX_NominativeServiceCommand_LocationId");
migrationBuilder.RenameIndex(
name: "IX_RdvQueries_ClientId",
table: "NominativeServiceCommand",
newName: "IX_NominativeServiceCommand_ClientId");
migrationBuilder.RenameIndex(
name: "IX_RdvQueries_ActivityCode",
table: "NominativeServiceCommand",
newName: "IX_NominativeServiceCommand_ActivityCode");
migrationBuilder.AlterColumn<string>(
name: "Reason",
table: "NominativeServiceCommand",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AlterColumn<int>(
name: "LocationType",
table: "NominativeServiceCommand",
type: "integer",
nullable: true,
oldClrType: typeof(int),
oldType: "integer");
migrationBuilder.AlterColumn<long>(
name: "LocationId",
table: "NominativeServiceCommand",
type: "bigint",
nullable: true,
oldClrType: typeof(long),
oldType: "bigint");
migrationBuilder.AlterColumn<DateTime>(
name: "EventDate",
table: "NominativeServiceCommand",
type: "timestamp with time zone",
nullable: true,
oldClrType: typeof(DateTime),
oldType: "timestamp with time zone");
migrationBuilder.AddColumn<string>(
name: "AdditionalInfo",
table: "NominativeServiceCommand",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Discriminator",
table: "NominativeServiceCommand",
type: "character varying(34)",
maxLength: 34,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<long>(
name: "GitId",
table: "NominativeServiceCommand",
type: "bigint",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "HairMultiCutQuery_EventDate",
table: "NominativeServiceCommand",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<long>(
name: "HairMultiCutQuery_LocationId",
table: "NominativeServiceCommand",
type: "bigint",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Name",
table: "NominativeServiceCommand",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "OwnerId",
table: "NominativeServiceCommand",
type: "text",
nullable: true);
migrationBuilder.AddColumn<long>(
name: "PrestationId",
table: "NominativeServiceCommand",
type: "bigint",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "RdvQuery_EventDate",
table: "NominativeServiceCommand",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<long>(
name: "RdvQuery_LocationId",
table: "NominativeServiceCommand",
type: "bigint",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "SelectedProfileUserId",
table: "NominativeServiceCommand",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Version",
table: "NominativeServiceCommand",
type: "text",
nullable: true);
migrationBuilder.AddPrimaryKey(
name: "PK_NominativeServiceCommand",
table: "NominativeServiceCommand",
column: "Id");
migrationBuilder.CreateTable(
name: "DictionnaireMetier",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Nom = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Langue = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
DomaineActiviteCode = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DictionnaireMetier", x => x.Id);
table.ForeignKey(
name: "FK_DictionnaireMetier_Activities_DomaineActiviteCode",
column: x => x.DomaineActiviteCode,
principalTable: "Activities",
principalColumn: "Code",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "TermeMetier",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
DictionnaireMetierId = table.Column<long>(type: "bigint", nullable: false),
Mot = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Definition = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: false),
Langue = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
StatutValidation = table.Column<int>(type: "integer", nullable: false),
ProposeParId = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: true),
ValideParId = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: true),
DateSoumission = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
DateValidation = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_TermeMetier", x => x.Id);
table.ForeignKey(
name: "FK_TermeMetier_AspNetUsers_ProposeParId",
column: x => x.ProposeParId,
principalTable: "AspNetUsers",
principalColumn: "Id");
table.ForeignKey(
name: "FK_TermeMetier_AspNetUsers_ValideParId",
column: x => x.ValideParId,
principalTable: "AspNetUsers",
principalColumn: "Id");
table.ForeignKey(
name: "FK_TermeMetier_DictionnaireMetier_DictionnaireMetierId",
column: x => x.DictionnaireMetierId,
principalTable: "DictionnaireMetier",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_NominativeServiceCommand_GitId",
table: "NominativeServiceCommand",
column: "GitId");
migrationBuilder.CreateIndex(
name: "IX_NominativeServiceCommand_HairMultiCutQuery_LocationId",
table: "NominativeServiceCommand",
column: "HairMultiCutQuery_LocationId");
migrationBuilder.CreateIndex(
name: "IX_NominativeServiceCommand_PrestationId",
table: "NominativeServiceCommand",
column: "PrestationId");
migrationBuilder.CreateIndex(
name: "IX_NominativeServiceCommand_RdvQuery_LocationId",
table: "NominativeServiceCommand",
column: "RdvQuery_LocationId");
migrationBuilder.CreateIndex(
name: "IX_NominativeServiceCommand_SelectedProfileUserId",
table: "NominativeServiceCommand",
column: "SelectedProfileUserId");
migrationBuilder.CreateIndex(
name: "IX_DictionnaireMetier_DomaineActiviteCode_Langue_Nom",
table: "DictionnaireMetier",
columns: new[] { "DomaineActiviteCode", "Langue", "Nom" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_TermeMetier_DictionnaireMetierId_Langue_Mot",
table: "TermeMetier",
columns: new[] { "DictionnaireMetierId", "Langue", "Mot" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_TermeMetier_ProposeParId",
table: "TermeMetier",
column: "ProposeParId");
migrationBuilder.CreateIndex(
name: "IX_TermeMetier_ValideParId",
table: "TermeMetier",
column: "ValideParId");
migrationBuilder.AddForeignKey(
name: "FK_Estimates_NominativeServiceCommand_CommandId",
table: "Estimates",
column: "CommandId",
principalTable: "NominativeServiceCommand",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_HairPrestationCollectionItem_NominativeServiceCommand_Query~",
table: "HairPrestationCollectionItem",
column: "QueryId",
principalTable: "NominativeServiceCommand",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_NominativeServiceCommand_Activities_ActivityCode",
table: "NominativeServiceCommand",
column: "ActivityCode",
principalTable: "Activities",
principalColumn: "Code",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_NominativeServiceCommand_AspNetUsers_ClientId",
table: "NominativeServiceCommand",
column: "ClientId",
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_NominativeServiceCommand_BrusherProfile_SelectedProfileUser~",
table: "NominativeServiceCommand",
column: "SelectedProfileUserId",
principalTable: "BrusherProfile",
principalColumn: "UserId");
migrationBuilder.AddForeignKey(
name: "FK_NominativeServiceCommand_GitRepositoryReference_GitId",
table: "NominativeServiceCommand",
column: "GitId",
principalTable: "GitRepositoryReference",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_NominativeServiceCommand_HairPrestation_PrestationId",
table: "NominativeServiceCommand",
column: "PrestationId",
principalTable: "HairPrestation",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_NominativeServiceCommand_Locations_HairMultiCutQuery_Locati~",
table: "NominativeServiceCommand",
column: "HairMultiCutQuery_LocationId",
principalTable: "Locations",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_NominativeServiceCommand_Locations_LocationId",
table: "NominativeServiceCommand",
column: "LocationId",
principalTable: "Locations",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_NominativeServiceCommand_Locations_RdvQuery_LocationId",
table: "NominativeServiceCommand",
column: "RdvQuery_LocationId",
principalTable: "Locations",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_NominativeServiceCommand_PayPalPayment_PaymentId",
table: "NominativeServiceCommand",
column: "PaymentId",
principalTable: "PayPalPayment",
principalColumn: "CreationToken");
migrationBuilder.AddForeignKey(
name: "FK_NominativeServiceCommand_Performers_PerformerId",
table: "NominativeServiceCommand",
column: "PerformerId",
principalTable: "Performers",
principalColumn: "PerformerId",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_ProjectBuildConfiguration_NominativeServiceCommand_ProjectId",
table: "ProjectBuildConfiguration",
column: "ProjectId",
principalTable: "NominativeServiceCommand",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Estimates_NominativeServiceCommand_CommandId",
table: "Estimates");
migrationBuilder.DropForeignKey(
name: "FK_HairPrestationCollectionItem_NominativeServiceCommand_Query~",
table: "HairPrestationCollectionItem");
migrationBuilder.DropForeignKey(
name: "FK_NominativeServiceCommand_Activities_ActivityCode",
table: "NominativeServiceCommand");
migrationBuilder.DropForeignKey(
name: "FK_NominativeServiceCommand_AspNetUsers_ClientId",
table: "NominativeServiceCommand");
migrationBuilder.DropForeignKey(
name: "FK_NominativeServiceCommand_BrusherProfile_SelectedProfileUser~",
table: "NominativeServiceCommand");
migrationBuilder.DropForeignKey(
name: "FK_NominativeServiceCommand_GitRepositoryReference_GitId",
table: "NominativeServiceCommand");
migrationBuilder.DropForeignKey(
name: "FK_NominativeServiceCommand_HairPrestation_PrestationId",
table: "NominativeServiceCommand");
migrationBuilder.DropForeignKey(
name: "FK_NominativeServiceCommand_Locations_HairMultiCutQuery_Locati~",
table: "NominativeServiceCommand");
migrationBuilder.DropForeignKey(
name: "FK_NominativeServiceCommand_Locations_LocationId",
table: "NominativeServiceCommand");
migrationBuilder.DropForeignKey(
name: "FK_NominativeServiceCommand_Locations_RdvQuery_LocationId",
table: "NominativeServiceCommand");
migrationBuilder.DropForeignKey(
name: "FK_NominativeServiceCommand_PayPalPayment_PaymentId",
table: "NominativeServiceCommand");
migrationBuilder.DropForeignKey(
name: "FK_NominativeServiceCommand_Performers_PerformerId",
table: "NominativeServiceCommand");
migrationBuilder.DropForeignKey(
name: "FK_ProjectBuildConfiguration_NominativeServiceCommand_ProjectId",
table: "ProjectBuildConfiguration");
migrationBuilder.DropTable(
name: "TermeMetier");
migrationBuilder.DropTable(
name: "DictionnaireMetier");
migrationBuilder.DropPrimaryKey(
name: "PK_NominativeServiceCommand",
table: "NominativeServiceCommand");
migrationBuilder.DropIndex(
name: "IX_NominativeServiceCommand_GitId",
table: "NominativeServiceCommand");
migrationBuilder.DropIndex(
name: "IX_NominativeServiceCommand_HairMultiCutQuery_LocationId",
table: "NominativeServiceCommand");
migrationBuilder.DropIndex(
name: "IX_NominativeServiceCommand_PrestationId",
table: "NominativeServiceCommand");
migrationBuilder.DropIndex(
name: "IX_NominativeServiceCommand_RdvQuery_LocationId",
table: "NominativeServiceCommand");
migrationBuilder.DropIndex(
name: "IX_NominativeServiceCommand_SelectedProfileUserId",
table: "NominativeServiceCommand");
migrationBuilder.DropColumn(
name: "AdditionalInfo",
table: "NominativeServiceCommand");
migrationBuilder.DropColumn(
name: "Discriminator",
table: "NominativeServiceCommand");
migrationBuilder.DropColumn(
name: "GitId",
table: "NominativeServiceCommand");
migrationBuilder.DropColumn(
name: "HairMultiCutQuery_EventDate",
table: "NominativeServiceCommand");
migrationBuilder.DropColumn(
name: "HairMultiCutQuery_LocationId",
table: "NominativeServiceCommand");
migrationBuilder.DropColumn(
name: "Name",
table: "NominativeServiceCommand");
migrationBuilder.DropColumn(
name: "OwnerId",
table: "NominativeServiceCommand");
migrationBuilder.DropColumn(
name: "PrestationId",
table: "NominativeServiceCommand");
migrationBuilder.DropColumn(
name: "RdvQuery_EventDate",
table: "NominativeServiceCommand");
migrationBuilder.DropColumn(
name: "RdvQuery_LocationId",
table: "NominativeServiceCommand");
migrationBuilder.DropColumn(
name: "SelectedProfileUserId",
table: "NominativeServiceCommand");
migrationBuilder.DropColumn(
name: "Version",
table: "NominativeServiceCommand");
migrationBuilder.RenameTable(
name: "NominativeServiceCommand",
newName: "RdvQueries");
migrationBuilder.RenameIndex(
name: "IX_NominativeServiceCommand_PerformerId",
table: "RdvQueries",
newName: "IX_RdvQueries_PerformerId");
migrationBuilder.RenameIndex(
name: "IX_NominativeServiceCommand_PaymentId",
table: "RdvQueries",
newName: "IX_RdvQueries_PaymentId");
migrationBuilder.RenameIndex(
name: "IX_NominativeServiceCommand_LocationId",
table: "RdvQueries",
newName: "IX_RdvQueries_LocationId");
migrationBuilder.RenameIndex(
name: "IX_NominativeServiceCommand_ClientId",
table: "RdvQueries",
newName: "IX_RdvQueries_ClientId");
migrationBuilder.RenameIndex(
name: "IX_NominativeServiceCommand_ActivityCode",
table: "RdvQueries",
newName: "IX_RdvQueries_ActivityCode");
migrationBuilder.AlterColumn<string>(
name: "Reason",
table: "RdvQueries",
type: "text",
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
migrationBuilder.AlterColumn<int>(
name: "LocationType",
table: "RdvQueries",
type: "integer",
nullable: false,
defaultValue: 0,
oldClrType: typeof(int),
oldType: "integer",
oldNullable: true);
migrationBuilder.AlterColumn<long>(
name: "LocationId",
table: "RdvQueries",
type: "bigint",
nullable: false,
defaultValue: 0L,
oldClrType: typeof(long),
oldType: "bigint",
oldNullable: true);
migrationBuilder.AlterColumn<DateTime>(
name: "EventDate",
table: "RdvQueries",
type: "timestamp with time zone",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified),
oldClrType: typeof(DateTime),
oldType: "timestamp with time zone",
oldNullable: true);
migrationBuilder.AddPrimaryKey(
name: "PK_RdvQueries",
table: "RdvQueries",
column: "Id");
migrationBuilder.CreateTable(
name: "HairCutQueries",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
ActivityCode = table.Column<string>(type: "text", nullable: false),
ClientId = table.Column<string>(type: "text", nullable: false),
LocationId = table.Column<long>(type: "bigint", nullable: true),
PaymentId = table.Column<string>(type: "text", nullable: true),
PerformerId = table.Column<string>(type: "text", nullable: false),
PrestationId = table.Column<long>(type: "bigint", nullable: false),
SelectedProfileUserId = table.Column<string>(type: "text", nullable: true),
AdditionalInfo = table.Column<string>(type: "text", nullable: false),
Consent = table.Column<bool>(type: "boolean", nullable: false),
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
DateModified = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
Description = table.Column<string>(type: "text", nullable: false),
EventDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
Provisional = table.Column<decimal>(type: "numeric", nullable: true),
Status = table.Column<int>(type: "integer", nullable: false),
UserCreated = table.Column<string>(type: "text", nullable: false),
UserModified = table.Column<string>(type: "text", nullable: false),
ValidationDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_HairCutQueries", x => x.Id);
table.ForeignKey(
name: "FK_HairCutQueries_Activities_ActivityCode",
column: x => x.ActivityCode,
principalTable: "Activities",
principalColumn: "Code",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_HairCutQueries_AspNetUsers_ClientId",
column: x => x.ClientId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_HairCutQueries_BrusherProfile_SelectedProfileUserId",
column: x => x.SelectedProfileUserId,
principalTable: "BrusherProfile",
principalColumn: "UserId");
table.ForeignKey(
name: "FK_HairCutQueries_HairPrestation_PrestationId",
column: x => x.PrestationId,
principalTable: "HairPrestation",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_HairCutQueries_Locations_LocationId",
column: x => x.LocationId,
principalTable: "Locations",
principalColumn: "Id");
table.ForeignKey(
name: "FK_HairCutQueries_PayPalPayment_PaymentId",
column: x => x.PaymentId,
principalTable: "PayPalPayment",
principalColumn: "CreationToken");
table.ForeignKey(
name: "FK_HairCutQueries_Performers_PerformerId",
column: x => x.PerformerId,
principalTable: "Performers",
principalColumn: "PerformerId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "HairMultiCutQueries",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
ActivityCode = table.Column<string>(type: "text", nullable: false),
ClientId = table.Column<string>(type: "text", nullable: false),
LocationId = table.Column<long>(type: "bigint", nullable: false),
PaymentId = table.Column<string>(type: "text", nullable: true),
PerformerId = table.Column<string>(type: "text", nullable: false),
Consent = table.Column<bool>(type: "boolean", nullable: false),
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
DateModified = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
Description = table.Column<string>(type: "text", nullable: false),
EventDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
Provisional = table.Column<decimal>(type: "numeric", nullable: true),
Status = table.Column<int>(type: "integer", nullable: false),
UserCreated = table.Column<string>(type: "text", nullable: false),
UserModified = table.Column<string>(type: "text", nullable: false),
ValidationDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_HairMultiCutQueries", x => x.Id);
table.ForeignKey(
name: "FK_HairMultiCutQueries_Activities_ActivityCode",
column: x => x.ActivityCode,
principalTable: "Activities",
principalColumn: "Code",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_HairMultiCutQueries_AspNetUsers_ClientId",
column: x => x.ClientId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_HairMultiCutQueries_Locations_LocationId",
column: x => x.LocationId,
principalTable: "Locations",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_HairMultiCutQueries_PayPalPayment_PaymentId",
column: x => x.PaymentId,
principalTable: "PayPalPayment",
principalColumn: "CreationToken");
table.ForeignKey(
name: "FK_HairMultiCutQueries_Performers_PerformerId",
column: x => x.PerformerId,
principalTable: "Performers",
principalColumn: "PerformerId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Project",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
ActivityCode = table.Column<string>(type: "text", nullable: false),
ClientId = table.Column<string>(type: "text", nullable: false),
GitId = table.Column<long>(type: "bigint", nullable: false),
PaymentId = table.Column<string>(type: "text", nullable: true),
PerformerId = table.Column<string>(type: "text", nullable: false),
Consent = table.Column<bool>(type: "boolean", nullable: false),
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
DateModified = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
Description = table.Column<string>(type: "text", nullable: true),
Name = table.Column<string>(type: "text", nullable: false),
OwnerId = table.Column<string>(type: "text", nullable: true),
Provisional = table.Column<decimal>(type: "numeric", nullable: true),
Status = table.Column<int>(type: "integer", nullable: false),
UserCreated = table.Column<string>(type: "text", nullable: false),
UserModified = table.Column<string>(type: "text", nullable: false),
ValidationDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
Version = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Project", x => x.Id);
table.ForeignKey(
name: "FK_Project_Activities_ActivityCode",
column: x => x.ActivityCode,
principalTable: "Activities",
principalColumn: "Code",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Project_AspNetUsers_ClientId",
column: x => x.ClientId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Project_GitRepositoryReference_GitId",
column: x => x.GitId,
principalTable: "GitRepositoryReference",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Project_PayPalPayment_PaymentId",
column: x => x.PaymentId,
principalTable: "PayPalPayment",
principalColumn: "CreationToken");
table.ForeignKey(
name: "FK_Project_Performers_PerformerId",
column: x => x.PerformerId,
principalTable: "Performers",
principalColumn: "PerformerId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_HairCutQueries_ActivityCode",
table: "HairCutQueries",
column: "ActivityCode");
migrationBuilder.CreateIndex(
name: "IX_HairCutQueries_ClientId",
table: "HairCutQueries",
column: "ClientId");
migrationBuilder.CreateIndex(
name: "IX_HairCutQueries_LocationId",
table: "HairCutQueries",
column: "LocationId");
migrationBuilder.CreateIndex(
name: "IX_HairCutQueries_PaymentId",
table: "HairCutQueries",
column: "PaymentId");
migrationBuilder.CreateIndex(
name: "IX_HairCutQueries_PerformerId",
table: "HairCutQueries",
column: "PerformerId");
migrationBuilder.CreateIndex(
name: "IX_HairCutQueries_PrestationId",
table: "HairCutQueries",
column: "PrestationId");
migrationBuilder.CreateIndex(
name: "IX_HairCutQueries_SelectedProfileUserId",
table: "HairCutQueries",
column: "SelectedProfileUserId");
migrationBuilder.CreateIndex(
name: "IX_HairMultiCutQueries_ActivityCode",
table: "HairMultiCutQueries",
column: "ActivityCode");
migrationBuilder.CreateIndex(
name: "IX_HairMultiCutQueries_ClientId",
table: "HairMultiCutQueries",
column: "ClientId");
migrationBuilder.CreateIndex(
name: "IX_HairMultiCutQueries_LocationId",
table: "HairMultiCutQueries",
column: "LocationId");
migrationBuilder.CreateIndex(
name: "IX_HairMultiCutQueries_PaymentId",
table: "HairMultiCutQueries",
column: "PaymentId");
migrationBuilder.CreateIndex(
name: "IX_HairMultiCutQueries_PerformerId",
table: "HairMultiCutQueries",
column: "PerformerId");
migrationBuilder.CreateIndex(
name: "IX_Project_ActivityCode",
table: "Project",
column: "ActivityCode");
migrationBuilder.CreateIndex(
name: "IX_Project_ClientId",
table: "Project",
column: "ClientId");
migrationBuilder.CreateIndex(
name: "IX_Project_GitId",
table: "Project",
column: "GitId");
migrationBuilder.CreateIndex(
name: "IX_Project_PaymentId",
table: "Project",
column: "PaymentId");
migrationBuilder.CreateIndex(
name: "IX_Project_PerformerId",
table: "Project",
column: "PerformerId");
migrationBuilder.AddForeignKey(
name: "FK_Estimates_RdvQueries_CommandId",
table: "Estimates",
column: "CommandId",
principalTable: "RdvQueries",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_HairPrestationCollectionItem_HairMultiCutQueries_QueryId",
table: "HairPrestationCollectionItem",
column: "QueryId",
principalTable: "HairMultiCutQueries",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_ProjectBuildConfiguration_Project_ProjectId",
table: "ProjectBuildConfiguration",
column: "ProjectId",
principalTable: "Project",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_RdvQueries_Activities_ActivityCode",
table: "RdvQueries",
column: "ActivityCode",
principalTable: "Activities",
principalColumn: "Code",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_RdvQueries_AspNetUsers_ClientId",
table: "RdvQueries",
column: "ClientId",
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_RdvQueries_Locations_LocationId",
table: "RdvQueries",
column: "LocationId",
principalTable: "Locations",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_RdvQueries_PayPalPayment_PaymentId",
table: "RdvQueries",
column: "PaymentId",
principalTable: "PayPalPayment",
principalColumn: "CreationToken");
migrationBuilder.AddForeignKey(
name: "FK_RdvQueries_Performers_PerformerId",
table: "RdvQueries",
column: "PerformerId",
principalTable: "Performers",
principalColumn: "PerformerId",
onDelete: ReferentialAction.Cascade);
}
}
}

View file

@ -0,0 +1,63 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Yavsc.Models;
#nullable disable
namespace Yavsc.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260913022000_cleanupLegacyNominativeServiceCommandLocationId")]
public partial class cleanupLegacyNominativeServiceCommandLocationId : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"
UPDATE ""NominativeServiceCommand""
SET ""RdvQuery_LocationId"" = COALESCE(""RdvQuery_LocationId"", ""LocationId"")
WHERE ""Discriminator"" = 'RdvQuery'
AND ""LocationId"" IS NOT NULL;
");
migrationBuilder.DropForeignKey(
name: "FK_NominativeServiceCommand_Locations_LocationId",
table: "NominativeServiceCommand");
migrationBuilder.DropIndex(
name: "IX_NominativeServiceCommand_LocationId",
table: "NominativeServiceCommand");
migrationBuilder.DropColumn(
name: "LocationId",
table: "NominativeServiceCommand");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<long>(
name: "LocationId",
table: "NominativeServiceCommand",
type: "bigint",
nullable: true);
migrationBuilder.Sql(@"
UPDATE ""NominativeServiceCommand""
SET ""LocationId"" = ""RdvQuery_LocationId""
WHERE ""Discriminator"" = 'RdvQuery'
AND ""RdvQuery_LocationId"" IS NOT NULL;
");
migrationBuilder.CreateIndex(
name: "IX_NominativeServiceCommand_LocationId",
table: "NominativeServiceCommand",
column: "LocationId");
migrationBuilder.AddForeignKey(
name: "FK_NominativeServiceCommand_Locations_LocationId",
table: "NominativeServiceCommand",
column: "LocationId",
principalTable: "Locations",
principalColumn: "Id");
}
}
}

View file

@ -1427,6 +1427,81 @@ namespace Yavsc.Migrations
b.ToTable("ExceptionsSIREN");
});
modelBuilder.Entity("Yavsc.Models.Billing.NominativeServiceCommand", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("ActivityCode")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ClientId")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("Consent")
.HasColumnType("boolean");
b.Property<DateTime>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("DateModified")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Discriminator")
.IsRequired()
.HasMaxLength(34)
.HasColumnType("character varying(34)");
b.Property<string>("PaymentId")
.HasColumnType("text");
b.Property<string>("PerformerId")
.IsRequired()
.HasColumnType("text");
b.Property<decimal?>("Provisional")
.HasColumnType("numeric");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("UserCreated")
.IsRequired()
.HasColumnType("text");
b.Property<string>("UserModified")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("ValidationDate")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("ActivityCode");
b.HasIndex("ClientId");
b.HasIndex("PaymentId");
b.HasIndex("PerformerId");
b.ToTable("NominativeServiceCommand");
b.HasDiscriminator<string>("Discriminator").HasValue("NominativeServiceCommand");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("Yavsc.Models.Billing.Signature", b =>
{
b.Property<long>("Id")
@ -1914,168 +1989,6 @@ namespace Yavsc.Migrations
b.ToTable("BrusherProfile");
});
modelBuilder.Entity("Yavsc.Models.Haircut.HairCutQuery", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("ActivityCode")
.IsRequired()
.HasColumnType("text");
b.Property<string>("AdditionalInfo")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ClientId")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("Consent")
.HasColumnType("boolean");
b.Property<DateTime>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("DateModified")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("EventDate")
.HasColumnType("timestamp with time zone");
b.Property<long?>("LocationId")
.HasColumnType("bigint");
b.Property<string>("PaymentId")
.HasColumnType("text");
b.Property<string>("PerformerId")
.IsRequired()
.HasColumnType("text");
b.Property<long>("PrestationId")
.HasColumnType("bigint");
b.Property<decimal?>("Provisional")
.HasColumnType("numeric");
b.Property<string>("SelectedProfileUserId")
.HasColumnType("text");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("UserCreated")
.IsRequired()
.HasColumnType("text");
b.Property<string>("UserModified")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("ValidationDate")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("ActivityCode");
b.HasIndex("ClientId");
b.HasIndex("LocationId");
b.HasIndex("PaymentId");
b.HasIndex("PerformerId");
b.HasIndex("PrestationId");
b.HasIndex("SelectedProfileUserId");
b.ToTable("HairCutQueries");
});
modelBuilder.Entity("Yavsc.Models.Haircut.HairMultiCutQuery", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("ActivityCode")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ClientId")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("Consent")
.HasColumnType("boolean");
b.Property<DateTime>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("DateModified")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("EventDate")
.HasColumnType("timestamp with time zone");
b.Property<long>("LocationId")
.HasColumnType("bigint");
b.Property<string>("PaymentId")
.HasColumnType("text");
b.Property<string>("PerformerId")
.IsRequired()
.HasColumnType("text");
b.Property<decimal?>("Provisional")
.HasColumnType("numeric");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("UserCreated")
.IsRequired()
.HasColumnType("text");
b.Property<string>("UserModified")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("ValidationDate")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("ActivityCode");
b.HasIndex("ClientId");
b.HasIndex("LocationId");
b.HasIndex("PaymentId");
b.HasIndex("PerformerId");
b.ToTable("HairMultiCutQueries");
});
modelBuilder.Entity("Yavsc.Models.Haircut.HairPrestation", b =>
{
b.Property<long>("Id")
@ -3066,6 +2979,37 @@ namespace Yavsc.Migrations
});
});
modelBuilder.Entity("Yavsc.Models.Workflow.DictionnaireMetier", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("DomaineActiviteCode")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Langue")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Nom")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.HasKey("Id");
b.HasIndex("DomaineActiviteCode", "Langue", "Nom")
.IsUnique();
b.ToTable("DictionnaireMetier");
});
modelBuilder.Entity("Yavsc.Models.Workflow.PerformerCodeInputValidation", b =>
{
b.Property<long>("Id")
@ -3180,7 +3124,7 @@ namespace Yavsc.Migrations
b.ToTable("FormationSettings");
});
modelBuilder.Entity("Yavsc.Models.Workflow.RdvQuery", b =>
modelBuilder.Entity("Yavsc.Models.Workflow.TermeMetier", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
@ -3188,77 +3132,51 @@ namespace Yavsc.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("ActivityCode")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ClientId")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("Consent")
.HasColumnType("boolean");
b.Property<DateTime>("DateCreated")
b.Property<DateTime>("DateSoumission")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("DateModified")
b.Property<DateTime?>("DateValidation")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
b.Property<string>("Definition")
.IsRequired()
.HasColumnType("text");
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<DateTime>("EventDate")
.HasColumnType("timestamp with time zone");
b.Property<long>("LocationId")
b.Property<long>("DictionnaireMetierId")
.HasColumnType("bigint");
b.Property<int>("LocationType")
b.Property<string>("Langue")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Mot")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("ProposeParId")
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<int>("StatutValidation")
.HasColumnType("integer");
b.Property<string>("PaymentId")
.HasColumnType("text");
b.Property<string>("PerformerId")
.IsRequired()
.HasColumnType("text");
b.Property<decimal?>("Provisional")
.HasColumnType("numeric");
b.Property<string>("Reason")
.IsRequired()
.HasColumnType("text");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("UserCreated")
.IsRequired()
.HasColumnType("text");
b.Property<string>("UserModified")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("ValidationDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("ValideParId")
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.HasKey("Id");
b.HasIndex("ActivityCode");
b.HasIndex("ProposeParId");
b.HasIndex("ClientId");
b.HasIndex("ValideParId");
b.HasIndex("LocationId");
b.HasIndex("DictionnaireMetierId", "Langue", "Mot")
.IsUnique();
b.HasIndex("PaymentId");
b.HasIndex("PerformerId");
b.ToTable("RdvQueries");
b.ToTable("TermeMetier");
});
modelBuilder.Entity("Yavsc.Models.Workflow.UserActivity", b =>
@ -3327,86 +3245,6 @@ namespace Yavsc.Migrations
b.ToTable("MailingTemplate");
});
modelBuilder.Entity("Yavsc.Server.Models.IT.Project", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("ActivityCode")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ClientId")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("Consent")
.HasColumnType("boolean");
b.Property<DateTime>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("DateModified")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<long>("GitId")
.HasColumnType("bigint");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("OwnerId")
.HasColumnType("text");
b.Property<string>("PaymentId")
.HasColumnType("text");
b.Property<string>("PerformerId")
.IsRequired()
.HasColumnType("text");
b.Property<decimal?>("Provisional")
.HasColumnType("numeric");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("UserCreated")
.IsRequired()
.HasColumnType("text");
b.Property<string>("UserModified")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("ValidationDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Version")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("ActivityCode");
b.HasIndex("ClientId");
b.HasIndex("GitId");
b.HasIndex("PaymentId");
b.HasIndex("PerformerId");
b.ToTable("Project");
});
modelBuilder.Entity("Yavsc.Server.Models.IT.ProjectBuildConfiguration", b =>
{
b.Property<long>("Id")
@ -3457,6 +3295,112 @@ namespace Yavsc.Migrations
b.ToTable("GitRepositoryReference");
});
modelBuilder.Entity("Yavsc.Models.Haircut.HairCutQuery", b =>
{
b.HasBaseType("Yavsc.Models.Billing.NominativeServiceCommand");
b.Property<string>("AdditionalInfo")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("EventDate")
.HasColumnType("timestamp with time zone");
b.Property<long?>("LocationId")
.HasColumnType("bigint");
b.Property<long>("PrestationId")
.HasColumnType("bigint");
b.Property<string>("SelectedProfileUserId")
.HasColumnType("text");
b.HasIndex("LocationId");
b.HasIndex("PrestationId");
b.HasIndex("SelectedProfileUserId");
b.HasDiscriminator().HasValue("HairCutQuery");
});
modelBuilder.Entity("Yavsc.Models.Haircut.HairMultiCutQuery", b =>
{
b.HasBaseType("Yavsc.Models.Billing.NominativeServiceCommand");
b.Property<DateTime>("EventDate")
.HasColumnType("timestamp with time zone");
b.Property<long>("LocationId")
.HasColumnType("bigint");
b.HasIndex("LocationId");
b.ToTable("NominativeServiceCommand", t =>
{
t.Property("EventDate")
.HasColumnName("HairMultiCutQuery_EventDate");
t.Property("LocationId")
.HasColumnName("HairMultiCutQuery_LocationId");
});
b.HasDiscriminator().HasValue("HairMultiCutQuery");
});
modelBuilder.Entity("Yavsc.Models.Workflow.RdvQuery", b =>
{
b.HasBaseType("Yavsc.Models.Billing.NominativeServiceCommand");
b.Property<DateTime>("EventDate")
.HasColumnType("timestamp with time zone");
b.Property<long>("LocationId")
.HasColumnType("bigint");
b.Property<int>("LocationType")
.HasColumnType("integer");
b.Property<string>("Reason")
.IsRequired()
.HasColumnType("text");
b.HasIndex("LocationId");
b.ToTable("NominativeServiceCommand", t =>
{
t.Property("EventDate")
.HasColumnName("RdvQuery_EventDate");
t.Property("LocationId")
.HasColumnName("RdvQuery_LocationId");
});
b.HasDiscriminator().HasValue("RdvQuery");
});
modelBuilder.Entity("Yavsc.Server.Models.IT.Project", b =>
{
b.HasBaseType("Yavsc.Models.Billing.NominativeServiceCommand");
b.Property<long>("GitId")
.HasColumnType("bigint");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("OwnerId")
.HasColumnType("text");
b.Property<string>("Version")
.HasColumnType("text");
b.HasIndex("GitId");
b.HasDiscriminator().HasValue("Project");
});
modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiResourceClaim", b =>
{
b.HasOne("IdentityServer8.EntityFramework.Entities.ApiResource", null)
@ -3870,7 +3814,7 @@ namespace Yavsc.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Yavsc.Models.Workflow.RdvQuery", "Query")
b.HasOne("Yavsc.Models.Billing.NominativeServiceCommand", "Query")
.WithMany()
.HasForeignKey("CommandId");
@ -3887,6 +3831,39 @@ namespace Yavsc.Migrations
b.Navigation("Query");
});
modelBuilder.Entity("Yavsc.Models.Billing.NominativeServiceCommand", b =>
{
b.HasOne("Yavsc.Models.Workflow.Activity", "Context")
.WithMany()
.HasForeignKey("ActivityCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Yavsc.Models.ApplicationUser", "Client")
.WithMany()
.HasForeignKey("ClientId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Yavsc.Models.Payment.PayPalPayment", "Regularization")
.WithMany()
.HasForeignKey("PaymentId");
b.HasOne("Yavsc.Models.Workflow.PerformerProfile", "PerformerProfile")
.WithMany()
.HasForeignKey("PerformerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Client");
b.Navigation("Context");
b.Navigation("PerformerProfile");
b.Navigation("Regularization");
});
modelBuilder.Entity("Yavsc.Models.Billing.Signature", b =>
{
b.HasOne("Yavsc.Models.Billing.Estimate", "Estimate")
@ -4070,100 +4047,6 @@ namespace Yavsc.Migrations
b.Navigation("Schedule");
});
modelBuilder.Entity("Yavsc.Models.Haircut.HairCutQuery", b =>
{
b.HasOne("Yavsc.Models.Workflow.Activity", "Context")
.WithMany()
.HasForeignKey("ActivityCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Yavsc.Models.ApplicationUser", "Client")
.WithMany()
.HasForeignKey("ClientId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Yavsc.Models.Relationship.Location", "Location")
.WithMany()
.HasForeignKey("LocationId");
b.HasOne("Yavsc.Models.Payment.PayPalPayment", "Regularization")
.WithMany()
.HasForeignKey("PaymentId");
b.HasOne("Yavsc.Models.Workflow.PerformerProfile", "PerformerProfile")
.WithMany()
.HasForeignKey("PerformerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Yavsc.Models.Haircut.HairPrestation", "Prestation")
.WithMany()
.HasForeignKey("PrestationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Yavsc.Models.Haircut.BrusherProfile", "SelectedProfile")
.WithMany()
.HasForeignKey("SelectedProfileUserId");
b.Navigation("Client");
b.Navigation("Context");
b.Navigation("Location");
b.Navigation("PerformerProfile");
b.Navigation("Prestation");
b.Navigation("Regularization");
b.Navigation("SelectedProfile");
});
modelBuilder.Entity("Yavsc.Models.Haircut.HairMultiCutQuery", b =>
{
b.HasOne("Yavsc.Models.Workflow.Activity", "Context")
.WithMany()
.HasForeignKey("ActivityCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Yavsc.Models.ApplicationUser", "Client")
.WithMany()
.HasForeignKey("ClientId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Yavsc.Models.Relationship.Location", "Location")
.WithMany()
.HasForeignKey("LocationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Yavsc.Models.Payment.PayPalPayment", "Regularization")
.WithMany()
.HasForeignKey("PaymentId");
b.HasOne("Yavsc.Models.Workflow.PerformerProfile", "PerformerProfile")
.WithMany()
.HasForeignKey("PerformerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Client");
b.Navigation("Context");
b.Navigation("Location");
b.Navigation("PerformerProfile");
b.Navigation("Regularization");
});
modelBuilder.Entity("Yavsc.Models.Haircut.HairPrestationCollectionItem", b =>
{
b.HasOne("Yavsc.Models.Haircut.HairPrestation", "Prestation")
@ -4481,6 +4364,17 @@ namespace Yavsc.Migrations
b.Navigation("Context");
});
modelBuilder.Entity("Yavsc.Models.Workflow.DictionnaireMetier", b =>
{
b.HasOne("Yavsc.Models.Workflow.Activity", "DomaineActivite")
.WithMany()
.HasForeignKey("DomaineActiviteCode")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("DomaineActivite");
});
modelBuilder.Entity("Yavsc.Models.Workflow.PerformerCodeInputValidation", b =>
{
b.HasOne("Yavsc.Models.Workflow.Country", "Country")
@ -4511,45 +4405,27 @@ namespace Yavsc.Migrations
b.Navigation("Performer");
});
modelBuilder.Entity("Yavsc.Models.Workflow.RdvQuery", b =>
modelBuilder.Entity("Yavsc.Models.Workflow.TermeMetier", b =>
{
b.HasOne("Yavsc.Models.Workflow.Activity", "Context")
.WithMany()
.HasForeignKey("ActivityCode")
b.HasOne("Yavsc.Models.Workflow.DictionnaireMetier", "DictionnaireMetier")
.WithMany("Termes")
.HasForeignKey("DictionnaireMetierId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Yavsc.Models.ApplicationUser", "Client")
b.HasOne("Yavsc.Models.ApplicationUser", "ProposePar")
.WithMany()
.HasForeignKey("ClientId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
.HasForeignKey("ProposeParId");
b.HasOne("Yavsc.Models.Relationship.Location", "Location")
b.HasOne("Yavsc.Models.ApplicationUser", "ValidePar")
.WithMany()
.HasForeignKey("LocationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
.HasForeignKey("ValideParId");
b.HasOne("Yavsc.Models.Payment.PayPalPayment", "Regularization")
.WithMany()
.HasForeignKey("PaymentId");
b.Navigation("DictionnaireMetier");
b.HasOne("Yavsc.Models.Workflow.PerformerProfile", "PerformerProfile")
.WithMany()
.HasForeignKey("PerformerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ProposePar");
b.Navigation("Client");
b.Navigation("Context");
b.Navigation("Location");
b.Navigation("PerformerProfile");
b.Navigation("Regularization");
b.Navigation("ValidePar");
});
modelBuilder.Entity("Yavsc.Models.Workflow.UserActivity", b =>
@ -4571,47 +4447,6 @@ namespace Yavsc.Migrations
b.Navigation("User");
});
modelBuilder.Entity("Yavsc.Server.Models.IT.Project", b =>
{
b.HasOne("Yavsc.Models.Workflow.Activity", "Context")
.WithMany()
.HasForeignKey("ActivityCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Yavsc.Models.ApplicationUser", "Client")
.WithMany()
.HasForeignKey("ClientId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Yavsc.Server.Models.IT.SourceCode.GitRepositoryReference", "Repository")
.WithMany()
.HasForeignKey("GitId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Yavsc.Models.Payment.PayPalPayment", "Regularization")
.WithMany()
.HasForeignKey("PaymentId");
b.HasOne("Yavsc.Models.Workflow.PerformerProfile", "PerformerProfile")
.WithMany()
.HasForeignKey("PerformerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Client");
b.Navigation("Context");
b.Navigation("PerformerProfile");
b.Navigation("Regularization");
b.Navigation("Repository");
});
modelBuilder.Entity("Yavsc.Server.Models.IT.ProjectBuildConfiguration", b =>
{
b.HasOne("Yavsc.Server.Models.IT.Project", "TargetProject")
@ -4632,6 +4467,62 @@ namespace Yavsc.Migrations
b.Navigation("Owner");
});
modelBuilder.Entity("Yavsc.Models.Haircut.HairCutQuery", b =>
{
b.HasOne("Yavsc.Models.Relationship.Location", "Location")
.WithMany()
.HasForeignKey("LocationId");
b.HasOne("Yavsc.Models.Haircut.HairPrestation", "Prestation")
.WithMany()
.HasForeignKey("PrestationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Yavsc.Models.Haircut.BrusherProfile", "SelectedProfile")
.WithMany()
.HasForeignKey("SelectedProfileUserId");
b.Navigation("Location");
b.Navigation("Prestation");
b.Navigation("SelectedProfile");
});
modelBuilder.Entity("Yavsc.Models.Haircut.HairMultiCutQuery", b =>
{
b.HasOne("Yavsc.Models.Relationship.Location", "Location")
.WithMany()
.HasForeignKey("LocationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Location");
});
modelBuilder.Entity("Yavsc.Models.Workflow.RdvQuery", b =>
{
b.HasOne("Yavsc.Models.Relationship.Location", "Location")
.WithMany()
.HasForeignKey("LocationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Location");
});
modelBuilder.Entity("Yavsc.Server.Models.IT.Project", b =>
{
b.HasOne("Yavsc.Server.Models.IT.SourceCode.GitRepositoryReference", "Repository")
.WithMany()
.HasForeignKey("GitId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Repository");
});
modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiResource", b =>
{
b.Navigation("Properties");
@ -4746,11 +4637,6 @@ namespace Yavsc.Migrations
b.Navigation("Links");
});
modelBuilder.Entity("Yavsc.Models.Haircut.HairMultiCutQuery", b =>
{
b.Navigation("Prestations");
});
modelBuilder.Entity("Yavsc.Models.Haircut.HairPrestation", b =>
{
b.Navigation("Taints");
@ -4795,6 +4681,11 @@ namespace Yavsc.Migrations
b.Navigation("Services");
});
modelBuilder.Entity("Yavsc.Models.Workflow.DictionnaireMetier", b =>
{
b.Navigation("Termes");
});
modelBuilder.Entity("Yavsc.Models.Workflow.PerformerProfile", b =>
{
b.Navigation("Activity");
@ -4805,6 +4696,11 @@ namespace Yavsc.Migrations
b.Navigation("CoWorking");
});
modelBuilder.Entity("Yavsc.Models.Haircut.HairMultiCutQuery", b =>
{
b.Navigation("Prestations");
});
modelBuilder.Entity("Yavsc.Server.Models.IT.Project", b =>
{
b.Navigation("Configurations");

View file

@ -19,7 +19,7 @@ namespace Yavsc.Services
bool Kick(string cxId, string userName, string roomName, string reason);
bool Op(string roomName, string userName);
bool Deop(string roomName, string userName);
bool DeOp(string roomName, string userName);
bool Hop(string roomName, string userName);
bool DeHop(string roomName, string userName);
bool TryGetChanInfo(string room, out ChatRoomInfo channelInfo);

View file

@ -126,6 +126,27 @@ namespace Yavsc.Models
builder.Entity<Activity>().Property(a => a.ParentCode).IsRequired(false);
builder.Entity<Activity>().Property(a => a.Description).IsRequired(false);
builder.Entity<DictionnaireMetier>()
.HasOne(d => d.DomaineActivite)
.WithMany()
.HasForeignKey(d => d.DomaineActiviteCode)
.HasPrincipalKey(a => a.Code)
.OnDelete(DeleteBehavior.Restrict);
builder.Entity<DictionnaireMetier>()
.HasIndex(d => new { d.DomaineActiviteCode, d.Langue, d.Nom })
.IsUnique();
builder.Entity<TermeMetier>()
.HasOne(t => t.DictionnaireMetier)
.WithMany(d => d.Termes)
.HasForeignKey(t => t.DictionnaireMetierId)
.OnDelete(DeleteBehavior.Cascade);
builder.Entity<TermeMetier>()
.HasIndex(t => new { t.DictionnaireMetierId, t.Langue, t.Mot })
.IsUnique();
builder.Entity<Country>().HasKey(c => c.Code);
builder.Entity<PerformerCodeInputValidation>()
.HasOne(v => v.Country)
@ -263,6 +284,10 @@ namespace Yavsc.Models
/// <returns></returns>
public DbSet<Activity> Activities { get; set; }
public DbSet<DictionnaireMetier> DictionnaireMetier { get; set; }
public DbSet<TermeMetier> TermeMetier { get; set; }
public DbSet<UserActivity> UserActivities { get; set; }
/// <summary>

View file

@ -2,6 +2,7 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
namespace Yavsc.Models.Billing
{
@ -20,8 +21,8 @@ namespace Yavsc.Models.Billing
/// it will result in a new estimate template
/// </summary>
/// <returns></returns>
[ForeignKey("CommandId"),JsonIgnore]
public RdvQuery Query { get; set; }
[ForeignKey("CommandId"),JsonIgnore,ValidateNever]
public NominativeServiceCommand? Query { get; set; }
public string Description { get; set; }
public string Title { get; set; }
@ -57,14 +58,15 @@ namespace Yavsc.Models.Billing
set { AttachedFiles = value.Split(':').ToList(); }
}
[ValidateNever]
public string OwnerId { get; set; }
[ForeignKey("OwnerId"),JsonIgnore]
[ForeignKey("OwnerId"),JsonIgnore,ValidateNever]
public virtual PerformerProfile Owner { get; set; }
[Required]
public string ClientId { get; set; }
[ForeignKey("ClientId"),JsonIgnore]
[ForeignKey("ClientId"),JsonIgnore,ValidateNever]
public virtual ApplicationUser Client { get; set; }
[Required]

View file

@ -0,0 +1,79 @@
#nullable enable annotations
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Newtonsoft.Json;
namespace Yavsc.Models.Workflow
{
public enum StatutValidationTerme
{
Propose,
Valide,
Rejete
}
public class DictionnaireMetier
{
[Key]
public long Id { get; set; }
[Required, MaxLength(200)]
public string Nom { get; set; } = string.Empty;
[Required, MaxLength(20)]
public string Langue { get; set; } = "fr";
[Required, MaxLength(128)]
[ForeignKey(nameof(DomaineActivite))]
public string DomaineActiviteCode { get; set; } = string.Empty;
[JsonIgnore]
public virtual Activity? DomaineActivite { get; set; }
[JsonIgnore]
public virtual ICollection<TermeMetier> Termes { get; set; } = new List<TermeMetier>();
}
public class TermeMetier
{
[Key]
public long Id { get; set; }
[Required]
public long DictionnaireMetierId { get; set; }
[ForeignKey(nameof(DictionnaireMetierId))]
[JsonIgnore]
public virtual DictionnaireMetier? DictionnaireMetier { get; set; }
[Required, MaxLength(200)]
public string Mot { get; set; } = string.Empty;
[Required, MaxLength(2000)]
public string Definition { get; set; } = string.Empty;
[Required, MaxLength(20)]
public string Langue { get; set; } = "fr";
public StatutValidationTerme StatutValidation { get; set; } = StatutValidationTerme.Propose;
[MaxLength(450)]
public string? ProposeParId { get; set; }
[ForeignKey(nameof(ProposeParId))]
[JsonIgnore]
public virtual ApplicationUser? ProposePar { get; set; }
[MaxLength(450)]
public string? ValideParId { get; set; }
[ForeignKey(nameof(ValideParId))]
[JsonIgnore]
public virtual ApplicationUser? ValidePar { get; set; }
public DateTime DateSoumission { get; set; } = DateTime.UtcNow;
public DateTime? DateValidation { get; set; }
}
}

View file

@ -186,6 +186,59 @@ public class BlogSpotService
_context.SaveChanges(user.GetUserId());
}
public async Task Modify(ClaimsPrincipal user, BlogPost blog, IFormFileCollection files)
{
await Modify(user, blog);
if (files == null || files.Count == 0)
return;
var userId = user.GetUserId();
var userEntity = _context.Users.FirstOrDefault(u => u.Id == userId);
if (userEntity == null)
return;
try
{
string blogFilesSubdir = $"blogs/{blog.Id}";
string destDir = Path.Combine(
AbstractFileSystemHelpers.UserFilesDirName,
userEntity.UserName,
blogFilesSubdir);
var di = new DirectoryInfo(destDir);
if (!di.Exists) di.Create();
foreach (var formFile in files)
{
var fileInfo = userEntity.ReceiveUserFile(destDir, formFile);
if (fileInfo != null && !fileInfo.QuotaOffense)
{
var uploadedFile = new UploadedFile
{
Path = fileInfo.FileName,
ContentType = formFile.ContentType,
Length = formFile.Length
};
_context.UploadedFiles.Add(uploadedFile);
_context.SaveChanges(userId);
var attachment = new BlogAttachedFile
{
PostId = blog.Id,
FileId = uploadedFile.Id
};
_context.BlogAttachedFiles.Add(attachment);
}
}
_context.SaveChanges(userId);
}
catch (Exception ex)
{
Debug.WriteLine($"Erreur lors du traitement des fichiers : {ex.Message}");
}
}
public async Task<IEnumerable<IBlogPost>> Index(ClaimsPrincipal user, string id, int skip = 0, int take = 25)
{
IEnumerable<IBlogPost> posts;

View file

@ -0,0 +1,116 @@
using Microsoft.EntityFrameworkCore;
using Yavsc.Models;
using Yavsc.Models.Workflow;
namespace Yavsc.Server.Services
{
public class DictionnaireMetierModerationService
{
private readonly ApplicationDbContext _context;
public DictionnaireMetierModerationService(ApplicationDbContext context)
{
_context = context;
}
public async Task<TermeMetier> ProposerTermAsync(
long dictionnaireId,
string mot,
string definition,
string langue,
string userId)
{
if (string.IsNullOrWhiteSpace(mot))
{
throw new ArgumentException("Le terme est requis.", nameof(mot));
}
if (string.IsNullOrWhiteSpace(definition))
{
throw new ArgumentException("La définition est requise.", nameof(definition));
}
var dictionary = await _context.DictionnaireMetier
.SingleOrDefaultAsync(d => d.Id == dictionnaireId);
if (dictionary is null)
{
throw new KeyNotFoundException($"Dictionnaire {dictionnaireId} introuvable.");
}
var normalizedMot = mot.Trim();
var exists = await _context.TermeMetier
.AnyAsync(t => t.DictionnaireMetierId == dictionnaireId
&& t.Langue == langue
&& t.Mot == normalizedMot);
if (exists)
{
throw new InvalidOperationException($"Le terme '{normalizedMot}' existe déjà dans ce dictionnaire.");
}
var term = new TermeMetier
{
DictionnaireMetierId = dictionnaireId,
DictionnaireMetier = dictionary,
Mot = normalizedMot,
Definition = definition.Trim(),
Langue = string.IsNullOrWhiteSpace(langue) ? "fr" : langue,
StatutValidation = StatutValidationTerme.Propose,
ProposeParId = userId,
ValideParId = null,
DateSoumission = DateTime.UtcNow,
DateValidation = null
};
_context.TermeMetier.Add(term);
await _context.SaveChangesAsync();
return term;
}
public async Task<TermeMetier> ValiderTermAsync(long termeId, string moderatorId)
{
var term = await _context.TermeMetier
.SingleOrDefaultAsync(t => t.Id == termeId);
if (term is null)
{
throw new KeyNotFoundException($"Terme {termeId} introuvable.");
}
term.StatutValidation = StatutValidationTerme.Valide;
term.ValideParId = moderatorId;
term.DateValidation = DateTime.UtcNow;
await _context.SaveChangesAsync();
return term;
}
public async Task<TermeMetier> RejeterTermAsync(long termeId, string moderatorId)
{
var term = await _context.TermeMetier
.SingleOrDefaultAsync(t => t.Id == termeId);
if (term is null)
{
throw new KeyNotFoundException($"Terme {termeId} introuvable.");
}
term.StatutValidation = StatutValidationTerme.Rejete;
term.ValideParId = moderatorId;
term.DateValidation = DateTime.UtcNow;
await _context.SaveChangesAsync();
return term;
}
public async Task<List<TermeMetier>> GetPendingAsync(long dictionnaireId)
{
return await _context.TermeMetier
.Where(t => t.DictionnaireMetierId == dictionnaireId
&& t.StatutValidation == StatutValidationTerme.Propose)
.OrderBy(t => t.DateSoumission)
.ToListAsync();
}
}
}

View file

@ -1,5 +1,7 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Logging;
using Yavsc.Abstract.Chat;
using Yavsc.Models;
using Yavsc.ViewModels.Chat;
@ -119,10 +121,10 @@ namespace Yavsc.Services
public bool Part(string cxId, string roomName, string reason)
{
ChatRoomInfo chanInfo;
if (Channels.TryGetValue(roomName, out chanInfo))
ChatRoomInfo channelInfo;
if (Channels.TryGetValue(roomName, out channelInfo))
{
if (!chanInfo.Users.Contains(cxId))
if (!channelInfo.Users.Contains(cxId))
{
// TODO NotifyErrorToCaller(roomName, "you didn't join.");
return false;
@ -130,11 +132,11 @@ namespace Yavsc.Services
// FIXME only remove cx, not username,
// as long as he might be connected
// from another device, to the same room
chanInfo.Users.Remove(cxId);
if (chanInfo.Users.Count == 0)
channelInfo.Users.Remove(cxId);
if (channelInfo.Users.Count == 0)
{
ChatRoomInfo deadchanInfo;
if (Channels.TryRemove(roomName, out deadchanInfo))
ChatRoomInfo deadChannelInfo;
if (Channels.TryRemove(roomName, out deadChannelInfo))
{
var room = _dbContext.ChatRoom.FirstOrDefault(r => r.Name == roomName);
room.LatestJoinPart = DateTime.UtcNow;
@ -155,67 +157,67 @@ namespace Yavsc.Services
var userName = ChatUserNames[cxId];
_logger.LogInformation($"Join: {userName}=>{roomName}");
ChatRoomInfo chanInfo;
ChatRoomInfo channelInfo;
// if channel already is open
if (Channels.ContainsKey(roomName))
{
if (Channels.TryGetValue(roomName, out chanInfo))
if (Channels.TryGetValue(roomName, out channelInfo))
{
if (IsPresent(roomName, userName))
{
// TODO implement some unique connection sharing protocol
// between all terminals from a single user.
return chanInfo;
return channelInfo;
}
else
{
if (IsCop(userName))
{
chanInfo.Ops.Add(cxId);
channelInfo.Ops.Add(cxId);
}
else{
chanInfo.Users.Add(cxId);
channelInfo.Users.Add(cxId);
}
_logger.LogInformation($"existing room joint: {userName}=>{roomName}");
if (!ChatRoomPresence[userName].Contains(roomName))
ChatRoomPresence[userName].Add(roomName);
return chanInfo;
return channelInfo;
}
}
else
{
string msg = "room seemd to be avaible ... but we could get no info on it.";
string msg = "room seemed to be available ... but we could get no info on it.";
_errorHandler(roomName, msg);
return null;
}
}
// room was closed.
var room = _dbContext.ChatRoom.FirstOrDefault(r => r.Name == roomName);
chanInfo = new ChatRoomInfo();
channelInfo = new ChatRoomInfo();
if (room != null)
{
chanInfo.Topic = room.Topic;
chanInfo.Name = room.Name;
chanInfo.Users.Add(cxId);
channelInfo.Topic = room.Topic;
channelInfo.Name = room.Name;
channelInfo.Users.Add(cxId);
}
else
{ // a first join, we create it.
chanInfo.Name = roomName;
chanInfo.Topic = _localizer.GetString(ChatHubConstants.JustCreatedBy)+userName;
chanInfo.Ops.Add(cxId);
channelInfo.Name = roomName;
channelInfo.Topic = _localizer.GetString(ChatHubConstants.JustCreatedBy)+userName;
channelInfo.Ops.Add(cxId);
}
if (Channels.TryAdd(roomName, chanInfo))
if (Channels.TryAdd(roomName, channelInfo))
{
ChatRoomPresence[userName].Add(roomName);
_logger.LogInformation("new room joint");
return (chanInfo);
return (channelInfo);
}
else
{
string msg = "Chan create failed unexpectly...";
string msg = "Chan create failed unexpectedly...";
_errorHandler(roomName, msg);
return null;
}
@ -226,7 +228,7 @@ namespace Yavsc.Services
throw new System.NotImplementedException();
}
public bool Deop(string roomName, string userName)
public bool DeOp(string roomName, string userName)
{
throw new System.NotImplementedException();
}
@ -246,9 +248,9 @@ namespace Yavsc.Services
return ChatUserNames[cxId];
}
public bool TryGetChanInfo(string room, out ChatRoomInfo chanInfo)
public bool TryGetChanInfo(string room, out ChatRoomInfo channelInfo)
{
return Channels.TryGetValue(room, out chanInfo);
return Channels.TryGetValue(room, out channelInfo);
}
public IEnumerable<ChannelShortInfo> ListChannels(string pattern)
@ -277,22 +279,22 @@ namespace Yavsc.Services
public bool Kick(string cxId, string userName, string roomName, string reason)
{
ChatRoomInfo chanInfo;
ChatRoomInfo channelInfo;
if (!Channels.ContainsKey(roomName))
{
_errorHandler(roomName, _localizer.GetString(ChatHubConstants.LabNoSuchChan).ToString());
return false;
}
if (!Channels.TryGetValue(roomName, out chanInfo))
if (!Channels.TryGetValue(roomName, out channelInfo))
{
_errorHandler(roomName, _localizer.GetString(ChatHubConstants.LabNoSuchChan).ToString());
return false;
}
var kickerName = GetUserName(cxId);
if (!chanInfo.Ops.Contains(cxId))
if (!chanInfo.Hops.Contains(cxId))
if (!channelInfo.Ops.Contains(cxId))
if (!channelInfo.Hops.Contains(cxId))
{
_errorHandler(roomName, _localizer.GetString(ChatHubConstants.LabYouNotOp).ToString());
return false;
@ -303,9 +305,9 @@ namespace Yavsc.Services
_errorHandler(roomName, _localizer.GetString(ChatHubConstants.LabNoSuchUser).ToString());
return false;
}
var ucxs = GetConnexionIds(userName);
if (chanInfo.Hops.Contains(cxId))
if (chanInfo.Ops.Any(c => ucxs.Contains(c)))
var userConnectionIds = GetConnexionIds(userName);
if (channelInfo.Hops.Contains(cxId))
if (channelInfo.Ops.Any(c => userConnectionIds.Contains(c)))
{
_errorHandler(roomName, _localizer.GetString(ChatHubConstants.HopWontKickOp).ToString());
return false;
@ -317,15 +319,15 @@ namespace Yavsc.Services
}
// all good, time to kick :-)
foreach (var ucx in ucxs) {
if (chanInfo.Users.Contains(ucx))
chanInfo.Users.Remove(ucx);
foreach (var ucx in userConnectionIds) {
if (channelInfo.Users.Contains(ucx))
channelInfo.Users.Remove(ucx);
else if (chanInfo.Ops.Contains(ucx))
chanInfo.Ops.Remove(ucx);
else if (channelInfo.Ops.Contains(ucx))
channelInfo.Ops.Remove(ucx);
else if (chanInfo.Hops.Contains(ucx))
chanInfo.Hops.Remove(ucx);
else if (channelInfo.Hops.Contains(ucx))
channelInfo.Hops.Remove(ucx);
}
return true;

View file

@ -1,4 +1,5 @@
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using Yavsc.Interface;

View file

@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.Extensions.DependencyInjection;
using System.Net;
using System.Runtime.Loader;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
@ -37,6 +38,7 @@ public abstract class WebHostFixture : IBackendFixture
private static readonly object _sync = new object();
private static WebApplication? _app;
private static bool _isInitialized;
private static bool _shutdownHooksRegistered;
private static int _instanceCount;
private static readonly List<string> _sharedAddresses = new();
private static IServiceProvider? _sharedServices;
@ -63,6 +65,8 @@ public abstract class WebHostFixture : IBackendFixture
{
lock (_sync)
{
RegisterShutdownHooks();
if (!_isInitialized)
{
InitializeAsync().GetAwaiter().GetResult();
@ -114,11 +118,19 @@ public abstract class WebHostFixture : IBackendFixture
/// listen port.</summary>
protected virtual int HttpsPort => 5101;
/// <summary>Options used to create the WebApplicationBuilder.
/// Derived fixtures can override (for example, to set
/// ApplicationName for MVC controller discovery).</summary>
protected virtual WebApplicationOptions CreateBuilderOptions()
{
return new WebApplicationOptions();
}
public WebApplication App { get; private set; }
private async Task InitializeAsync()
{
var builder = WebApplication.CreateBuilder();
var builder = WebApplication.CreateBuilder(CreateBuilderOptions());
builder.WebHost.ConfigureKestrel(options =>
{
@ -158,23 +170,40 @@ public abstract class WebHostFixture : IBackendFixture
_instanceCount--;
}
IsInitialized = false;
IsInitialized = _isInitialized;
if (_instanceCount > 0)
// Keep the shared host alive for the whole test process.
// Disposing per class/collection can race with other test
// classes and intermittently drop the listener mid-run.
}
}
private static void RegisterShutdownHooks()
{
if (_shutdownHooksRegistered)
{
return;
}
AppDomain.CurrentDomain.ProcessExit += (_, __) => ShutdownSharedHost();
AssemblyLoadContext.Default.Unloading += _ => ShutdownSharedHost();
_shutdownHooksRegistered = true;
}
private static void ShutdownSharedHost()
{
lock (_sync)
{
if (!_isInitialized || _app is null)
{
return;
}
if (!_isInitialized)
{
return;
}
_app?.StopAsync().GetAwaiter().GetResult();
_app.StopAsync().GetAwaiter().GetResult();
_app = null;
_isInitialized = false;
_sharedAddresses.Clear();
_sharedServices = null;
_sharedAddresses.Clear();
}
}