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.Api/Controllers/Business/BillingController.cs b/src/Yavsc.Api/Controllers/Business/BillingController.cs
index a7047049..b26f3bb5 100644
--- a/src/Yavsc.Api/Controllers/Business/BillingController.cs
+++ b/src/Yavsc.Api/Controllers/Business/BillingController.cs
@@ -264,21 +264,53 @@ namespace Yavsc.ApiControllers
return BadRequest(new { Error = "file write failed", Detail = ex.Message });
}
- // Now persist the database row. The int[] is round-
- // tripped via Npgsql's native int[] mapping; the
- // migration (separate commit) introduces the column
- // and the index.
- var signature = new Signature
+ // 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)
{
- EstimateId = id,
- SignerId = userId,
- Type = type,
- CoordinateMax = payload.CoordinateMax,
- Strokes = payload.Strokes,
- CapturedAtUtc = payload.CapturedAtUtc,
- FilePath = Path.Combine(fi.DestDir, fi.FileName),
- };
- dbContext.Signatures.Add(signature);
+ 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
diff --git a/src/Yavsc.Server/Hubs/HubInputValidator.cs b/src/Yavsc.Server/Hubs/HubInputValidator.cs
index 36e5dce2..a6acac5a 100644
--- a/src/Yavsc.Server/Hubs/HubInputValidator.cs
+++ b/src/Yavsc.Server/Hubs/HubInputValidator.cs
@@ -35,7 +35,7 @@ namespace Yavsc.Server.Hubs
{
bool valid = ValidateStringLength(roomName,1,25);
if (valid) valid = IsLetterOrDigit(roomName);
- if (!valid) NotifyUser(NotificationTypes.Error, "roomName", _locator.GetString(ChatHubLabels.InvalidRoomName));
+ if (!valid) NotifyUser(NotificationTypes.Error, "roomName", _locator.GetString("InvalidRoomName"));
return valid;
}
public bool ValidateUserName (string userName)
@@ -52,14 +52,14 @@ namespace Yavsc.Server.Hubs
NotifyUser(
NotificationTypes.Error,
"userName",
- _locator.GetString(ChatHubLabels.InvalidUserName));
+ _locator.GetString("InvalidUserName)"));
return valid;
}
public bool ValidateMessage (string message)
{
if (!ValidateStringLength(message, 1, 10240))
{
- NotifyUser(NotificationTypes.Error, "message", _locator.GetString(ChatHubLabels.InvalidMessage));
+ NotifyUser(NotificationTypes.Error, "message", _locator.GetString("InvalidMessage"));
return false;
}
return true;
@@ -68,7 +68,7 @@ namespace Yavsc.Server.Hubs
{
if (!ValidateStringLength(reason, 1,240))
{
- NotifyUser(NotificationTypes.Error, "reason", ChatHubLabels.InvalidReason);
+ NotifyUser(NotificationTypes.Error, "reason", "InvalidReason");
return false;
}
return true;
diff --git a/src/Yavsc.Server/Models/ApplicationDbContext.cs b/src/Yavsc.Server/Models/ApplicationDbContext.cs
index b8ade1c2..cbdb6895 100644
--- a/src/Yavsc.Server/Models/ApplicationDbContext.cs
+++ b/src/Yavsc.Server/Models/ApplicationDbContext.cs
@@ -69,16 +69,17 @@ namespace Yavsc.Models
builder.Entity().Property(x => x.DeclarationDate).HasDefaultValueSql(NOW_SQL);
builder.Entity().HasKey(x => new { x.PostId, x.TagId });
- // Signature: composite index (EstimateId, Type,
- // CapturedAtUtc DESC) to support the controller's
- // "most recent signature per type" read pattern
- // without an extra ORDER BY cost. The default
- // EF-generated FK index on EstimateId alone is
- // replaced by the composite to avoid duplicate
- // indexes.
+ // Signature: unique constraint on (EstimateId, Type)
+ // — the most recent version replaces the previous
+ // one, so a partial unique index (not a regular one)
+ // is the right shape. The controller does a
+ // Find-or-Add so EF translates an upsert into a
+ // single UPDATE when the row already exists; the
+ // unique index is the database-level guarantee that
+ // the contract holds.
builder.Entity()
- .HasIndex(s => new { s.EstimateId, s.Type, s.CapturedAtUtc })
- .IsDescending(false, false, true);
+ .HasIndex(s => new { s.EstimateId, s.Type })
+ .IsUnique();
builder.Entity()
.HasOne(s => s.Estimate)
.WithMany(e => e.Signatures)