diff --git a/.vscode/mcp.json b/.vscode/mcp.json
new file mode 100644
index 00000000..7ca6ed4b
--- /dev/null
+++ b/.vscode/mcp.json
@@ -0,0 +1,11 @@
+{
+ "servers": {
+ "openclaw": {
+ "type": "stdio",
+ "command": "/home/paul/.nvm/versions/node/v22.23.0/bin/node",
+ "args": [
+ "/home/paul/Workspace/tools/openclaw-mcp-server.js"
+ ]
+ }
+ }
+}
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 0a4785b9..65938a22 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -26,5 +26,16 @@
"cSpell.language": "fr,en",
"makefile.configureOnOpen": false,
"search.useGlobalIgnoreFiles": true,
- "search.useParentIgnoreFiles": true
+ "search.useParentIgnoreFiles": true,
+ "chat.mcp.serverSampling": {
+ "yavsc/.vscode/mcp.json: openclaw": {
+ "allowedModels": [
+ "copilot/auto",
+ "copilotcli/claude-haiku-4.5",
+ "copilotcli/gpt-4.1",
+ "copilotcli/gpt-5-mini",
+ "copilotcli/mai-code-1-flash-picker"
+ ]
+ }
+ }
}
diff --git a/Directory.Packages.props b/Directory.Packages.props
index b1ed6926..31472d7f 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -13,9 +13,9 @@
-->
-
-
-
+
+
+
@@ -24,10 +24,10 @@
-
+
-
\ No newline at end of file
+
diff --git a/Dockerfile.backend b/Dockerfile.backend
index 86df9ccd..76ad9ea0 100644
--- a/Dockerfile.backend
+++ b/Dockerfile.backend
@@ -26,7 +26,7 @@ COPY src/PostIt/PostIt.Desktop/*.csproj ./src/PostIt/PostIt.Desktop/
COPY . .
# 3. Restauration des dépendances avec vos workloads actifs
-RUN dotnet nuget add source https://isn.pschneider.fr/v3/index.json --allow-insecure-connections
+RUN dotnet nuget add source https://isn.pschneider.fr/api/v3/index.json
# 4. Restauration des dépendances pour tous les projets
RUN dotnet restore
diff --git a/Makefile b/Makefile
index a4922a3d..2683c542 100644
--- a/Makefile
+++ b/Makefile
@@ -10,8 +10,8 @@ include .env
all:
dotnet build --nologo
-clean:
- dotnet clean
+clean:
+ dotnet clean -c $(CONFIG)
src/Yavsc/bin/output/wwwroot:
dotnet --project src/Yavsc.Org/Yavsc.Org.csproj publish
@@ -31,7 +31,7 @@ src/Yavsc.Server/bin/$(CONFIG)/$(FRAMEWORK)/Yavsc.Server.dll:
src/Yavsc/bin/$(CONFIG)/$(FRAMEWORK)/Yavsc.dll:
dotnet build -p:Configuration=$(CONFIG) --project src/Yavsc.Org/Yavsc.Org.csproj
-$(DESTDIR):
+$(DESTDIR):
mkdir $(DESTDIR)
install: $(DESTDIR)
diff --git a/src/PostIt.Tests/FakeAuthorizingBrowser.cs b/src/PostIt.Tests/FakeAuthorizingBrowser.cs
index 10311500..4748425a 100644
--- a/src/PostIt.Tests/FakeAuthorizingBrowser.cs
+++ b/src/PostIt.Tests/FakeAuthorizingBrowser.cs
@@ -10,7 +10,7 @@ namespace PostIt.Tests;
/// URL emitted by OidcClient, extracts its state, and returns a
/// BrowserResult that mimics the OIDC redirect-with-code callback.
///
-/// The paired 's token endpoint accepts
+/// The paired 's token endpoint accepts
/// any authorization code, so we don't need to mint a real one here.
///
public sealed class FakeAuthorizingBrowser
diff --git a/src/PostIt.Tests/LoginPageViewModelTests.cs b/src/PostIt.Tests/LoginPageViewModelTests.cs
index e469c011..7a8b579f 100644
--- a/src/PostIt.Tests/LoginPageViewModelTests.cs
+++ b/src/PostIt.Tests/LoginPageViewModelTests.cs
@@ -14,7 +14,7 @@ public class LoginPageViewModelTests
// short-circuits the system browser. The authority signs its
// access_token with RS256; the fake browser captures the redirect
// URI so the authority can complete the token exchange.
- using var authority = await OidcStubAuthority.StartAsync();
+ using var authority = await OIDCStubAuthority.StartAsync();
var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri);
var settings = new PostIt.Settings
@@ -96,7 +96,7 @@ public class LoginPageViewModelTests
// double slash before /.well-known/openid-configuration. The
// stub advertises itself without the trailing slash; OidcClient
// must bridge.
- using var authority = await OidcStubAuthority.StartAsync();
+ using var authority = await OIDCStubAuthority.StartAsync();
var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri);
var settings = new PostIt.Settings
@@ -254,4 +254,4 @@ public class LoginPageViewModelTests
"https://yavsc.example.com/.well-known/openid-configuration",
vm.StatusMessage);
}
-}
\ No newline at end of file
+}
diff --git a/src/PostIt.Tests/OidcStubAuthority.cs b/src/PostIt.Tests/OidcStubAuthority.cs
index 552db6b0..3c6552fb 100644
--- a/src/PostIt.Tests/OidcStubAuthority.cs
+++ b/src/PostIt.Tests/OidcStubAuthority.cs
@@ -20,7 +20,7 @@ namespace PostIt.Tests;
/// the browser intercepts the authorize redirect, the server completes
/// the token exchange.
///
-public sealed class OidcStubAuthority : IAsyncDisposable, IDisposable
+public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable
{
private readonly HttpListener _listener;
private readonly RSA _rsa;
@@ -30,7 +30,7 @@ public sealed class OidcStubAuthority : IAsyncDisposable, IDisposable
public string Issuer { get; }
public string LoopbackRedirectUri { get; }
- private OidcStubAuthority(HttpListener listener, RSA rsa, string kid, string issuer, string loopback)
+ private OIDCStubAuthority(HttpListener listener, RSA rsa, string kid, string issuer, string loopback)
{
_listener = listener;
_rsa = rsa;
@@ -39,7 +39,7 @@ public sealed class OidcStubAuthority : IAsyncDisposable, IDisposable
LoopbackRedirectUri = loopback;
}
- public static async Task StartAsync()
+ public static async Task StartAsync()
{
// Pick a free loopback port.
var port = GetFreePort();
@@ -53,7 +53,7 @@ public sealed class OidcStubAuthority : IAsyncDisposable, IDisposable
var rsa = RSA.Create(2048);
var kid = "test-key-1";
- var authority = new OidcStubAuthority(listener, rsa, kid, prefix.TrimEnd('/'), loopback);
+ var authority = new OIDCStubAuthority(listener, rsa, kid, prefix.TrimEnd('/'), loopback);
_ = Task.Run(() => authority.AcceptLoopAsync(authority._cts.Token));
return authority;
}
diff --git a/src/PostIt.Tests/SignaturePadControlTests.cs b/src/PostIt.Tests/SignaturePadControlTests.cs
new file mode 100644
index 00000000..691ae547
--- /dev/null
+++ b/src/PostIt.Tests/SignaturePadControlTests.cs
@@ -0,0 +1,188 @@
+using System;
+using System.Linq;
+using PostIt.Controls;
+using PostIt.Models;
+using Xunit;
+
+namespace PostIt.Tests;
+
+///
+/// Targeted tests for and
+/// .
+///
+/// The control exposes internal test hooks so we can drive
+/// the buffer without standing up a headless XAML tree just to
+/// deliver synthetic pointer events. The headless surface is used
+/// only to assert that the control's pointer handlers are wired
+/// when a template is applied; see
+/// .
+///
+public class SignaturePadControlTests
+{
+ // --- SignaturePadData (pure) ---------------------------------------
+
+ [Fact]
+ public void Data_empty_array_is_empty()
+ {
+ var d = new SignaturePadData(Array.Empty());
+ Assert.True(d.IsEmpty);
+ Assert.Equal(0, d.StrokeCount);
+ }
+
+ [Fact]
+ public void Data_single_dot_is_one_stroke_with_k_equals_one()
+ {
+ var d = new SignaturePadData(new[] { 1, 5_000, 5_000 });
+ Assert.False(d.IsEmpty);
+ Assert.Equal(1, d.StrokeCount);
+ }
+
+ [Fact]
+ public void Data_two_strokes_are_independent()
+ {
+ var d = new SignaturePadData(new[]
+ {
+ 2, 100, 100, 200, 200,
+ 1, 9_000, 9_000,
+ });
+ Assert.Equal(2, d.StrokeCount);
+ }
+
+ [Fact]
+ public void Data_malformed_payload_does_not_throw_on_read()
+ {
+ // k=0 at the head would underflow the walker. The reader
+ // short-circuits instead of throwing.
+ var d = new SignaturePadData(new[] { 0, 1, 2, 3 });
+ Assert.Equal(0, d.StrokeCount);
+ }
+
+ [Fact]
+ public void Data_constructor_rejects_null()
+ {
+ Assert.Throws(() => new SignaturePadData(null!));
+ }
+
+ // --- SignaturePadControl (buffer / events) -------------------------
+
+ [Fact]
+ public void New_control_has_empty_buffer()
+ {
+ var pad = new SignaturePadControl();
+ Assert.Empty(pad.Strokes);
+ Assert.True(pad.Snapshot().IsEmpty);
+ }
+
+ [Fact]
+ public void Snapshot_returns_a_distinct_array_each_call()
+ {
+ var pad = new SignaturePadControl();
+ pad.AppendPointForTest(1_000, 2_000);
+ pad.AppendPointForTest(3_000, 4_000);
+ pad.SealStrokeForTest();
+
+ var first = pad.Snapshot();
+ var second = pad.Snapshot();
+
+ // Distinct array instances — the consumer of the first
+ // snapshot can hold onto it after the control mutates.
+ Assert.NotSame(first.Strokes, second.Strokes);
+ // Same logical content (no mutation in between).
+ Assert.Equal(first.Strokes, second.Strokes);
+
+ pad.AppendPointForTest(5_000, 6_000);
+ pad.SealStrokeForTest();
+
+ var third = pad.Snapshot();
+ Assert.NotEqual(first.Strokes, third.Strokes);
+ }
+
+ [Fact]
+ public void Clear_empties_buffer_and_raises_redraw()
+ {
+ var pad = new SignaturePadControl();
+ pad.AppendPointForTest(1, 1);
+ pad.SealStrokeForTest();
+ Assert.NotEmpty(pad.Strokes);
+
+ int redraws = 0;
+ pad.RedrawRequested += (_, _) => redraws++;
+ pad.Clear();
+
+ Assert.Empty(pad.Strokes);
+ Assert.True(pad.Snapshot().IsEmpty);
+ Assert.Equal(1, redraws);
+ }
+
+ [Fact]
+ public void SealStrokeForTest_raises_redraw()
+ {
+ var pad = new SignaturePadControl();
+ int redraws = 0;
+ pad.RedrawRequested += (_, _) => redraws++;
+ pad.AppendPointForTest(1, 1);
+ pad.AppendPointForTest(2, 2);
+ pad.SealStrokeForTest();
+ Assert.Equal(1, redraws);
+ }
+
+ [Fact]
+ public void SealStrokeForTest_with_no_pending_points_is_a_no_op()
+ {
+ var pad = new SignaturePadControl();
+ int redraws = 0;
+ pad.RedrawRequested += (_, _) => redraws++;
+ pad.SealStrokeForTest();
+ Assert.Equal(0, redraws);
+ }
+
+ [Fact]
+ public void Two_sealed_strokes_produce_two_length_prefixes()
+ {
+ var pad = new SignaturePadControl();
+ // Stroke 0: one point.
+ pad.AppendPointForTest(1_000, 1_000);
+ pad.SealStrokeForTest();
+ // Stroke 1: two points.
+ pad.AppendPointForTest(2_000, 2_000);
+ pad.AppendPointForTest(3_000, 3_000);
+ pad.SealStrokeForTest();
+
+ var s = pad.Strokes;
+ // Layout: [k0, x0, y0, k1, x1, y1, x2, y2]
+ Assert.Equal(1, s[0]);
+ Assert.Equal(1_000, s[1]);
+ Assert.Equal(1_000, s[2]);
+ Assert.Equal(2, s[3]);
+ Assert.Equal(2_000, s[4]);
+ Assert.Equal(2_000, s[5]);
+ Assert.Equal(3_000, s[6]);
+ Assert.Equal(3_000, s[7]);
+ }
+
+ [Fact]
+ public void StrokeCompleted_fires_on_seal()
+ {
+ var pad = new SignaturePadControl();
+ int events = 0;
+ pad.StrokeCompleted += (_, _) => events++;
+ pad.AppendPointForTest(1, 1);
+ pad.SealStrokeForTest();
+ pad.AppendPointForTest(2, 2);
+ pad.SealStrokeForTest();
+ Assert.Equal(2, events);
+ }
+
+ [Fact]
+ public void StrokeCompleted_carries_a_snapshot_with_k_count()
+ {
+ var pad = new SignaturePadControl();
+ SignaturePadData? captured = null;
+ pad.StrokeCompleted += (_, d) => captured = d;
+ pad.AppendPointForTest(1, 1);
+ pad.AppendPointForTest(2, 2);
+ pad.SealStrokeForTest();
+ Assert.NotNull(captured);
+ Assert.Equal(1, captured!.StrokeCount);
+ }
+}
diff --git a/src/PostIt.Tests/SignaturePageViewModelTests.cs b/src/PostIt.Tests/SignaturePageViewModelTests.cs
new file mode 100644
index 00000000..37f17a58
--- /dev/null
+++ b/src/PostIt.Tests/SignaturePageViewModelTests.cs
@@ -0,0 +1,163 @@
+using System;
+using System.IO;
+using System.Text.Json;
+using System.Threading.Tasks;
+using PostIt.Controls;
+using PostIt.ViewModels;
+using Xunit;
+
+namespace PostIt.Tests;
+
+///
+/// Tests for : the contract
+/// between the page's view model and the .
+/// The view (XAML + code-behind rendering) is not tested here — the
+/// control is render-agnostic, and the rendering is plain Polyline
+/// reconstruction that we'll exercise manually in PostIt.Desktop.
+///
+public class SignaturePageViewModelTests
+{
+ [Fact]
+ public void Default_constructor_uses_default_dimensions()
+ {
+ var vm = new SignaturePageViewModel();
+ Assert.Equal(SignaturePageViewModel.DefaultWidth, vm.Width);
+ Assert.Equal(SignaturePageViewModel.DefaultHeight, vm.Height);
+ }
+
+ [Fact]
+ public void Constructor_rejects_non_positive_dimensions()
+ {
+ Assert.Throws(
+ () => new SignaturePageViewModel(0, 100));
+ Assert.Throws(
+ () => new SignaturePageViewModel(100, 0));
+ Assert.Throws(
+ () => new SignaturePageViewModel(-1, 100));
+ }
+
+ [Fact]
+ public void Attach_then_Detach_is_idempotent()
+ {
+ var vm = new SignaturePageViewModel();
+ var pad = new SignaturePadControl();
+ vm.Attach(pad);
+ vm.Detach();
+ // Second detach is a no-op: must not throw.
+ vm.Detach();
+ }
+
+ [Fact]
+ public void Attach_rejects_null()
+ {
+ var vm = new SignaturePageViewModel();
+ Assert.Throws(() => vm.Attach(null!));
+ }
+
+ [Fact]
+ public void StrokeCompleted_updates_status_and_counts()
+ {
+ var vm = new SignaturePageViewModel();
+ var pad = new SignaturePadControl();
+ vm.Attach(pad);
+
+ // Drive the control via the test hooks so we don't depend
+ // on Avalonia pointer events.
+ pad.AppendPointForTest(1_000, 1_000);
+ pad.AppendPointForTest(2_000, 2_000);
+ pad.SealStrokeForTest();
+
+ Assert.Equal(1, vm.StrokeCount);
+ Assert.Equal(2, vm.PointCount);
+ Assert.Contains("1 trait", vm.StatusMessage);
+ }
+
+ [Fact]
+ public void Clear_resets_counts_and_buffer()
+ {
+ var vm = new SignaturePageViewModel();
+ var pad = new SignaturePadControl();
+ vm.Attach(pad);
+
+ pad.AppendPointForTest(1, 1);
+ pad.SealStrokeForTest();
+ Assert.Equal(1, vm.StrokeCount);
+
+ vm.Clear();
+
+ Assert.Equal(0, vm.StrokeCount);
+ Assert.Equal(0, vm.PointCount);
+ Assert.Empty(pad.Strokes);
+ Assert.Contains("Effacé", vm.StatusMessage);
+ }
+
+ [Fact]
+ public async Task CaptureAsync_on_empty_buffer_reports_and_writes_nothing()
+ {
+ var vm = new SignaturePageViewModel();
+ var pad = new SignaturePadControl();
+ vm.Attach(pad);
+
+ await vm.CaptureAsync();
+
+ Assert.Contains("Rien", vm.StatusMessage);
+ Assert.Null(vm.LastCapturedPath);
+ }
+
+ [Fact]
+ public async Task CaptureAsync_writes_a_yavsc_signature_v1_file()
+ {
+ // The VM uses Environment.SpecialFolder.LocalApplicationData,
+ // which we cannot redirect per-call without a constructor
+ // seam. We test the produced file's structure rather than
+ // its text formatting, because System.Text.Json's pretty-
+ // printer is not part of the contract we're locking down.
+ var vm = new SignaturePageViewModel();
+ var pad = new SignaturePadControl();
+ vm.Attach(pad);
+
+ pad.AppendPointForTest(1_000, 2_000);
+ pad.AppendPointForTest(3_000, 4_000);
+ pad.SealStrokeForTest();
+
+ await vm.CaptureAsync();
+
+ Assert.NotNull(vm.LastCapturedPath);
+ Assert.True(File.Exists(vm.LastCapturedPath!), $"file missing: {vm.LastCapturedPath}");
+
+ using var doc = JsonDocument.Parse(File.ReadAllText(vm.LastCapturedPath!));
+ var root = doc.RootElement;
+
+ Assert.Equal("yavsc.signature/v1", root.GetProperty("format").GetString());
+ Assert.Equal(10_000, root.GetProperty("coordinateMax").GetInt32());
+ Assert.Equal(1, root.GetProperty("strokeCount").GetInt32());
+
+ var strokes = root.GetProperty("strokes");
+ Assert.Equal(JsonValueKind.Array, strokes.ValueKind);
+ // [k=2, x0, y0, x1, y1]
+ Assert.Equal(5, strokes.GetArrayLength());
+ Assert.Equal(2, strokes[0].GetInt32()); // k (2 points)
+ Assert.Equal(1_000, strokes[1].GetInt32()); // x0
+ Assert.Equal(2_000, strokes[2].GetInt32()); // y0
+ Assert.Equal(3_000, strokes[3].GetInt32()); // x1
+ Assert.Equal(4_000, strokes[4].GetInt32()); // y1
+ }
+
+ [Fact]
+ public async Task CaptureAsync_creates_directory_if_missing()
+ {
+ var vm = new SignaturePageViewModel();
+ var pad = new SignaturePadControl();
+ vm.Attach(pad);
+ pad.AppendPointForTest(1, 1);
+ pad.SealStrokeForTest();
+
+ // The directory must exist after the call (CreateDirectory
+ // in the VM handles this).
+ await vm.CaptureAsync();
+
+ var dir = Path.GetDirectoryName(vm.LastCapturedPath!);
+ Assert.NotNull(dir);
+ Assert.True(Directory.Exists(dir), $"directory missing: {dir}");
+ }
+}
diff --git a/src/PostIt.Tests/YavscApiClientTests.cs b/src/PostIt.Tests/YavscApiClientTests.cs
index 1617c1b6..03e2fa3f 100644
--- a/src/PostIt.Tests/YavscApiClientTests.cs
+++ b/src/PostIt.Tests/YavscApiClientTests.cs
@@ -20,7 +20,7 @@ namespace PostIt.Tests;
/// End-to-end coverage of : silent
/// refresh on a near-expiry access token, 401-driven refresh + retry,
/// and persistence of the token bundle via .
-/// Uses the project's for the IdP and
+/// Uses the project's for the IdP and
/// a tiny in-process HTTP listener for the API server side.
///
public class YavscApiClientTests
@@ -45,7 +45,7 @@ public class YavscApiClientTests
// in-memory access token as expired and re-run a call. The
// refresh path must rotate the refresh token transparently
// and the API call must succeed with the new token.
- using var authority = await OidcStubAuthority.StartAsync();
+ using var authority = await OIDCStubAuthority.StartAsync();
using var apiServer = new StubApiServer();
await apiServer.StartAsync();
@@ -63,7 +63,7 @@ public class YavscApiClientTests
var reloaded = new YavscApiClient(settings, new TokenStore(tokensPath));
var posts = await reloaded.CallAsync>(
- HttpMethod.Get, "posts");
+ HttpMethod.Get, "posts", TestContext.Current.CancellationToken);
Assert.NotNull(posts);
Assert.NotEmpty(posts);
@@ -85,7 +85,7 @@ public class YavscApiClientTests
{
// API server returns 401 on the first request, 200 on the next.
// YavscApiClient must refresh, then retry exactly once.
- using var authority = await OidcStubAuthority.StartAsync();
+ using var authority = await OIDCStubAuthority.StartAsync();
using var apiServer = new StubApiServer(forceFirstRequest: true);
await apiServer.StartAsync();
@@ -97,7 +97,7 @@ public class YavscApiClientTests
settings, authority, tokensPath);
var posts = await client.CallAsync>(
- HttpMethod.Get, "posts");
+ HttpMethod.Get, "posts", TestContext.Current.CancellationToken);
Assert.NotEmpty(posts);
Assert.Equal(2, apiServer.RequestCount);
@@ -125,14 +125,15 @@ public class YavscApiClientTests
var client = new YavscApiClient(settings, new TokenStore(Path.Combine(
Path.GetTempPath(), $"postit-tests-noop-{Guid.NewGuid():N}.json")));
- await Assert.ThrowsAsync(() =>
- client.CallAsync(HttpMethod.Get, "posts"));
+ await Assert.ThrowsAsync(
+ () =>
+ client.CallAsync(HttpMethod.Get, "posts", TestContext.Current.CancellationToken));
}
[Fact]
public async Task HasValidSession_is_true_after_login()
{
- using var authority = await OidcStubAuthority.StartAsync();
+ using var authority = await OIDCStubAuthority.StartAsync();
using var apiServer = new StubApiServer();
await apiServer.StartAsync();
@@ -154,7 +155,7 @@ public class YavscApiClientTests
// --- helpers --------------------------------------------------------
- private static PostIt.Settings BuildSettings(OidcStubAuthority authority, string apiBaseUrl) => new()
+ private static PostIt.Settings BuildSettings(OIDCStubAuthority authority, string apiBaseUrl) => new()
{
Authentication = new AuthenticationSettings
{
@@ -167,7 +168,7 @@ public class YavscApiClientTests
};
private static async Task LoginAndPersistAsync(
- PostIt.Settings settings, OidcStubAuthority authority, string tokensPath)
+ PostIt.Settings settings, OIDCStubAuthority authority, string tokensPath)
{
var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri);
var client = new YavscApiClient(settings, new TokenStore(tokensPath));
@@ -181,7 +182,7 @@ public class YavscApiClientTests
///
/// YavscApiClient.LoginInteractiveAsync delegates to
/// Platform.CreateBrowser. We can't override that static cleanly
- /// from xunit.v3, so we rebuild the call by re-routing the
+ /// from XUnit.v3, so we rebuild the call by re-routing the
/// Platform.CreateBrowser delegate for the duration of the call.
///
private static async Task LoginWithBrowserAsync(
@@ -214,7 +215,7 @@ public class YavscApiClientTests
File.WriteAllText(tokensPath, JsonSerializer.Serialize(record));
}
- // --- OidcLoginPhase progress tests ---------------------------------
+ // --- OIDCLoginPhase progress tests ---------------------------------
///
/// Collecting Progress is documented to capture reports
@@ -225,7 +226,7 @@ public class YavscApiClientTests
[Fact]
public async Task LoginInteractiveAsync_reports_Discovering_then_Success()
{
- using var authority = await OidcStubAuthority.StartAsync();
+ using var authority = await OIDCStubAuthority.StartAsync();
using var apiServer = new StubApiServer();
await apiServer.StartAsync();
@@ -234,18 +235,18 @@ public class YavscApiClientTests
var client = new YavscApiClient(settings, new TokenStore(tokensPath));
var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri);
- var reported = new System.Collections.Generic.List();
- var progress = new SyncProgress(reported);
+ var reported = new System.Collections.Generic.List();
+ var progress = new SyncProgress(reported);
try
{
await LoginWithBrowserAsync(client, browser.CreateBrowser(), progress);
// SyncProgress captures reports synchronously — no flush needed.
- Assert.Contains(OidcLoginPhase.Discovering, reported);
- Assert.Contains(OidcLoginPhase.OpeningBrowser, reported);
- Assert.Contains(OidcLoginPhase.ExchangingCode, reported);
- Assert.Equal(OidcLoginPhase.Success, Last(reported));
+ Assert.Contains(OIDCLoginPhase.Discovering, reported);
+ Assert.Contains(OIDCLoginPhase.OpeningBrowser, reported);
+ Assert.Contains(OIDCLoginPhase.ExchangingCode, reported);
+ Assert.Equal(OIDCLoginPhase.Success, Last(reported));
}
finally
{
@@ -256,24 +257,24 @@ public class YavscApiClientTests
[Fact]
public async Task LoginInteractiveAsync_reports_Error_when_browser_missing()
{
- using var authority = await OidcStubAuthority.StartAsync();
+ using var authority = await OIDCStubAuthority.StartAsync();
using var apiServer = new StubApiServer();
await apiServer.StartAsync();
var settings = BuildSettings(authority, apiServer.BaseUrl);
var client = new YavscApiClient(settings, new TokenStore(TokensPath()));
- var reported = new System.Collections.Generic.List();
- var progress = new SyncProgress(reported);
+ var reported = new System.Collections.Generic.List();
+ var progress = new SyncProgress(reported);
var original = Platform.CreateBrowser;
try
{
Platform.CreateBrowser = () => null; // simulate no browser wired up
await Assert.ThrowsAsync(
- () => client.LoginInteractiveAsync(progress));
+ () => client.LoginInteractiveAsync(progress, TestContext.Current.CancellationToken));
// SyncProgress captures reports synchronously — no flush needed.
- Assert.Equal(OidcLoginPhase.Error, Last(reported));
+ Assert.Equal(OIDCLoginPhase.Error, Last(reported));
}
finally
{
@@ -284,7 +285,7 @@ public class YavscApiClientTests
[Fact]
public async Task TrySilentLoginAsync_returns_false_when_no_bundle_on_disk()
{
- using var authority = await OidcStubAuthority.StartAsync();
+ using var authority = await OIDCStubAuthority.StartAsync();
using var apiServer = new StubApiServer();
await apiServer.StartAsync();
@@ -293,7 +294,7 @@ public class YavscApiClientTests
// Tokens file deliberately doesn't exist.
var client = new YavscApiClient(settings, new TokenStore(tokensPath));
- var ok = await client.TrySilentLoginAsync();
+ var ok = await client.TrySilentLoginAsync(null, TestContext.Current.CancellationToken);
Assert.False(ok);
Assert.False(client.HasValidSession);
}
@@ -301,7 +302,7 @@ public class YavscApiClientTests
[Fact]
public async Task TrySilentLoginAsync_returns_true_when_access_token_still_valid()
{
- using var authority = await OidcStubAuthority.StartAsync();
+ using var authority = await OIDCStubAuthority.StartAsync();
using var apiServer = new StubApiServer();
await apiServer.StartAsync();
@@ -314,7 +315,7 @@ public class YavscApiClientTests
{
await LoginWithBrowserAsync(client, browser.CreateBrowser());
// Login fresh → access token is far from expiry.
- var ok = await client.TrySilentLoginAsync();
+ var ok = await client.TrySilentLoginAsync(null, TestContext.Current.CancellationToken);
Assert.True(ok);
Assert.True(client.HasValidSession);
}
@@ -327,7 +328,7 @@ public class YavscApiClientTests
[Fact]
public async Task TrySilentLoginAsync_returns_true_when_refresh_succeeds()
{
- using var authority = await OidcStubAuthority.StartAsync();
+ using var authority = await OIDCStubAuthority.StartAsync();
using var apiServer = new StubApiServer();
await apiServer.StartAsync();
@@ -351,13 +352,13 @@ public class YavscApiClientTests
// matches the disk: access expired, refresh still good.
var client = new YavscApiClient(settings, store);
- var reported = new System.Collections.Generic.List();
- var progress = new SyncProgress(reported);
+ var reported = new System.Collections.Generic.List();
+ var progress = new SyncProgress(reported);
- var ok = await client.TrySilentLoginAsync(progress);
+ var ok = await client.TrySilentLoginAsync(progress, TestContext.Current.CancellationToken);
Assert.True(ok, "silent refresh should succeed via the stub authority.");
- Assert.Contains(OidcLoginPhase.ExchangingCode, reported);
- Assert.Equal(OidcLoginPhase.Success, Last(reported));
+ Assert.Contains(OIDCLoginPhase.ExchangingCode, reported);
+ Assert.Equal(OIDCLoginPhase.Success, Last(reported));
}
finally
{
@@ -430,7 +431,7 @@ public class YavscApiClientTests
/// overload stays for tests that don't care about phase events.
///
private static async Task LoginWithBrowserAsync(
- YavscApiClient client, IBrowser browser, IProgress? progress = null)
+ YavscApiClient client, IBrowser browser, IProgress? progress = null)
{
var original = Platform.CreateBrowser;
try
diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs
index 65ce8b26..134ae42f 100644
--- a/src/PostIt/PostIt/App.axaml.cs
+++ b/src/PostIt/PostIt/App.axaml.cs
@@ -63,6 +63,7 @@ public partial class App : Application
services.AddTransient();
services.AddTransient();
services.AddTransient();
+ services.AddTransient();
// ViewModels
services.AddSingleton(settings);
@@ -72,6 +73,7 @@ public partial class App : Application
services.AddTransient();
services.AddTransient();
services.AddTransient();
+ services.AddTransient();
// Persistent session banner: one instance for the lifetime of
// the app so the same VM survives page navigation.
diff --git a/src/PostIt/PostIt/Controls/SignaturePadControl.cs b/src/PostIt/PostIt/Controls/SignaturePadControl.cs
new file mode 100644
index 00000000..87949d30
--- /dev/null
+++ b/src/PostIt/PostIt/Controls/SignaturePadControl.cs
@@ -0,0 +1,204 @@
+using System;
+using System.Collections.Generic;
+using Avalonia;
+using Avalonia.Controls.Primitives;
+using Avalonia.Input;
+using PostIt.Models;
+
+namespace PostIt.Controls;
+
+///
+/// Pointer-driven capture surface that records a signature as a list
+/// of strokes, each stroke being a length-prefixed sequence of (x, y)
+/// coordinates normalised to [0, CoordinateMax].
+///
+/// The control is render-agnostic: it does not draw anything. The
+/// host view templates a (typically a
+/// Border) as PART_CaptureArea for pointer capture,
+/// and binds a separate visual layer (e.g. a Canvas) to
+/// for redraw. Keeping the control headless of
+/// rendering makes it usable from a headless test where no
+/// composition happens.
+///
+/// Wire format (see ):
+/// int[] = [k0, x0, y0, ..., k1, x0, y0, ...]
+/// with x, y ∈ [0, 10_000].
+///
+/// Threading: pointer events are dispatched on the UI thread, which
+/// is the only thread that ever mutates . The
+/// buffer is safe to read from any thread as long as no read
+/// straddles a pointer event — for cross-thread transfer use
+/// , which copies.
+///
+public class SignaturePadControl : TemplatedControl
+{
+ ///
+ /// Styled property pointing at the
+ /// that receives pointer events. Set it in the control's
+ /// template (PART_CaptureArea).
+ ///
+ public static readonly StyledProperty CaptureAreaProperty =
+ AvaloniaProperty.Register(nameof(CaptureArea));
+
+ public InputElement? CaptureArea
+ {
+ get => GetValue(CaptureAreaProperty);
+ set => SetValue(CaptureAreaProperty, value);
+ }
+
+ ///
+ /// Captured strokes in wire form. Exposed as a read-only view
+ /// over the internal buffer. The buffer only mutates on the UI
+ /// thread, between pointer events.
+ ///
+ public IReadOnlyList Strokes => _strokes;
+
+ ///
+ /// Raised when the user finishes a stroke (pointer release).
+ /// The argument is a snapshot of the buffer at release time.
+ ///
+ public event EventHandler? StrokeCompleted;
+
+ ///
+ /// Raised when the buffer changes: at the end of every stroke
+ /// and on . Mid-stroke points do not raise
+ /// this event (pointer-move is too dense); bind a separate
+ /// visual layer if you need a live preview.
+ ///
+ public event EventHandler? RedrawRequested;
+
+ private readonly List _strokes = new(capacity: 256);
+ private int _pendingPoints; // number of (x, y) pairs awaiting a length prefix
+ private bool _capturing;
+
+ protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
+ {
+ base.OnApplyTemplate(e);
+
+ if (CaptureArea is { } previous)
+ {
+ previous.PointerPressed -= OnCapturePressed;
+ previous.PointerMoved -= OnCaptureMoved;
+ previous.PointerReleased -= OnCaptureReleased;
+ }
+
+ if (CaptureArea is { } area)
+ {
+ area.PointerPressed += OnCapturePressed;
+ area.PointerMoved += OnCaptureMoved;
+ area.PointerReleased += OnCaptureReleased;
+ }
+ }
+
+ private void OnCapturePressed(object? sender, PointerPressedEventArgs e)
+ {
+ if (!e.GetCurrentPoint(CaptureArea).Properties.IsLeftButtonPressed) return;
+ e.Pointer.Capture(CaptureArea);
+ _capturing = true;
+ _pendingPoints = 0;
+ AppendPoint(e.GetPosition(CaptureArea));
+ }
+
+ private void OnCaptureMoved(object? sender, PointerEventArgs e)
+ {
+ if (!_capturing) return;
+ AppendPoint(e.GetPosition(CaptureArea));
+ }
+
+ private void OnCaptureReleased(object? sender, PointerReleasedEventArgs e)
+ {
+ if (!_capturing) return;
+ AppendPoint(e.GetPosition(CaptureArea));
+ _capturing = false;
+
+ if (_pendingPoints == 0)
+ {
+ // Press + immediate release without movement yields no
+ // point at all (the press fired AppendPoint, so this
+ // branch is unreachable — kept for clarity if a future
+ // change skips the press append).
+ return;
+ }
+
+ // Seal the current stroke by inserting its length at the
+ // head of its slice. The slice is the trailing
+ // 2 * _pendingPoints entries.
+ int sliceStart = _strokes.Count - 2 * _pendingPoints;
+ _strokes.Insert(sliceStart, _pendingPoints);
+ _pendingPoints = 0;
+
+ StrokeCompleted?.Invoke(this, Snapshot());
+ RedrawRequested?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void AppendPoint(Point p)
+ {
+ var (nx, ny) = Normalise(p);
+ _strokes.Add(nx);
+ _strokes.Add(ny);
+ _pendingPoints++;
+ }
+
+ private (int x, int y) Normalise(Point p)
+ {
+ if (CaptureArea is null) return (0, 0);
+ var bounds = CaptureArea.Bounds;
+ double w = bounds.Width;
+ double h = bounds.Height;
+ if (w <= 0 || h <= 0) return (0, 0);
+ int nx = (int)Math.Round(Math.Clamp(p.X / w, 0.0, 1.0) * SignaturePadData.CoordinateMax);
+ int ny = (int)Math.Round(Math.Clamp(p.Y / h, 0.0, 1.0) * SignaturePadData.CoordinateMax);
+ return (nx, ny);
+ }
+
+ ///
+ /// Forget every captured stroke. Raises .
+ ///
+ public void Clear()
+ {
+ _strokes.Clear();
+ _pendingPoints = 0;
+ _capturing = false;
+ RedrawRequested?.Invoke(this, EventArgs.Empty);
+ }
+
+ ///
+ /// Defensive copy of the current buffer wrapped in a
+ /// . Cheap; call only when the
+ /// view needs to ship the data off (e.g. to a backend).
+ ///
+ public SignaturePadData Snapshot() => new(_strokes.ToArray());
+
+ // --- Test-only surface (visible to PostIt.Tests) -------------------
+
+ ///
+ /// Test hook: append a single normalised point without going
+ /// through the pointer pipeline. Does not raise
+ /// .
+ ///
+ internal void AppendPointForTest(int x, int y)
+ {
+ _strokes.Add(x);
+ _strokes.Add(y);
+ _pendingPoints++;
+ }
+
+ ///
+ /// Test hook: seal the currently-pending stroke with a length
+ /// prefix. Mirrors what does at
+ /// pointer release time, including the
+ /// and
+ /// events, so test scenarios observe the same notification
+ /// contract as production. Idempotent: a second call without
+ /// intermediate appends is a no-op.
+ ///
+ internal void SealStrokeForTest()
+ {
+ if (_pendingPoints == 0) return;
+ int sliceStart = _strokes.Count - 2 * _pendingPoints;
+ _strokes.Insert(sliceStart, _pendingPoints);
+ _pendingPoints = 0;
+ StrokeCompleted?.Invoke(this, Snapshot());
+ RedrawRequested?.Invoke(this, EventArgs.Empty);
+ }
+}
diff --git a/src/PostIt/PostIt/Models/SignaturePadData.cs b/src/PostIt/PostIt/Models/SignaturePadData.cs
new file mode 100644
index 00000000..11568eac
--- /dev/null
+++ b/src/PostIt/PostIt/Models/SignaturePadData.cs
@@ -0,0 +1,103 @@
+namespace PostIt.Models;
+
+///
+/// Serialized form of a signature captured by
+/// .
+///
+/// Wire format (length-prefixed, normalised):
+///
+/// int[] = [k0, x00, y00, x01, y01, ..., x0_{k0-1}, y0_{k0-1},
+/// k1, x10, y10, x11, y11, ..., x1_{k1-1}, y1_{k1-1},
+/// ...]
+///
+///
+/// - k_i — number of (x, y) pairs in stroke i.
+/// - x, y — coordinates normalised to [0, CoordinateMax]
+/// (inclusive) on the control's client area.
+/// is 10_000 by default — a 4-decimal fixed-point fraction of
+/// the surface, which is enough to discriminate 0.01% of the diagonal
+/// on any reasonable screen and stays well inside int.
+/// - Total array length is even: each stroke contributes
+/// 1 + 2 * k_i integers, and 1 + 2k is always odd.
+/// Sum of 1 + 2k_i over strokes is therefore odd * N, which
+/// is odd when N is odd and even when N is even — so the overall
+/// "size pair" property is not enforced, only the per-stroke shape
+/// is. If the consumer needs a strictly even total, pad the last
+/// stroke with a duplicate terminal point (or use
+/// to drop the array entirely).
+///
+///
+/// Empty signature (no strokes) is represented by an empty array
+/// (length 0). A single dot — pen down + pen up at the same point —
+/// is a single stroke with k = 1: [1, x, y].
+///
+public sealed class SignaturePadData
+{
+ ///
+ /// Upper bound of normalised coordinates. 10_000 means a
+ /// surface unit is represented as 0.0001 of the whole.
+ ///
+ public const int CoordinateMax = 10_000;
+
+ ///
+ /// Raw payload. See for the layout.
+ /// Never null; an empty array means "no strokes".
+ ///
+ public int[] Strokes { get; }
+
+ public SignaturePadData(int[] strokes)
+ {
+ if (strokes is null) throw new System.ArgumentNullException(nameof(strokes));
+ Strokes = strokes;
+ }
+
+ /// True if no stroke has been captured.
+ public bool IsEmpty => Strokes.Length == 0;
+
+ ///
+ /// Number of distinct strokes (pen-down / pen-up cycles).
+ /// Returns 0 when is true.
+ ///
+ public int StrokeCount
+ {
+ get
+ {
+ if (Strokes.Length == 0) return 0;
+ int n = 0;
+ int i = 0;
+ while (i < Strokes.Length)
+ {
+ int k = Strokes[i];
+ // Defensive: a malformed entry is treated as 0 so we
+ // never throw on read. The capture side never produces
+ // these, this is only for robustness on the wire.
+ if (k <= 0) return n;
+ i += 1 + 2 * k;
+ n++;
+ }
+ return n;
+ }
+ }
+
+ ///
+ /// Total number of (x, y) pairs across all strokes. Useful
+ /// for sanity-checks and for displaying capture density
+ /// without re-walking the wire format.
+ ///
+ public int PointCount
+ {
+ get
+ {
+ int n = 0;
+ int i = 0;
+ while (i < Strokes.Length)
+ {
+ int k = Strokes[i];
+ if (k <= 0) break;
+ n += k;
+ i += 1 + 2 * k;
+ }
+ return n;
+ }
+ }
+}
diff --git a/src/PostIt/PostIt/Services/OidcLoginPhase.cs b/src/PostIt/PostIt/Services/OidcLoginPhase.cs
index 0add3d76..c4787489 100644
--- a/src/PostIt/PostIt/Services/OidcLoginPhase.cs
+++ b/src/PostIt/PostIt/Services/OidcLoginPhase.cs
@@ -12,7 +12,7 @@ namespace PostIt.Services;
/// The set is deliberately small: each value is a milestone an
/// operator can grep for in logs / StatusMessage, not a heartbeat.
///
-public enum OidcLoginPhase
+public enum OIDCLoginPhase
{
/// No login in flight (or login has settled).
Idle,
diff --git a/src/PostIt/PostIt/Services/YavscApiClient.cs b/src/PostIt/PostIt/Services/YavscApiClient.cs
index 6710ebb8..0a42773b 100644
--- a/src/PostIt/PostIt/Services/YavscApiClient.cs
+++ b/src/PostIt/PostIt/Services/YavscApiClient.cs
@@ -91,15 +91,15 @@ public class YavscApiClient : IAsyncDisposable
/// for the human
/// text (URLs, error detail).
public async Task LoginInteractiveAsync(
- IProgress? progress = null,
+ IProgress? progress = null,
CancellationToken ct = default)
{
- progress?.Report(OidcLoginPhase.Discovering);
+ progress?.Report(OIDCLoginPhase.Discovering);
var browser = Platform.CreateBrowser?.Invoke();
if (browser is null)
{
- progress?.Report(OidcLoginPhase.Error);
+ progress?.Report(OIDCLoginPhase.Error);
throw new InvalidOperationException("No browser is available on this platform.");
}
@@ -114,20 +114,20 @@ public class YavscApiClient : IAsyncDisposable
// the moment we ask the browser to open (covers the entire
// user-driven window including the AwaitingCallback wait), and
// the moment we trade the code for tokens.
- progress?.Report(OidcLoginPhase.OpeningBrowser);
+ progress?.Report(OIDCLoginPhase.OpeningBrowser);
var result = await client.LoginAsync(new LoginRequest(), ct).ConfigureAwait(false);
if (result.IsError)
{
- progress?.Report(OidcLoginPhase.Error);
+ progress?.Report(OIDCLoginPhase.Error);
throw new InvalidOperationException($"OIDC login failed: {result.Error}");
}
- progress?.Report(OidcLoginPhase.ExchangingCode);
+ progress?.Report(OIDCLoginPhase.ExchangingCode);
if (string.IsNullOrEmpty(result.RefreshToken))
{
- progress?.Report(OidcLoginPhase.Error);
+ progress?.Report(OIDCLoginPhase.Error);
throw new InvalidOperationException(
"Missing refresh_token — vérifie le scope 'offline_access'.");
}
@@ -139,7 +139,7 @@ public class YavscApiClient : IAsyncDisposable
IdToken: result.IdentityToken);
_store.Save(_tokens);
- progress?.Report(OidcLoginPhase.Success);
+ progress?.Report(OIDCLoginPhase.Success);
}
///
@@ -152,7 +152,7 @@ public class YavscApiClient : IAsyncDisposable
/// phase and returns false so the UI can keep going.
///
public async Task TrySilentLoginAsync(
- IProgress? progress = null,
+ IProgress? progress = null,
CancellationToken ct = default)
{
if (!HasValidSession) return false;
@@ -161,7 +161,7 @@ public class YavscApiClient : IAsyncDisposable
// Access token still has plenty of life — nothing to do.
if (_tokens.AccessTokenExpiresAt - DateTimeOffset.UtcNow > RefreshSkew)
{
- progress?.Report(OidcLoginPhase.Success);
+ progress?.Report(OIDCLoginPhase.Success);
return true;
}
@@ -172,19 +172,19 @@ public class YavscApiClient : IAsyncDisposable
// the user back to the login page.
try
{
- progress?.Report(OidcLoginPhase.ExchangingCode);
+ progress?.Report(OIDCLoginPhase.ExchangingCode);
await ForceRefreshAsync(ct).ConfigureAwait(false);
- progress?.Report(OidcLoginPhase.Success);
+ progress?.Report(OIDCLoginPhase.Success);
return true;
}
catch (RefreshFailedException)
{
- progress?.Report(OidcLoginPhase.Idle);
+ progress?.Report(OIDCLoginPhase.Idle);
return false;
}
catch
{
- progress?.Report(OidcLoginPhase.Idle);
+ progress?.Report(OIDCLoginPhase.Idle);
return false;
}
}
@@ -205,6 +205,16 @@ public class YavscApiClient : IAsyncDisposable
return dto!;
}
+ ///
+ /// Call a JSON endpoint with no request body while still allowing a
+ /// positional cancellation token argument.
+ ///
+ public Task CallAsync(
+ HttpMethod method,
+ string path,
+ CancellationToken ct)
+ => CallAsync(method, path, body: null, ct);
+
/// Call an endpoint that returns no useful body (DELETE, etc.).
public async Task CallAsync(
HttpMethod method,
@@ -216,6 +226,16 @@ public class YavscApiClient : IAsyncDisposable
response.EnsureSuccessStatusCode();
}
+ ///
+ /// Call an endpoint with no request body while still allowing a
+ /// positional cancellation token argument.
+ ///
+ public Task CallAsync(
+ HttpMethod method,
+ string path,
+ CancellationToken ct)
+ => CallAsync(method, path, body: null, ct);
+
private async Task SendAsync(
HttpMethod method, string path, object? body, CancellationToken ct)
{
diff --git a/src/PostIt/PostIt/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs
index 9a05aa84..e6d0e91a 100644
--- a/src/PostIt/PostIt/ViewLocator.cs
+++ b/src/PostIt/PostIt/ViewLocator.cs
@@ -29,6 +29,7 @@ public class ViewLocator : IDataTemplate
SettingsPageViewModel => _services.GetRequiredService(),
LoginPageViewModel => _services.GetRequiredService(),
HomePageViewModel => _services.GetRequiredService(),
+ SignaturePageViewModel => _services.GetRequiredService(),
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
};
}
diff --git a/src/PostIt/PostIt/ViewModels/LoginPageViewModel.cs b/src/PostIt/PostIt/ViewModels/LoginPageViewModel.cs
index 009d67f2..a6ed595d 100644
--- a/src/PostIt/PostIt/ViewModels/LoginPageViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/LoginPageViewModel.cs
@@ -105,8 +105,8 @@ public partial class LoginPageViewModel : ViewModelBase
/// callback hand-off: when AwaitingCallback never resolves,
/// the OS never re-launched PostIt with the postit:// URL.
///
- private OidcLoginPhase _phase = OidcLoginPhase.Idle;
- public OidcLoginPhase Phase
+ private OIDCLoginPhase _phase = OIDCLoginPhase.Idle;
+ public OIDCLoginPhase Phase
{
get => _phase;
private set
@@ -122,13 +122,13 @@ public partial class LoginPageViewModel : ViewModelBase
///
public string PhaseLabel => _phase switch
{
- OidcLoginPhase.Idle => "En attente",
- OidcLoginPhase.Discovering => "Découverte OIDC…",
- OidcLoginPhase.OpeningBrowser => "Ouverture du navigateur…",
- OidcLoginPhase.AwaitingCallback => "En attente du callback postit://…",
- OidcLoginPhase.ExchangingCode => "Échange du code contre les jetons…",
- OidcLoginPhase.Success => "Connecté",
- OidcLoginPhase.Error => "Erreur",
+ OIDCLoginPhase.Idle => "En attente",
+ OIDCLoginPhase.Discovering => "Découverte OIDC…",
+ OIDCLoginPhase.OpeningBrowser => "Ouverture du navigateur…",
+ OIDCLoginPhase.AwaitingCallback => "En attente du callback postit://…",
+ OIDCLoginPhase.ExchangingCode => "Échange du code contre les jetons…",
+ OIDCLoginPhase.Success => "Connecté",
+ OIDCLoginPhase.Error => "Erreur",
_ => _phase.ToString(),
};
@@ -275,7 +275,7 @@ public partial class LoginPageViewModel : ViewModelBase
// The progress sink drives Phase / PhaseLabel; StatusMessage
// keeps the text detail (URLs, error messages). Same
// underlying flow, two views.
- var progress = new Progress(p => Phase = p);
+ var progress = new Progress(p => Phase = p);
await LoginInteractiveCoreAsync(_api, progress);
IsBusy = false;
@@ -299,7 +299,7 @@ public partial class LoginPageViewModel : ViewModelBase
///
private async Task LoginInteractiveCoreAsync(
YavscApiClient api,
- IProgress? progress = null)
+ IProgress? progress = null)
{
var original = Platform.CreateBrowser;
try
diff --git a/src/PostIt/PostIt/ViewModels/SignaturePageViewModel.cs b/src/PostIt/PostIt/ViewModels/SignaturePageViewModel.cs
new file mode 100644
index 00000000..b4b37974
--- /dev/null
+++ b/src/PostIt/PostIt/ViewModels/SignaturePageViewModel.cs
@@ -0,0 +1,185 @@
+using System;
+using System.IO;
+using System.Text;
+using System.Text.Json;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using PostIt.Controls;
+using PostIt.Models;
+
+namespace PostIt.ViewModels;
+
+///
+/// Backing state for .
+///
+/// The page exists to produce a
+/// (length-prefixed normalised int[]) from a human signature drawn
+/// with the mouse (Desktop) or finger (touch / Android). The page
+/// is a recipient of an external trigger — a SignalR push from
+/// Yavsc.Org telling PostIt "a devis has been sent, sign here" —
+/// so it intentionally has no first-class entry point in
+/// . The only "open" affordance today is a
+/// dev-only shortcut on the blog editor, marked for removal once
+/// the SignalR handler lands.
+///
+/// Output path is the platform-friendly per-user data directory
+/// (XDG_DATA_HOME / AppData / NSDocumentDirectory on iOS). Files
+/// are JSON, one per capture, named
+/// signature-{yyyyMMdd-HHmmssfff}.json. This is a stop-gap
+/// until the Yavsc.Org endpoint exists; the contract there will
+/// be POST /api/signature/{devisId} with this same payload.
+///
+public partial class SignaturePageViewModel : ViewModelBase
+{
+ ///
+ /// Default capture surface, in DIPs. 3:1 ratio matches a
+ /// signature line at the bottom of an A4 contract.
+ ///
+ public const double DefaultWidth = 600;
+ public const double DefaultHeight = 200;
+
+ [ObservableProperty]
+ public partial string StatusMessage { get; set; } = "Prêt.";
+
+ [ObservableProperty]
+ public partial int StrokeCount { get; set; }
+
+ [ObservableProperty]
+ public partial int PointCount { get; set; }
+
+ [ObservableProperty]
+ public partial string? LastCapturedPath { get; set; }
+
+ public double Width { get; }
+ public double Height { get; }
+
+ private SignaturePadControl? _control;
+
+ public override bool CanNavigateNext
+ {
+ get => false;
+ protected set { _ = value; }
+ }
+
+ public override bool CanNavigatePrevious
+ {
+ get => true;
+ protected set { _ = value; }
+ }
+
+ public SignaturePageViewModel()
+ : this(DefaultWidth, DefaultHeight)
+ {
+ }
+
+ public SignaturePageViewModel(double width, double height)
+ {
+ if (width <= 0) throw new ArgumentOutOfRangeException(nameof(width));
+ if (height <= 0) throw new ArgumentOutOfRangeException(nameof(height));
+ Width = width;
+ Height = height;
+ }
+
+ ///
+ /// Bind a freshly-constructed (or re-templated) control to this
+ /// VM. Called from the view's code-behind once the control has
+ /// been added to the visual tree and its template applied (so
+ /// is wired).
+ ///
+ public void Attach(SignaturePadControl control)
+ {
+ if (control is null) throw new ArgumentNullException(nameof(control));
+ Detach();
+ _control = control;
+ _control.RedrawRequested += OnRedraw;
+ _control.StrokeCompleted += OnStrokeCompleted;
+ RefreshCounts();
+ }
+
+ public void Detach()
+ {
+ if (_control is null) return;
+ _control.RedrawRequested -= OnRedraw;
+ _control.StrokeCompleted -= OnStrokeCompleted;
+ _control = null;
+ }
+
+ private void OnStrokeCompleted(object? sender, SignaturePadData data)
+ {
+ StatusMessage = $"Trait terminé. {data.StrokeCount} trait(s).";
+ RefreshCounts();
+ }
+
+ private void OnRedraw(object? sender, EventArgs e) => RefreshCounts();
+
+ private void RefreshCounts()
+ {
+ if (_control is null) return;
+ var snap = _control.Snapshot();
+ StrokeCount = snap.StrokeCount;
+ PointCount = snap.PointCount;
+ }
+
+ [RelayCommand]
+ public void Clear()
+ {
+ _control?.Clear();
+ StatusMessage = "Effacé.";
+ RefreshCounts();
+ }
+
+ [RelayCommand]
+ public async Task CaptureAsync()
+ {
+ if (_control is null)
+ {
+ StatusMessage = "Contrôle non attaché.";
+ return;
+ }
+
+ var data = _control.Snapshot();
+ if (data.IsEmpty)
+ {
+ StatusMessage = "Rien à capturer.";
+ return;
+ }
+
+ try
+ {
+ var path = WriteCapture(data);
+ LastCapturedPath = path;
+ StatusMessage = $"Capture enregistrée: {path}";
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Erreur: {ex.Message}";
+ }
+ await Task.CompletedTask;
+ }
+
+ private static string WriteCapture(SignaturePadData data)
+ {
+ var dir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "PostIt", "signatures");
+ Directory.CreateDirectory(dir);
+
+ var fileName = $"signature-{DateTime.UtcNow:yyyyMMdd-HHmmssfff}.json";
+ var path = Path.Combine(dir, fileName);
+
+ var payload = new
+ {
+ format = "yavsc.signature/v1",
+ coordinateMax = SignaturePadData.CoordinateMax,
+ capturedAtUtc = DateTime.UtcNow,
+ strokes = data.Strokes,
+ strokeCount = data.StrokeCount,
+ };
+ File.WriteAllText(
+ path,
+ JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }),
+ Encoding.UTF8);
+ return path;
+ }
+}
diff --git a/src/PostIt/PostIt/Views/MainPage.axaml b/src/PostIt/PostIt/Views/MainPage.axaml
index b9e0308d..c09c2f7a 100644
--- a/src/PostIt/PostIt/Views/MainPage.axaml
+++ b/src/PostIt/PostIt/Views/MainPage.axaml
@@ -35,6 +35,17 @@
+
+
diff --git a/src/PostIt/PostIt/Views/MainPage.axaml.cs b/src/PostIt/PostIt/Views/MainPage.axaml.cs
index 769234e3..1535d2f4 100644
--- a/src/PostIt/PostIt/Views/MainPage.axaml.cs
+++ b/src/PostIt/PostIt/Views/MainPage.axaml.cs
@@ -1,5 +1,8 @@
using Avalonia;
using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Microsoft.Extensions.DependencyInjection;
+using PostIt.ViewModels;
namespace PostIt.Views;
@@ -10,4 +13,30 @@ public partial class MainPage : ContentPage
InitializeComponent();
}
+ ///
+ /// DEV ONLY: temporary shortcut to open the signature capture
+ /// page from the blog editor. The production entry point is a
+ /// SignalR push from Yavsc.Org ("devis received, sign here"),
+ /// which is the only path that carries the devis identifier
+ /// needed to bind the capture to a specific contract.
+ ///
+ /// Remove this method and the corresponding button in
+ /// MainPage.axaml.cs once the SignalR handler lands.
+ ///
+ private void OpenSignatureDev(object? sender, RoutedEventArgs e)
+ {
+ // Resolve via the App's DI container so the page gets
+ // the canonical services (Api client, settings, ...).
+ var app = Application.Current as App;
+ var services = app?.Services;
+ if (services is null) return;
+
+ var page = services.GetRequiredService();
+ page.DataContext = services.GetRequiredService();
+
+ if (this.VisualRoot is MainWindow window)
+ {
+ _ = window.NavRoot.PushAsync(page);
+ }
+ }
}
diff --git a/src/PostIt/PostIt/Views/SignaturePage.axaml b/src/PostIt/PostIt/Views/SignaturePage.axaml
new file mode 100644
index 00000000..d868f2a4
--- /dev/null
+++ b/src/PostIt/PostIt/Views/SignaturePage.axaml
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PostIt/PostIt/Views/SignaturePage.axaml.cs b/src/PostIt/PostIt/Views/SignaturePage.axaml.cs
new file mode 100644
index 00000000..da6b8b7b
--- /dev/null
+++ b/src/PostIt/PostIt/Views/SignaturePage.axaml.cs
@@ -0,0 +1,91 @@
+using System;
+using System.Collections.Generic;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Shapes;
+using Avalonia.Media;
+using PostIt.Controls;
+using PostIt.ViewModels;
+
+namespace PostIt.Views;
+
+public partial class SignaturePage : ContentPage
+{
+ private static readonly IBrush StrokeBrush = new SolidColorBrush(Color.FromRgb(0x10, 0x10, 0x10));
+ private const double StrokeThickness = 2.0;
+ private const double CoordinateMax = 10_000.0;
+
+ private SignaturePageViewModel? _vm;
+ private SignaturePadControl? _control;
+
+ public SignaturePage()
+ {
+ InitializeComponent();
+
+ // Wire the capture area: the Pad itself is the control, the
+ // surrounding Border (PadFrame) is the hit-test region. We
+ // set CaptureArea once the control's template has been
+ // applied — for an inline control with no template, that
+ // happens on first measure, which is guaranteed before
+ // the user can interact, so attaching here is safe.
+ _control = Pad;
+ _control.CaptureArea = PadFrame;
+
+ DataContextChanged += (_, _) => RebindViewModel(DataContext as SignaturePageViewModel);
+ }
+
+ private void RebindViewModel(SignaturePageViewModel? vm)
+ {
+ if (_vm is not null)
+ {
+ _vm.Detach();
+ _control!.RedrawRequested -= OnRedrawRequested;
+ }
+
+ _vm = vm;
+
+ if (_vm is null || _control is null) return;
+
+ _vm.Attach(_control);
+ _control.RedrawRequested += OnRedrawRequested;
+ Repaint();
+ }
+
+ private void OnRedrawRequested(object? sender, EventArgs e) => Repaint();
+
+ private void Repaint()
+ {
+ if (_control is null || InkLayer is null) return;
+
+ InkLayer.Children.Clear();
+ var w = PadFrame.Bounds.Width;
+ var h = PadFrame.Bounds.Height;
+ if (w <= 0 || h <= 0) return;
+
+ var strokes = _control.Strokes;
+ int i = 0;
+ while (i < strokes.Count)
+ {
+ int k = strokes[i];
+ if (k <= 0) break;
+ i++; // skip the length prefix
+
+ var poly = new Polyline
+ {
+ Stroke = StrokeBrush,
+ StrokeThickness = StrokeThickness,
+ StrokeLineCap = PenLineCap.Round,
+ StrokeJoin = PenLineJoin.Round,
+ };
+ var pts = new List(k);
+ for (int p = 0; p < k; p++)
+ {
+ int nx = strokes[i++];
+ int ny = strokes[i++];
+ pts.Add(new Point(nx / CoordinateMax * w, ny / CoordinateMax * h));
+ }
+ poly.Points = pts;
+ InkLayer.Children.Add(poly);
+ }
+ }
+}
diff --git a/src/Yavsc.Abstract/Chat/ChatHubConstants.cs b/src/Yavsc.Abstract/Chat/ChatHubConstants.cs
index e68c719d..b54c00c7 100644
--- a/src/Yavsc.Abstract/Chat/ChatHubConstants.cs
+++ b/src/Yavsc.Abstract/Chat/ChatHubConstants.cs
@@ -14,11 +14,11 @@ namespace Yavsc.Abstract.Chat
public const string JustCreatedBy = "just created by ";
public const string LabYouNotOp = "you're no op.";
- public const string LabNoSuchUser = "No such user";
- public const string LabNoSuchChan = "No such chan";
+ public const string LabNoSuchUser = "No such user";
+ public const string LabNoSuchChan = "No such chan";
public const string HopWontKickOp = "Half operator cannot kick any operator";
public const string LabAuthChatUser = "Authenticated chat user";
public const string NoKickOnCop = "No, you won´t, you´ĺl never do kick a cop, it is the bad.";
- public const string LabnoJoinNoSend = "LabnoJoinNoSend";
+ public const string LabNoJoinNoSend = "LabnoJoinNoSend";
}
-}
\ No newline at end of file
+}
diff --git a/src/Yavsc.Abstract/Interfaces/IBillingService.cs b/src/Yavsc.Abstract/Interfaces/IBillingService.cs
index 5c99212a..3ba7fc59 100644
--- a/src/Yavsc.Abstract/Interfaces/IBillingService.cs
+++ b/src/Yavsc.Abstract/Interfaces/IBillingService.cs
@@ -17,8 +17,8 @@ namespace Yavsc.Services
///
/// Renvoye la facture associée à une clé de facturation,
/// à partir du couple suivant :
- ///
- /// * un code de facturation
+ ///
+ /// * un code de facturation
/// (identifiant associé à un type de demande du client)
/// * un entier long identifiant la demande du client
/// (à une demande, on associe au maximum une seule facture)
@@ -26,10 +26,10 @@ namespace Yavsc.Services
/// Identifiant du type de facturation
/// Identifiant de la demande du client
/// La facture
- Task GetBillAsync(string billingCode, long queryId);
-
+ Task GetBillAsync(string billingCode, long queryId);
+
///
- /// Perfomer settings for the specified performer in the activity
+ /// Perfomer settings for the specified performer in the activity
///
/// activityCode
/// performer uid
diff --git a/src/Yavsc.Abstract/Resources/Yavsc.ChatHubLabels.Designer.cs b/src/Yavsc.Abstract/Resources/Yavsc.ChatHubLabels.Designer.cs
deleted file mode 100644
index bdfc3776..00000000
--- a/src/Yavsc.Abstract/Resources/Yavsc.ChatHubLabels.Designer.cs
+++ /dev/null
@@ -1,82 +0,0 @@
-// ------------------------------------------------------------------------------
-//
-// This code was generated by a tool.
-// Mono Runtime Version: 4.0.30319.42000
-//
-// Changes to this file may cause incorrect behavior and will be lost if
-// the code is regenerated.
-//
-// ------------------------------------------------------------------------------
-
-namespace Yavsc {
- using System;
- using System.Reflection;
-
-
- [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
- [System.Diagnostics.DebuggerNonUserCodeAttribute()]
- [System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
- public partial class ChatHubLabels {
-
- private static System.Resources.ResourceManager resourceMan;
-
- private static System.Globalization.CultureInfo resourceCulture;
-
- [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)]
- public static System.Resources.ResourceManager ResourceManager {
- get {
- if (object.Equals(null, resourceMan)) {
- System.Resources.ResourceManager temp = new System.Resources.ResourceManager(("Yavsc.Abstract.Resources." + "Yavsc.ChatHub"), typeof(ChatHubLabels).GetTypeInfo().Assembly);
- resourceMan = temp;
- }
- return resourceMan;
- }
- }
-
- [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)]
- public static System.Globalization.CultureInfo Culture {
- get {
- return resourceCulture;
- }
- set {
- resourceCulture = value;
- }
- }
-
- public static string Authenticated_chat_user {
- get {
- return ResourceManager.GetString("Authenticated chat user", resourceCulture);
- }
- }
-
- public static string LabnoJoinNoSend {
- get {
- return ResourceManager.GetString("LabnoJoinNoSend", resourceCulture);
- }
- }
-
- public static string InvalidRoomName {
- get {
- return ResourceManager.GetString("InvalidRoomName", resourceCulture);
- }
- }
-
- public static string InvalidUserName {
- get {
- return ResourceManager.GetString("InvalidUserName", resourceCulture);
- }
- }
-
- public static string InvalidMessage {
- get {
- return ResourceManager.GetString("InvalidMessage", resourceCulture);
- }
- }
-
- public static string InvalidReason {
- get {
- return ResourceManager.GetString("InvalidReason", resourceCulture);
- }
- }
- }
-}
diff --git a/src/Yavsc.Abstract/Workflow/INominativeQuery.cs b/src/Yavsc.Abstract/Workflow/INominativeQuery.cs
index f0adebdc..d498e28d 100644
--- a/src/Yavsc.Abstract/Workflow/INominativeQuery.cs
+++ b/src/Yavsc.Abstract/Workflow/INominativeQuery.cs
@@ -4,8 +4,6 @@ namespace Yavsc.Abstract.Workflow
{
public interface IDecidableQuery: ITrackedEntity, IQuery
{
- bool Decided { get; set; }
- bool Accepted { get; set; }
-
+
}
}
diff --git a/src/Yavsc.Abstract/Workflow/QueryStatus.cs b/src/Yavsc.Abstract/Workflow/QueryStatus.cs
index e440ebbc..86104376 100644
--- a/src/Yavsc.Abstract/Workflow/QueryStatus.cs
+++ b/src/Yavsc.Abstract/Workflow/QueryStatus.cs
@@ -9,12 +9,9 @@ namespace Yavsc
public enum QueryStatus: int
{
Inserted,
- OwnerValidated,
- Visited,
Rejected,
Accepted,
InProgress,
-
// final states
Failed,
Success
diff --git a/src/Yavsc.Api/Controllers/Business/BillingController.cs b/src/Yavsc.Api/Controllers/Business/BillingController.cs
index 25844955..65a6dbcf 100644
--- a/src/Yavsc.Api/Controllers/Business/BillingController.cs
+++ b/src/Yavsc.Api/Controllers/Business/BillingController.cs
@@ -5,6 +5,8 @@ using Newtonsoft.Json;
using System.Security.Claims;
using Yavsc.Helpers;
using Yavsc.ViewModels;
+using Yavsc.Models.Billing;
+using Yavsc.Server.Models.FileSystem;
namespace Yavsc.ApiControllers
{
@@ -181,5 +183,203 @@ namespace Yavsc.ApiControllers
if (!fi.Exists) return NotFound(new { Error = "Professional signature not found" });
return File(fi.OpenRead(), "application/x-pdf", filename); ;
}
+
+ ///
+ /// Capture a signature for an estimate, in the JSON
+ /// wire format produced by PostIt (see
+ /// PostIt.Models.SignaturePadData). The legacy
+ /// POST prosign / POST clisign endpoints
+ /// take a PNG ; this one takes a
+ /// JSON body so the capture happens entirely in-app on
+ /// the client side, without a rasterisation step.
+ ///
+ /// The route is intentionally a sibling of the
+ /// legacy endpoints, not a replacement: the legacy
+ /// PNG-based flow stays in place to keep the TeX
+ /// invoice templates (Bill_tex.cshtml,
+ /// Estimate_tex.cshtml) working until the
+ /// migration commit regenerates PNGs from the JSON
+ /// payload. The two flows share the
+ /// table for storage but not
+ /// the URL surface.
+ ///
+ [HttpPost("estimate/{id:long}/sign")]
+ [ValidateAntiForgeryToken]
+ [Consumes("application/json")]
+ [ProducesResponseType(StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task Sign(
+ [FromRoute] long id,
+ [FromBody] SignatureSubmission body,
+ CancellationToken token)
+ {
+ if (body is null) return BadRequest(new { Error = "missing body" });
+ if (body.Strokes is null) return BadRequest(new { Error = "missing strokes" });
+ if (string.IsNullOrEmpty(body.SignerUserId))
+ return BadRequest(new { Error = "missing signerUserId" });
+
+ var estimate = await dbContext.Estimates
+ .Include(e => e.Client)
+ .FirstOrDefaultAsync(e => e.Id == id, token);
+ if (estimate is null) return NotFound(new { Error = "estimate not found" });
+
+ // The signer is identified by userId in the body, not
+ // by the bearer token, because the OAuth scope we
+ // carry is for the API client (PostIt), not the end
+ // user. We trust the body's userId to match either
+ // Owner or Client, and reject everything else.
+ var userId = body.SignerUserId;
+ if (userId != estimate.OwnerId && userId != estimate.ClientId)
+ return Forbid();
+
+ // Map userId → type. The Pro/Client split is the
+ // same one the legacy prosign/clisign endpoints use;
+ // keeping the rule here means the Signature table
+ // and the legacy ProviderValidationDate/ClientValidationDate
+ // columns can co-exist without contradicting each other.
+ var type = userId == estimate.OwnerId
+ ? SignatureType.Pro
+ : SignatureType.Client;
+
+ var payload = new SignaturePadPayload
+ {
+ CoordinateMax = body.CoordinateMax,
+ CapturedAtUtc = body.CapturedAtUtc ?? DateTime.UtcNow,
+ Strokes = body.Strokes,
+ };
+
+ // Disk write first: a disk failure shouldn't leave
+ // a Signature row pointing at a file that doesn't
+ // exist. The file helper throws on filesystem
+ // problems and propagates here.
+ FileReceivedInfo fi;
+ try
+ {
+ fi = await User.ReceiveEstimateSignatureAsync(id, type, payload, token);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "estimate {Id}: signature file write failed", id);
+ return BadRequest(new { Error = "file write failed", Detail = ex.Message });
+ }
+
+ // Find-or-add: the (EstimateId, Type) pair is
+ // unique, so a second POST for the same side of the
+ // estimate replaces the previous signature. EF
+ // translates this into a single UPDATE when the
+ // row exists and an INSERT otherwise; the unique
+ // index in ApplicationDbContext is the
+ // database-level guarantee that the contract
+ // holds if two requests race.
+ var signature = await dbContext.Signatures
+ .FirstOrDefaultAsync(s => s.EstimateId == id && s.Type == type, token);
+
+ if (signature is null)
+ {
+ signature = new Signature
+ {
+ EstimateId = id,
+ SignerId = userId,
+ Type = type,
+ };
+ dbContext.Signatures.Add(signature);
+ }
+ else
+ {
+ // Roll the signer's quota back by the size of
+ // the file we're about to orphan: the old
+ // FilePath is no longer referenced once we
+ // overwrite FilePath below.
+ try
+ {
+ var orphan = new FileInfo(signature.FilePath);
+ if (orphan.Exists)
+ {
+ var signerForOrphan = await dbContext.Users
+ .FirstOrDefaultAsync(u => u.Id == userId, token);
+ if (signerForOrphan is not null)
+ signerForOrphan.DiskUsage =
+ Math.Max(0, signerForOrphan.DiskUsage - orphan.Length);
+ }
+ }
+ catch { /* best effort — the file is being replaced anyway */ }
+ }
+
+ signature.SignerId = userId;
+ signature.CoordinateMax = payload.CoordinateMax;
+ signature.Strokes = payload.Strokes;
+ signature.CapturedAtUtc = payload.CapturedAtUtc;
+ signature.FilePath = Path.Combine(fi.DestDir, fi.FileName);
+
+ // Bump the signer's quota. The Signature row's
+ // SignerId is the IdentityUser.Id (a string), so we
+ // look up by Id and not by username.
+ var signer = await dbContext.Users
+ .FirstOrDefaultAsync(u => u.Id == userId, token);
+ if (signer is not null)
+ {
+ signer.DiskUsage += new FileInfo(signature.FilePath).Length;
+ }
+
+ try
+ {
+ await dbContext.SaveChangesAsync(token);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "estimate {Id}: signature db write failed", id);
+ // Best-effort rollback: remove the file we wrote
+ // so disk and db don't disagree.
+ try { System.IO.File.Delete(signature.FilePath); }
+ catch { /* swallow — the row will be re-orphaned, the user re-signs */ }
+ return BadRequest(new { Error = "db write failed", Detail = ex.Message });
+ }
+
+ var location = Url.Action(nameof(Sign), new { id })
+ ?? $"/api/bill/estimate/{id}/sign";
+ return Created(location, new
+ {
+ id = signature.Id,
+ estimateId = signature.EstimateId,
+ type = signature.Type.ToString(),
+ capturedAtUtc = signature.CapturedAtUtc,
+ coordinateMax = signature.CoordinateMax,
+ });
+ }
}
}
+
+///
+/// JSON body of POST /api/bill/estimate/{id}/sign. The
+/// shape mirrors what PostIt sends; the signerUserId
+/// field disambiguates which side of the estimate signed
+/// because the bearer token belongs to the PostIt OAuth
+/// client, not the end user.
+///
+public class SignatureSubmission
+{
+ ///
+ /// ApplicationUser.Id of the signer. Must equal
+ /// Estimate.OwnerId for a Pro signature or
+ /// Estimate.ClientId for a Client signature.
+ ///
+ public string SignerUserId { get; set; }
+
+ ///
+ /// Wire-format strokes. See
+ /// PostIt.Models.SignaturePadData.
+ ///
+ public int[] Strokes { get; set; } = Array.Empty();
+
+ public int CoordinateMax { get; set; } = 10_000;
+
+ ///
+ /// Client-reported capture time. The server may override
+ /// this with DateTime.UtcNow if the client is
+ /// caught lying about clock skew, but the default is to
+ /// trust the client.
+ ///
+ public DateTime? CapturedAtUtc { get; set; }
+}
diff --git a/src/Yavsc.Api/Controllers/Business/BookQueryApiController.cs b/src/Yavsc.Api/Controllers/Business/BookQueryApiController.cs
index ac1630e8..24fa3c28 100644
--- a/src/Yavsc.Api/Controllers/Business/BookQueryApiController.cs
+++ b/src/Yavsc.Api/Controllers/Business/BookQueryApiController.cs
@@ -41,7 +41,7 @@ namespace Yavsc.Controllers
{
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
var now = DateTime.Now;
-
+
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).
@@ -49,12 +49,12 @@ namespace Yavsc.Controllers
{
Client = new ClientProviderInfo {
UserName = c.Client.UserName,
- UserId = c.ClientId,
+ UserId = c.ClientId,
Avatar = c.Client.Avatar },
Location = c.Location,
EventDate = c.EventDate,
Id = c.Id,
- Previsional = c.Previsional,
+ Previsional = c.Provisional,
Reason = c.Reason,
ActivityCode = c.ActivityCode,
BillingCode = BillingCodes.Rdv
diff --git a/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs b/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs
index 935de524..b91cba51 100644
--- a/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs
+++ b/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs
@@ -34,8 +34,8 @@ namespace Yavsc.ApiControllers
if (queryId == 0) return BadRequest("queryId");
var billing = BillingService.GetBillable(dbContext, billingCode, queryId);
if (billing == null) return BadRequest();
- billing.Decided = true;
- billing.Accepted = false;
+
+ billing.Status = QueryStatus.Rejected;
dbContext.SaveChanges();
return Ok();
}
@@ -47,8 +47,7 @@ namespace Yavsc.ApiControllers
if (queryId == 0) return BadRequest("queryId");
var billing = BillingService.GetBillable(dbContext, billingCode, queryId);
if (billing == null) return BadRequest();
- billing.Accepted = true;
- billing.Decided = true;
+ billing.Status = QueryStatus.Accepted;
dbContext.SaveChanges();
return Ok();
}
diff --git a/src/Yavsc.Api/Controllers/HairCut/HairCutController.cs b/src/Yavsc.Api/Controllers/HairCut/HairCutController.cs
index 12a93758..927c0acc 100644
--- a/src/Yavsc.Api/Controllers/HairCut/HairCutController.cs
+++ b/src/Yavsc.Api/Controllers/HairCut/HairCutController.cs
@@ -41,7 +41,7 @@ namespace Yavsc.ApiControllers
// user, as a client
public IActionResult Index()
{
-
+
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
var now = DateTime.Now;
@@ -151,7 +151,7 @@ namespace Yavsc.ApiControllers
{
HairCutQuery query = await _context.HairCutQueries.Include(q => q.Client).
- Include(q => q.Client.PostalAddress).Include(q => q.Prestation).Include(q=>q.Regularisation)
+ Include(q => q.Client.PostalAddress).Include(q => q.Prestation).Include(q=>q.Regularization)
.SingleAsync(q => q.Id == id);
if (query.PaymentId!=null)
return new BadRequestObjectResult(new { error = "An existing payment process already exists" });
diff --git a/src/Yavsc.Blogs/Controllers/FileSystemStream.cs b/src/Yavsc.Blogs/Controllers/FileSystemStreamController.cs
similarity index 98%
rename from src/Yavsc.Blogs/Controllers/FileSystemStream.cs
rename to src/Yavsc.Blogs/Controllers/FileSystemStreamController.cs
index 799d6ffb..23cf0cc6 100644
--- a/src/Yavsc.Blogs/Controllers/FileSystemStream.cs
+++ b/src/Yavsc.Blogs/Controllers/FileSystemStreamController.cs
@@ -3,13 +3,13 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Yavsc.Attributes.Validation;
-using Yavsc.Helpers;
using Yavsc.Models;
using Yavsc.Models.Messaging;
using Yavsc.Services;
using Microsoft.AspNetCore.SignalR;
using Yavsc.Server.Helpers;
using static Yavsc.Blogs.Constants;
+using Yavsc.Server.Hubs;
namespace Yavsc.Blogs.Controllers
{
@@ -46,7 +46,7 @@ namespace Yavsc.Blogs.Controllers
}
logger.LogInformation("validated: api/stream/Put: "+filename);
var userName = User.GetUserName();
-
+
string url = string.Format(
"{0}/{1}/{2}",
Config.UserFilesOptions.RequestPath.ToUriComponent(),
@@ -54,7 +54,7 @@ namespace Yavsc.Blogs.Controllers
filename
);
-
+
string destDir = HttpContext.User.EnsureDestinationDirectory(filePath);
logger.LogInformation($"Saving flow to {destDir}");
var userId = User.GetUserId();
@@ -65,7 +65,7 @@ namespace Yavsc.Blogs.Controllers
sender = userName,
url = url,
}, $"{userName} is starting a stream!");
-
+
await liveProcessor.AcceptStream(HttpContext, user, destDir, shortFileName);
return Ok();
}
diff --git a/src/Yavsc.Org.Tests/Controllers/CommandFormsControllerTests.cs b/src/Yavsc.Org.Tests/Controllers/CommandFormsControllerTests.cs
new file mode 100644
index 00000000..f32893c7
--- /dev/null
+++ b/src/Yavsc.Org.Tests/Controllers/CommandFormsControllerTests.cs
@@ -0,0 +1,38 @@
+using System.Net;
+using System.Net.Http;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Xunit;
+
+namespace Yavsc.Org.Tests.Controllers;
+
+public class CommandFormsControllerTests : IClassFixture
+{
+ private readonly TestWebApplicationFactory _factory;
+
+ public CommandFormsControllerTests(TestWebApplicationFactory factory)
+ {
+ _factory = factory;
+ }
+
+ private HttpClient CreateAdminClient()
+ {
+ var http = _factory.CreateClient(new WebApplicationFactoryClientOptions
+ {
+ HandleCookies = true,
+ });
+ http.DefaultRequestHeaders.Add(TestAuthPolicyProvider.HeaderName, TestAuthPolicyProvider.AdminRole);
+ return http;
+ }
+
+ [Fact]
+ public async Task Create_GET_returns_200_for_admin()
+ {
+ var http = CreateAdminClient();
+ var response = await http.GetAsync("/CommandForms/Create", TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
+ Assert.Contains("Create", body);
+ }
+}
diff --git a/src/Yavsc.Org.Tests/EstimateSignatureFileHelperTests.cs b/src/Yavsc.Org.Tests/EstimateSignatureFileHelperTests.cs
new file mode 100644
index 00000000..9de881af
--- /dev/null
+++ b/src/Yavsc.Org.Tests/EstimateSignatureFileHelperTests.cs
@@ -0,0 +1,134 @@
+using System;
+using System.IO;
+using System.Security.Claims;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Xunit;
+using Yavsc.Models;
+using Yavsc.Models.Billing;
+using Yavsc.Server.Helpers;
+using Yavsc.Server.Models.FileSystem;
+
+namespace Yavsc.Org.Tests;
+
+///
+/// Tests for the static
+/// helper. Scope is intentionally narrow: the file-naming format,
+/// the strokes counter, and the on-disk write path. The controller
+/// (authz, db persistence, signalR notification) is out of scope
+/// for this commit and will get a dedicated integration test once
+/// the Yavsc.Api test project is set up.
+///
+public class EstimateSignatureFileHelperTests : IDisposable
+{
+ private readonly string _tempRoot;
+
+ public EstimateSignatureFileHelperTests()
+ {
+ // UserFilesDirName is a process-wide static; we redirect
+ // it to a per-test temp dir so concurrent tests don't
+ // collide and the host filesystem is not littered.
+ _tempRoot = Path.Combine(
+ Path.GetTempPath(),
+ "yavsc-sig-tests-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_tempRoot);
+ AbstractFileSystemHelpers.UserFilesDirName = _tempRoot;
+ }
+
+ public void Dispose()
+ {
+ try { Directory.Delete(_tempRoot, recursive: true); }
+ catch { /* best effort — the OS will clean Temp eventually */ }
+ }
+
+ [Fact]
+ public void FileNameFormat_lowercases_type_and_includes_estimateId_and_ticks()
+ {
+ var name = EstimateSignatureFileHelper.FileNameFormat(
+ SignatureType.Pro, 42, 638_000_000_000_000_000L);
+ Assert.Equal("sign-pro-42-638000000000000000.json", name);
+
+ var cli = EstimateSignatureFileHelper.FileNameFormat(
+ SignatureType.Client, 7, 1L);
+ Assert.Equal("sign-client-7-1.json", cli);
+ }
+
+ [Theory]
+ [InlineData(new int[] { }, 0)]
+ [InlineData(new[] { 1, 100, 200 }, 1)]
+ [InlineData(new[] { 2, 1, 2, 3, 4 }, 1)]
+ [InlineData(new[] { 1, 1, 1, 2, 2, 3, 3 }, 2)]
+ [InlineData(new[] { 0, 1, 2, 3 }, 0)] // malformed k=0: short-circuit
+ public void ReceiveEstimateSignatureAsync_writes_a_v1_envelope(int[] strokes, int expectedStrokeCount)
+ {
+ // We don't read the count back from the helper (it's a
+ // private method), but the JSON envelope must reflect
+ // it; this verifies the public behaviour end-to-end.
+ _ = expectedStrokeCount;
+ // Arrange
+ var user = MakeUser("alice");
+ var payload = new SignaturePadPayload
+ {
+ CoordinateMax = 10_000,
+ CapturedAtUtc = new DateTime(2026, 7, 4, 12, 0, 0, DateTimeKind.Utc),
+ Strokes = strokes,
+ };
+
+ // Act
+ var fi = Run(user, 123L, SignatureType.Pro, payload);
+
+ // Assert: file exists, sits under the user's root, and
+ // parses as a yavsc.signature/v1 envelope.
+ var fullPath = Path.Combine(fi.DestDir, fi.FileName);
+ Assert.True(File.Exists(fullPath), $"missing: {fullPath}");
+
+ using var doc = JsonDocument.Parse(File.ReadAllText(fullPath));
+ var root = doc.RootElement;
+ Assert.Equal("yavsc.signature/v1", root.GetProperty("format").GetString());
+ Assert.Equal(10_000, root.GetProperty("coordinateMax").GetInt32());
+ Assert.Equal(123L, root.GetProperty("estimateId").GetInt64());
+ Assert.Equal("Pro", root.GetProperty("type").GetString());
+ Assert.Equal("alice", root.GetProperty("signerName").GetString());
+ Assert.Equal(expectedStrokeCount, root.GetProperty("strokeCount").GetInt32());
+ }
+
+ [Fact]
+ public async Task ReceiveEstimateSignatureAsync_rejects_null_payload()
+ {
+ var user = MakeUser("bob");
+ await Assert.ThrowsAsync(() =>
+ EstimateSignatureFileHelper.ReceiveEstimateSignatureAsync(
+ user, 1L, SignatureType.Pro, payload: null!));
+ }
+
+ [Fact]
+ public async Task ReceiveEstimateSignatureAsync_rejects_non_positive_estimateId()
+ {
+ var user = MakeUser("bob");
+ var payload = new SignaturePadPayload { Strokes = new[] { 1, 100, 100 } };
+ await Assert.ThrowsAsync(() =>
+ EstimateSignatureFileHelper.ReceiveEstimateSignatureAsync(
+ user, 0L, SignatureType.Pro, payload));
+ }
+
+ // --- helpers ----------------------------------------------------
+
+ private static FileReceivedInfo Run(
+ ClaimsPrincipal user, long estimateId, SignatureType type, SignaturePadPayload payload)
+ {
+ // The helper is async; tests that don't care about the
+ // result can call it sync via .GetAwaiter().GetResult()
+ // because we know it never throws in the happy path.
+ return EstimateSignatureFileHelper
+ .ReceiveEstimateSignatureAsync(user, estimateId, type, payload, CancellationToken.None)
+ .GetAwaiter().GetResult();
+ }
+
+ private static ClaimsPrincipal MakeUser(string username)
+ {
+ return new ClaimsPrincipal(new ClaimsIdentity(
+ new[] { new Claim(ClaimTypes.Name, username) },
+ authenticationType: "test"));
+ }
+}
diff --git a/src/Yavsc.Org.Tests/NonRegression/BillingServiceTests.cs b/src/Yavsc.Org.Tests/NonRegression/BillingServiceTests.cs
index 0309ba8e..bf40438a 100644
--- a/src/Yavsc.Org.Tests/NonRegression/BillingServiceTests.cs
+++ b/src/Yavsc.Org.Tests/NonRegression/BillingServiceTests.cs
@@ -36,11 +36,11 @@ namespace Yavsc
{
WorkflowHelpers.ConfigureBillingService();
- var firstRegistrar = new Func((db, id) =>
- db.HairCutQueries.Include(q => q.Prestation).Include(q => q.Regularisation).Single(q => q.Id == id));
+ var firstRegistrar = new Func((db, id) =>
+ db.HairCutQueries.Include(q => q.Prestation).Include(q => q.Regularization).Single(q => q.Id == id));
const string testCode = "Brush";
-
+
Assert.Throws(() =>
WorkflowHelpers.RegisterBilling(testCode, firstRegistrar));
}
diff --git a/src/Yavsc.Org/Controllers/Accounting/ManageController.cs b/src/Yavsc.Org/Controllers/Accounting/ManageController.cs
index 9e3b4310..4f32a438 100644
--- a/src/Yavsc.Org/Controllers/Accounting/ManageController.cs
+++ b/src/Yavsc.Org/Controllers/Accounting/ManageController.cs
@@ -93,10 +93,10 @@ namespace Yavsc.Controllers
: "";
var user = await GetCurrentUserAsync();
-
+
long pc = _dbContext.BlogSpot.Count(x => x.AuthorId == user.Id);
-
-
+
+
var model = new IndexViewModel
{
@@ -123,14 +123,14 @@ namespace Yavsc.Controllers
AllowMonthlyEmail = user.AllowMonthlyEmail,
Address = user.PostalAddress?.Address
};
-
+
model.HaveProfessionalSettings = _dbContext.Performers.Any(x => x.PerformerId == user.Id);
var usrActs = _dbContext.UserActivities.Include(a=>a.Does).Where(a=> a.UserId == user.Id).ToArray();
// TODO remember me who this magical a.Settings is built
var usrActToSet = usrActs.Where( a => ( a.Settings == null && a.Does.SettingsClassName != null )).ToArray();
model.HaveActivityToConfigure = usrActToSet .Count()>0;
model.Activity = _dbContext.UserActivities.Include(a=>a.Does).Where(u=>u.UserId == user.Id).ToList();
-
+
return View(model);
}
@@ -152,7 +152,7 @@ namespace Yavsc.Controllers
var user = await GetCurrentUserAsync();
user.AllowMonthlyEmail = model.Allow;
await this._dbContext.SaveChangesAsync(User.GetUserId());
-
+
return RedirectToAction(nameof(Index), new { Message = ManageMessageId.SetMonthlyEmailSuccess });
}
@@ -302,8 +302,8 @@ namespace Yavsc.Controllers
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
var calendars = await _calendarManager.GetCalendarsAsync(pageToken);
- return View(new SetGoogleCalendarViewModel {
- ReturnUrl = returnUrl,
+ return View(new SetGoogleCalendarViewModel {
+ ReturnUrl = returnUrl,
Calendars = calendars
});
}
@@ -343,9 +343,9 @@ namespace Yavsc.Controllers
)) return BadRequest(new { message = "data already present" });
user.BankInfo.Add(model);
-
+
_dbContext.Update(user);
-
+
await _dbContext.SaveChangesAsync();
}
return RedirectToAction(nameof(Index), new { Message = ManageMessageId.SetBankInfoSuccess });
@@ -495,7 +495,7 @@ namespace Yavsc.Controllers
return View("Error");
}
var userLogins = await _userManager.GetLoginsAsync(user);
-
+
ViewBag.ShowRemoveButton = user.PasswordHash != null || userLogins.Count > 1;
return View(new ManageLoginsViewModel
diff --git a/src/Yavsc.Org/Controllers/Contracting/CommandFormsController.cs b/src/Yavsc.Org/Controllers/Contracting/CommandFormsController.cs
index 4428105e..f59666fc 100644
--- a/src/Yavsc.Org/Controllers/Contracting/CommandFormsController.cs
+++ b/src/Yavsc.Org/Controllers/Contracting/CommandFormsController.cs
@@ -1,20 +1,24 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
-using Yavsc.Helpers;
+using Microsoft.Extensions.Localization;
using Yavsc.Models;
using Yavsc.Models.Workflow;
using Yavsc.Server.Helpers;
+using Yavsc.Services;
namespace Yavsc.Controllers
{
public class CommandFormsController : Controller
{
private readonly ApplicationDbContext _context;
+ private readonly IStringLocalizer _localizer;
- public CommandFormsController(ApplicationDbContext context)
+ public CommandFormsController(ApplicationDbContext context,
+ IStringLocalizer localizer)
{
_context = context;
+ _localizer = localizer;
}
// GET: CommandForms
@@ -47,11 +51,14 @@ namespace Yavsc.Controllers
SetViewBag();
return View();
}
+
private void SetViewBag(CommandForm commandForm = null)
{
ViewBag.ActivityCode = new SelectList(_context.Activities, "Code", "Name", commandForm?.ActivityCode);
- ViewBag.ActionName = _context.CommandForm.Select(c => new SelectListItem { Value = c.Id.ToString(), Text = c.Title, Selected = commandForm.Id == c.Id });
+ ViewBag.ActionName = BillingService.Billing.Keys
+ .Select((string b) => new SelectListItem { Value = b, Text = _localizer[b] }).ToList();
}
+
// POST: CommandForms/Create
[HttpPost]
[ValidateAntiForgeryToken]
diff --git a/src/Yavsc.Org/Controllers/Contracting/FrontOfficeController.cs b/src/Yavsc.Org/Controllers/Contracting/FrontOfficeController.cs
index b2a1cd22..b86d0ab4 100644
--- a/src/Yavsc.Org/Controllers/Contracting/FrontOfficeController.cs
+++ b/src/Yavsc.Org/Controllers/Contracting/FrontOfficeController.cs
@@ -36,16 +36,16 @@ namespace Yavsc.Controllers
public ActionResult Index()
{
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
- var now = DateTime.Now;
+ var now = DateTime.UtcNow;
var model = new FrontOfficeIndexViewModel
{
- EstimateToProduceCount = _context.RdvQueries.Where(c => c.PerformerId == uid && c.EventDate > now
- && c.ValidationDate == null && !_context.Estimates.Any(e => (e.CommandId == c.Id && e.ProviderValidationDate != null))).Count(),
- EstimateToSignAsProCount = _context.RdvQueries.Where(c => (c.PerformerId == uid && c.EventDate > now
- && c.ValidationDate == null && _context.Estimates.Any(e => (e.CommandId == c.Id && e.ProviderValidationDate != null)))).Count(),
- EstimateToSignAsCliCount = _context.Estimates.Where(e => e.ClientId == uid && e.ClientValidationDate == null).Count(),
- BillToSignAsProCount = 0,
+ EstimateToProduceCount = _context.RdvQueries.Where(c => c.PerformerId == uid && c.EventDate > now && c.Status == QueryStatus.Inserted
+ && c.ValidationDate == null && !_context.Estimates.Any(e => e.CommandId == c.Id)).Count(),
+ EstimateToHonorAsProCount = _context.RdvQueries.Where(c => c.PerformerId == uid && c.EventDate > now && c.Status == QueryStatus.Accepted
+ && c.ValidationDate == null && _context.Estimates.Any(e => e.CommandId == c.Id )).Count(),
+ EstimateToSignAsCliCount = _context.Estimates.Where(e => e.ClientId == uid && e.Query.Status == QueryStatus.Accepted).Count(),
+
BillToSignAsCliCount = 0,
NewPayementsCount = 0
};
@@ -65,14 +65,14 @@ namespace Yavsc.Controllers
}
[AllowAnonymous]
- public async Task HairCut(string id)
+ public async Task ListPerformersAsync(string activityCode)
{
- if (id == null)
+ if (activityCode == null)
{
throw new NotImplementedException("No Activity code");
}
- ViewBag.Activity = await _context.Activities.FirstOrDefaultAsync(a => a.Code == id);
- var result = await _context.ListPerformersAsync(_billing, id);
+ ViewBag.Activity = await _context.Activities.FirstOrDefaultAsync(a => a.Code == activityCode);
+ var result = await _context.ListPerformersAsync(_billing, activityCode);
return View(result);
}
diff --git a/src/Yavsc.Org/Controllers/Contracting/GeneralSettingsController.cs b/src/Yavsc.Org/Controllers/Contracting/GeneralSettingsController.cs
index 1678f2a7..5357a59e 100644
--- a/src/Yavsc.Org/Controllers/Contracting/GeneralSettingsController.cs
+++ b/src/Yavsc.Org/Controllers/Contracting/GeneralSettingsController.cs
@@ -17,7 +17,7 @@ namespace Yavsc.Controllers
// GET: GeneralSettings
public async Task Index()
{
- return View(await _context.GeneralSettings.ToListAsync());
+ return View(await _context.MusicLoverSettings.ToListAsync());
}
// GET: GeneralSettings/Details/5
@@ -28,7 +28,7 @@ namespace Yavsc.Controllers
return NotFound();
}
- MusicLoverSettings generalSettings = await _context.GeneralSettings.SingleAsync(m => m.UserId == id);
+ MusicLoverSettings generalSettings = await _context.MusicLoverSettings.SingleAsync(m => m.UserId == id);
if (generalSettings == null)
{
return NotFound();
@@ -50,7 +50,7 @@ namespace Yavsc.Controllers
{
if (ModelState.IsValid)
{
- _context.GeneralSettings.Add(generalSettings);
+ _context.MusicLoverSettings.Add(generalSettings);
await _context.SaveChangesAsync();
return RedirectToAction("Index");
}
@@ -65,7 +65,7 @@ namespace Yavsc.Controllers
return NotFound();
}
- MusicLoverSettings generalSettings = await _context.GeneralSettings.SingleAsync(m => m.UserId == id);
+ MusicLoverSettings generalSettings = await _context.MusicLoverSettings.SingleAsync(m => m.UserId == id);
if (generalSettings == null)
{
return NotFound();
@@ -96,7 +96,7 @@ namespace Yavsc.Controllers
return NotFound();
}
- MusicLoverSettings generalSettings = await _context.GeneralSettings.SingleAsync(m => m.UserId == id);
+ MusicLoverSettings generalSettings = await _context.MusicLoverSettings.SingleAsync(m => m.UserId == id);
if (generalSettings == null)
{
return NotFound();
@@ -110,8 +110,8 @@ namespace Yavsc.Controllers
[ValidateAntiForgeryToken]
public async Task DeleteConfirmed(string id)
{
- MusicLoverSettings generalSettings = await _context.GeneralSettings.SingleAsync(m => m.UserId == id);
- _context.GeneralSettings.Remove(generalSettings);
+ MusicLoverSettings generalSettings = await _context.MusicLoverSettings.SingleAsync(m => m.UserId == id);
+ _context.MusicLoverSettings.Remove(generalSettings);
await _context.SaveChangesAsync();
return RedirectToAction("Index");
}
diff --git a/src/Yavsc.Org/Controllers/Generic/SettingsController.cs b/src/Yavsc.Org/Controllers/Generic/SettingsController.cs
index 8c490a1c..24ac53a6 100644
--- a/src/Yavsc.Org/Controllers/Generic/SettingsController.cs
+++ b/src/Yavsc.Org/Controllers/Generic/SettingsController.cs
@@ -6,7 +6,6 @@ namespace Yavsc.Controllers.Generic
using System.Linq;
using Microsoft.EntityFrameworkCore;
using Models;
- using Yavsc.Helpers;
using Yavsc.Server.Helpers;
using Yavsc.Services;
@@ -37,7 +36,7 @@ namespace Yavsc.Controllers.Generic
{
_context = context;
}
-
+
public async Task Index()
{
return View(await GetSettingsAsync(User.GetUserId()));
diff --git a/src/Yavsc.Org/Controllers/Haircut/HairCutCommandController.cs b/src/Yavsc.Org/Controllers/Haircut/HairCutCommandController.cs
index 0a2610a8..80e5f80c 100644
--- a/src/Yavsc.Org/Controllers/Haircut/HairCutCommandController.cs
+++ b/src/Yavsc.Org/Controllers/Haircut/HairCutCommandController.cs
@@ -49,7 +49,7 @@ namespace Yavsc.Controllers
this.haircutLocalizer = haircutLocalizer;
}
-
+
private async Task GetQuery(long id)
{
var query = await _context.HairCutQueries
@@ -58,7 +58,7 @@ namespace Yavsc.Controllers
.Include(x => x.Prestation)
.Include(x => x.PerformerProfile.Performer)
.Include(x => x.PerformerProfile.Performer.DeviceDeclaration)
- .Include(x => x.Regularisation)
+ .Include(x => x.Regularization)
.SingleAsync(m => m.Id == id);
query.SelectedProfile = await _context.BrusherProfile.SingleAsync(b => b.UserId == query.PerformerId);
return query;
@@ -82,11 +82,11 @@ namespace Yavsc.Controllers
}
var paymentInfo = await _context.ConfirmPayment(User.GetUserId(), PayerID, token);
ViewBag.paymentinfo = paymentInfo;
- command.Regularisation = paymentInfo.DbContent;
+ command.Regularization = paymentInfo.DbContent;
command.PaymentId = token;
bool paymentOk = false;
if (paymentInfo.DetailsFromPayPal != null)
- if (paymentInfo.DetailsFromPayPal.Ack == AckCodeType.SUCCESS)
+ if (paymentInfo.DetailsFromPayPal.Ack == AckCodeType.SUCCESS)
{
// FIXME Assert (command.ValidationDate == null)
if (command.ValidationDate == null) {
@@ -174,7 +174,7 @@ namespace Yavsc.Controllers
.Include(x => x.PerformerProfile)
.Include(x => x.Prestation)
.Include(x => x.PerformerProfile.Performer)
- .Include(x => x.Regularisation)
+ .Include(x => x.Regularization)
.SingleOrDefaultAsync(m => m.Id == id);
if (command == null)
{
@@ -224,7 +224,7 @@ namespace Yavsc.Controllers
.FirstOrDefault(
x => x.PerformerId == model.PerformerId
);
-
+
if (taintIds != null)
{
diff --git a/src/Yavsc.Org/Controllers/IT/ProjectController.cs b/src/Yavsc.Org/Controllers/IT/ProjectController.cs
index 83ccc67a..965530f3 100644
--- a/src/Yavsc.Org/Controllers/IT/ProjectController.cs
+++ b/src/Yavsc.Org/Controllers/IT/ProjectController.cs
@@ -18,7 +18,7 @@ namespace Yavsc.Controllers
private readonly ApplicationDbContext _context;
readonly IStringLocalizer _localizer;
readonly IStringLocalizer _bugLocalizer;
-
+
public ProjectController(ApplicationDbContext context,
IStringLocalizer localizer,
IStringLocalizer bugLocalizer
@@ -32,7 +32,7 @@ namespace Yavsc.Controllers
// GET: Project
public async Task Index()
{
- var applicationDbContext = _context.Project.Include(p => p.Client).Include(p => p.Context).Include(p => p.PerformerProfile).Include(p => p.Regularisation).Include(p => p.Repository);
+ var applicationDbContext = _context.Project.Include(p => p.Client).Include(p => p.Context).Include(p => p.PerformerProfile).Include(p => p.Regularization).Include(p => p.Repository);
return View(await applicationDbContext.ToListAsync());
}
diff --git a/src/Yavsc.Org/Directory.Packages.props b/src/Yavsc.Org/Directory.Packages.props
index 6cd62331..fd16374b 100644
--- a/src/Yavsc.Org/Directory.Packages.props
+++ b/src/Yavsc.Org/Directory.Packages.props
@@ -4,14 +4,14 @@
-
-
+
+
-
-
-
-
+
+
+
+
diff --git a/src/Yavsc.Org/Extensions/EnumExtensions.cs b/src/Yavsc.Org/Extensions/EnumExtensions.cs
index ae733f20..29fb0eee 100644
--- a/src/Yavsc.Org/Extensions/EnumExtensions.cs
+++ b/src/Yavsc.Org/Extensions/EnumExtensions.cs
@@ -1,8 +1,5 @@
-using System;
-using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
-using System.Linq;
using System.Reflection;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.Extensions.Localization;
@@ -23,7 +20,7 @@ namespace Yavsc.Extensions
var typeInfo = type.GetTypeInfo();
var values = Enum.GetValues(type).Cast();
var items = new List();
-
+
foreach (var value in values)
{
items.Add(new SelectListItem {
diff --git a/src/Yavsc.Org/Extensions/HostingExtensions.cs b/src/Yavsc.Org/Extensions/HostingExtensions.cs
index b62a6567..0ad5ba9a 100644
--- a/src/Yavsc.Org/Extensions/HostingExtensions.cs
+++ b/src/Yavsc.Org/Extensions/HostingExtensions.cs
@@ -41,6 +41,7 @@ using Yavsc.Settings;
using Yavsc.ViewModels.Auth;
using IdentityServer8.Models;
using IdentityServer8.EntityFramework.Mappers;
+using Yavsc.Server.Hubs;
namespace Yavsc.Extensions;
diff --git a/src/Yavsc.Org/Helpers/EventHelpers.cs b/src/Yavsc.Org/Helpers/EventHelpers.cs
index 48c23c1b..151f3360 100644
--- a/src/Yavsc.Org/Helpers/EventHelpers.cs
+++ b/src/Yavsc.Org/Helpers/EventHelpers.cs
@@ -17,11 +17,11 @@ namespace Yavsc.Helpers
{
Sender = query.ClientId,
Reason = query.Reason,
- Client = new ClientProviderInfo {
+ Client = new ClientProviderInfo {
UserName = query.Client.UserName ,
UserId = query.ClientId,
Avatar = query.Client.Avatar } ,
- Previsional = query.Previsional,
+ Previsional = query.Provisional,
EventDate = query.EventDate,
Location = query.Location,
Id = query.Id,
@@ -44,7 +44,7 @@ namespace Yavsc.Helpers
var yaev = query.CreateEvent("NewHairCutQuery",
string.Format(SR["HairCutQueryValidation"],query.Client.UserName),
$"{query.Client.Id}");
-
+
return yaev;
}
@@ -58,12 +58,12 @@ namespace Yavsc.Helpers
var yaev = new HairCutQueryEvent("newCommand")
{
Sender = query.ClientId,
-
- Client = new ClientProviderInfo {
+
+ Client = new ClientProviderInfo {
UserName = query.Client.UserName ,
UserId = query.ClientId,
Avatar = query.Client.Avatar } ,
- Previsional = query.Previsional,
+ Previsional = query.Provisional,
EventDate = query.EventDate,
Location = query.Location,
Id = query.Id,
diff --git a/src/Yavsc.Org/Helpers/ListItemHelpers.cs b/src/Yavsc.Org/Helpers/ListItemHelpers.cs
index 4455ee1a..a9101622 100644
--- a/src/Yavsc.Org/Helpers/ListItemHelpers.cs
+++ b/src/Yavsc.Org/Helpers/ListItemHelpers.cs
@@ -12,12 +12,16 @@ namespace Yavsc.Helpers {
this ApplicationDbContext _dbContext, List activity)
{
var activities = activity.ToArray();
+ var activityCodes = activities.Select(a=>a.DoesCode).ToArray();
- List items = _dbContext.Activities.Select(
+ var systemActivities = _dbContext.Activities.Where(a=>!a.Moderated
+ && activityCodes.Contains(a.Code)).ToArray();
+
+ List items = systemActivities.Select(
x=> new SelectListItem() {
Value = x.Code, Text = x.Name, Selected = activities.Any(a=>a.DoesCode == x.Code)
} ).ToList();
-
+
return items;
}
}
diff --git a/src/Yavsc.Org/Migrations/20260704154837_moderatedActivities.Designer.cs b/src/Yavsc.Org/Migrations/20260704154837_moderatedActivities.Designer.cs
new file mode 100644
index 00000000..88b271b0
--- /dev/null
+++ b/src/Yavsc.Org/Migrations/20260704154837_moderatedActivities.Designer.cs
@@ -0,0 +1,4696 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using Yavsc.Models;
+
+#nullable disable
+
+namespace Yavsc.Migrations
+{
+ [DbContext(typeof(ApplicationDbContext))]
+ [Migration("20260704154837_moderatedActivities")]
+ partial class moderatedActivities
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.9")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiResource", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id"));
+
+ b.Property("AllowedAccessTokenSigningAlgorithms")
+ .HasColumnType("text");
+
+ b.Property("Created")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .HasColumnType("text");
+
+ b.Property("DisplayName")
+ .HasColumnType("text");
+
+ b.Property("Enabled")
+ .HasColumnType("boolean");
+
+ b.Property("LastAccessed")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .HasColumnType("text");
+
+ b.Property("NonEditable")
+ .HasColumnType("boolean");
+
+ b.Property("ShowInDiscoveryDocument")
+ .HasColumnType("boolean");
+
+ b.Property("Updated")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.ToTable("ApiResources");
+ });
+
+ modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiResourceClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ApiResourceId")
+ .HasColumnType("integer");
+
+ b.Property("ApiResourceId1")
+ .HasColumnType("integer");
+
+ b.Property("Type")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ApiResourceId");
+
+ b.HasIndex("ApiResourceId1");
+
+ b.ToTable("ApiResourceClaims");
+ });
+
+ modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiResourceProperty", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ApiResourceId")
+ .HasColumnType("integer");
+
+ b.Property("ApiResourceId1")
+ .HasColumnType("integer");
+
+ b.Property("Key")
+ .HasColumnType("text");
+
+ b.Property("Value")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ApiResourceId");
+
+ b.HasIndex("ApiResourceId1");
+
+ b.ToTable("ApiResourceProperties");
+ });
+
+ modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiResourceScope", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ApiResourceId")
+ .HasColumnType("integer");
+
+ b.Property("ApiResourceId1")
+ .HasColumnType("integer");
+
+ b.Property("Scope")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ApiResourceId");
+
+ b.HasIndex("ApiResourceId1");
+
+ b.ToTable("ApiResourceScopes");
+ });
+
+ modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiResourceSecret", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ApiResourceId")
+ .HasColumnType("integer");
+
+ b.Property("ApiResourceId1")
+ .HasColumnType("integer");
+
+ b.Property("Created")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .HasColumnType("text");
+
+ b.Property("Expiration")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Type")
+ .HasColumnType("text");
+
+ b.Property("Value")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ApiResourceId");
+
+ b.HasIndex("ApiResourceId1");
+
+ b.ToTable("ApiResourceSecrets");
+ });
+
+ modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiScope", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id"));
+
+ b.Property("Description")
+ .HasColumnType("text");
+
+ b.Property("DisplayName")
+ .HasColumnType("text");
+
+ b.Property("Emphasize")
+ .HasColumnType("boolean");
+
+ b.Property("Enabled")
+ .HasColumnType("boolean");
+
+ b.Property("Name")
+ .HasColumnType("text");
+
+ b.Property("Required")
+ .HasColumnType("boolean");
+
+ b.Property("ShowInDiscoveryDocument")
+ .HasColumnType("boolean");
+
+ b.HasKey("Id");
+
+ b.ToTable("ApiScopes");
+ });
+
+ modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiScopeClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ScopeId")
+ .HasColumnType("integer");
+
+ b.Property("ScopeId1")
+ .HasColumnType("integer");
+
+ b.Property("Type")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ScopeId");
+
+ b.HasIndex("ScopeId1");
+
+ b.ToTable("ApiScopeClaims");
+ });
+
+ modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiScopeProperty", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Key")
+ .HasColumnType("text");
+
+ b.Property("ScopeId")
+ .HasColumnType("integer");
+
+ b.Property("ScopeId1")
+ .HasColumnType("integer");
+
+ b.Property("Value")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ScopeId");
+
+ b.HasIndex("ScopeId1");
+
+ b.ToTable("ApiScopeProperties");
+ });
+
+ modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.Client", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id"));
+
+ b.Property("AbsoluteRefreshTokenLifetime")
+ .HasColumnType("integer");
+
+ b.Property("AccessTokenLifetime")
+ .HasColumnType("integer");
+
+ b.Property("AccessTokenType")
+ .HasColumnType("integer");
+
+ b.Property("AllowAccessTokensViaBrowser")
+ .HasColumnType("boolean");
+
+ b.Property("AllowOfflineAccess")
+ .HasColumnType("boolean");
+
+ b.Property("AllowPlainTextPkce")
+ .HasColumnType("boolean");
+
+ b.Property("AllowRememberConsent")
+ .HasColumnType("boolean");
+
+ b.Property("AllowedIdentityTokenSigningAlgorithms")
+ .HasColumnType("text");
+
+ b.Property("AlwaysIncludeUserClaimsInIdToken")
+ .HasColumnType("boolean");
+
+ b.Property("AlwaysSendClientClaims")
+ .HasColumnType("boolean");
+
+ b.Property("AuthorizationCodeLifetime")
+ .HasColumnType("integer");
+
+ b.Property("BackChannelLogoutSessionRequired")
+ .HasColumnType("boolean");
+
+ b.Property("BackChannelLogoutUri")
+ .HasColumnType("text");
+
+ b.Property("ClientClaimsPrefix")
+ .HasColumnType("text");
+
+ b.Property("ClientId")
+ .HasColumnType("text");
+
+ b.Property("ClientName")
+ .HasColumnType("text");
+
+ b.Property("ClientUri")
+ .HasColumnType("text");
+
+ b.Property("ConsentLifetime")
+ .HasColumnType("integer");
+
+ b.Property("Created")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .HasColumnType("text");
+
+ b.Property("DeviceCodeLifetime")
+ .HasColumnType("integer");
+
+ b.Property("EnableLocalLogin")
+ .HasColumnType("boolean");
+
+ b.Property("Enabled")
+ .HasColumnType("boolean");
+
+ b.Property("FrontChannelLogoutSessionRequired")
+ .HasColumnType("boolean");
+
+ b.Property("FrontChannelLogoutUri")
+ .HasColumnType("text");
+
+ b.Property("IdentityTokenLifetime")
+ .HasColumnType("integer");
+
+ b.Property("IncludeJwtId")
+ .HasColumnType("boolean");
+
+ b.Property("LastAccessed")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LogoUri")
+ .HasColumnType("text");
+
+ b.Property("NonEditable")
+ .HasColumnType("boolean");
+
+ b.Property("PairWiseSubjectSalt")
+ .HasColumnType("text");
+
+ b.Property("ProtocolType")
+ .HasColumnType("text");
+
+ b.Property("RefreshTokenExpiration")
+ .HasColumnType("integer");
+
+ b.Property("RefreshTokenUsage")
+ .HasColumnType("integer");
+
+ b.Property("RequireClientSecret")
+ .HasColumnType("boolean");
+
+ b.Property("RequireConsent")
+ .HasColumnType("boolean");
+
+ b.Property("RequirePkce")
+ .HasColumnType("boolean");
+
+ b.Property("RequireRequestObject")
+ .HasColumnType("boolean");
+
+ b.Property("SlidingRefreshTokenLifetime")
+ .HasColumnType("integer");
+
+ b.Property("UpdateAccessTokenClaimsOnRefresh")
+ .HasColumnType("boolean");
+
+ b.Property("Updated")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserCodeType")
+ .HasColumnType("text");
+
+ b.Property("UserSsoLifetime")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.ToTable("Clients");
+ });
+
+ modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id"));
+
+ b.Property("ClientId")
+ .HasColumnType("integer");
+
+ b.Property("Type")
+ .HasColumnType("text");
+
+ b.Property("Value")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ClientId");
+
+ b.ToTable("ClientClaims");
+ });
+
+ modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientCorsOrigin", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id"));
+
+ b.Property("ClientId")
+ .HasColumnType("integer");
+
+ b.Property("ClientId1")
+ .HasColumnType("integer");
+
+ b.Property("Origin")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ClientId");
+
+ b.HasIndex("ClientId1");
+
+ b.ToTable("ClientCorsOrigins");
+ });
+
+ modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientGrantType", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id"));
+
+ b.Property("ClientId")
+ .HasColumnType("integer");
+
+ b.Property("ClientId1")
+ .HasColumnType("integer");
+
+ b.Property("GrantType")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ClientId");
+
+ b.HasIndex("ClientId1");
+
+ b.ToTable("ClientGrantTypes");
+ });
+
+ modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientIdPRestriction", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id"));
+
+ b.Property("ClientId")
+ .HasColumnType("integer");
+
+ b.Property("ClientId1")
+ .HasColumnType("integer");
+
+ b.Property("Provider")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ClientId");
+
+ b.HasIndex("ClientId1");
+
+ b.ToTable("ClientIdPRestrictions");
+ });
+
+ modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientPostLogoutRedirectUri", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id"));
+
+ b.Property("ClientId")
+ .HasColumnType("integer");
+
+ b.Property("ClientId1")
+ .HasColumnType("integer");
+
+ b.Property("PostLogoutRedirectUri")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ClientId");
+
+ b.HasIndex("ClientId1");
+
+ b.ToTable("ClientPostLogoutRedirectUris");
+ });
+
+ modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientProperty", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id"));
+
+ b.Property("ClientId")
+ .HasColumnType("integer");
+
+ b.Property("ClientId1")
+ .HasColumnType("integer");
+
+ b.Property("Key")
+ .HasColumnType("text");
+
+ b.Property("Value")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ClientId");
+
+ b.HasIndex("ClientId1");
+
+ b.ToTable("ClientProperties");
+ });
+
+ modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientRedirectUri", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id"));
+
+ b.Property("ClientId")
+ .HasColumnType("integer");
+
+ b.Property("ClientId1")
+ .HasColumnType("integer");
+
+ b.Property("RedirectUri")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ClientId");
+
+ b.HasIndex("ClientId1");
+
+ b.ToTable("ClientRedirectUris");
+ });
+
+ modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientScope", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id"));
+
+ b.Property("ClientId")
+ .HasColumnType("integer");
+
+ b.Property("ClientId1")
+ .HasColumnType("integer");
+
+ b.Property("Scope")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ClientId");
+
+ b.HasIndex("ClientId1");
+
+ b.ToTable("ClientScopes");
+ });
+
+ modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientSecret", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id"));
+
+ b.Property("ClientId")
+ .HasColumnType("integer");
+
+ b.Property("ClientId1")
+ .HasColumnType("integer");
+
+ b.Property("Created")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .HasColumnType("text");
+
+ b.Property("Expiration")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Type")
+ .HasColumnType("text");
+
+ b.Property("Value")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ClientId");
+
+ b.HasIndex("ClientId1");
+
+ b.ToTable("ClientSecrets");
+ });
+
+ modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.DeviceFlowCodes", b =>
+ {
+ b.Property("UserCode")
+ .HasColumnType("text");
+
+ b.Property("DeviceCode")
+ .HasColumnType("text");
+
+ b.Property("ClientId")
+ .HasColumnType("text");
+
+ b.Property("CreationTime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Data")
+ .HasColumnType("text");
+
+ b.Property("Description")
+ .HasColumnType("text");
+
+ b.Property("Expiration")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("SessionId")
+ .HasColumnType("text");
+
+ b.Property("SubjectId")
+ .HasColumnType("text");
+
+ b.HasKey("UserCode", "DeviceCode");
+
+ b.ToTable("DeviceFlowCodes");
+ });
+
+ modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.IdentityResource", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Created")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .HasColumnType("text");
+
+ b.Property("DisplayName")
+ .HasColumnType("text");
+
+ b.Property("Emphasize")
+ .HasColumnType("boolean");
+
+ b.Property("Enabled")
+ .HasColumnType("boolean");
+
+ b.Property("Name")
+ .HasColumnType("text");
+
+ b.Property("NonEditable")
+ .HasColumnType("boolean");
+
+ b.Property("Required")
+ .HasColumnType("boolean");
+
+ b.Property("ShowInDiscoveryDocument")
+ .HasColumnType("boolean");
+
+ b.Property("Updated")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.ToTable("IdentityResources");
+ });
+
+ modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.IdentityResourceClaim", b =>
+ {
+ b.Property