Nom d'app, drapeau QR et liens stores par application

ApplicationInstance porte désormais AppName (traductions, jsonb),
IsQRCodeEnabled (défaut true) et les liens App Store / Play Store.
Une ligne existe par (instance, type d'app) : chaque réglage vaut
donc séparément pour le mobile et pour le web.

Les liens stores ne sont modifiables que par un SuperAdmin — c'est
Unov qui publie les apps —, contrôlé côté API et pas seulement masqué
dans l'UI.

La liste anonyme des apps expose le slug web et le nom de l'instance :
la page /download de visitapp-web n'a que l'instanceId d'un QR imprimé.

Réserve les slugs download, demo et api : un segment statique de
visitapp-web l'emporte sur [slug], l'instance serait inaccessible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thomas Fransolet 2026-09-10 11:42:07 +02:00
parent 9cc45c5767
commit 4e17c572ba
9 changed files with 2079 additions and 4 deletions

View File

@ -36,6 +36,9 @@ namespace ManagerService.Controllers
_myInfoMateDbContext = myInfoMateDbContext;
}
private bool IsSuperAdmin() =>
User.HasClaim(ManagerService.Service.Security.ClaimTypes.Permission, ManagerService.Service.Security.Permissions.SuperAdmin);
/// <summary>
/// Get a list of all applicationInstance (summary)
/// </summary>
@ -54,6 +57,11 @@ namespace ManagerService.Controllers
return new OkObjectResult(applicationInstances.Select(ai => {
var dto = ai.ToDTO(_myInfoMateDbContext);
dto.hasStats = instance?.HasStats ?? false;
// Porté par l'Instance, pas par l'application. La page /download de
// visitapp-web n'a que l'instanceId d'un QR imprimé : sans ce slug, elle
// ne peut pas renvoyer vers la visite web d'une instance sans app mobile.
dto.webSlug = instance?.WebSlug;
dto.instanceName = instance?.Name;
return dto;
}).OrderBy(c => c.appType));
}
@ -92,6 +100,12 @@ namespace ManagerService.Controllers
ApplicationInstance applicationInstance = new ApplicationInstance().FromDTO(newApplicationInstanceDTO);
applicationInstance.Id = idService.GenerateHexId();
if (!IsSuperAdmin())
{
applicationInstance.AppStoreUrl = null;
applicationInstance.PlayStoreUrl = null;
}
_myInfoMateDbContext.ApplicationInstances.Add(applicationInstance);
_myInfoMateDbContext.SaveChanges();
@ -133,7 +147,17 @@ namespace ManagerService.Controllers
if (applicationInstance == null)
throw new KeyNotFoundException("application instance does not exist");
var appStoreUrl = applicationInstance.AppStoreUrl;
var playStoreUrl = applicationInstance.PlayStoreUrl;
applicationInstance = applicationInstance.FromDTO(updatedApplicationInstanceDTO);
if (!IsSuperAdmin())
{
applicationInstance.AppStoreUrl = appStoreUrl;
applicationInstance.PlayStoreUrl = playStoreUrl;
}
_myInfoMateDbContext.SaveChanges();
return new OkObjectResult(applicationInstance.ToDTO(_myInfoMateDbContext));

View File

@ -65,7 +65,7 @@ namespace ManagerService.Controllers
public ObjectResult CheckSlug(string slug)
{
var sanitized = SanitizeSlug(slug);
var available = sanitized.Length >= 3 && !_myInfoMateDbContext.Instances.Any(i => i.WebSlug == sanitized);
var available = sanitized.Length >= 3 && !SlugHelper.IsTaken(_myInfoMateDbContext, sanitized);
return new OkObjectResult(new { slug = sanitized, available });
}
@ -176,7 +176,7 @@ namespace ManagerService.Controllers
var slug = SanitizeSlug(dto.slug);
if (slug.Length < 3)
throw new ArgumentNullException(nameof(dto.slug), "Slug must be at least 3 characters");
if (_myInfoMateDbContext.Instances.Any(i => i.WebSlug == slug))
if (SlugHelper.IsTaken(_myInfoMateDbContext, slug))
throw new InvalidOperationException("This address is already taken");
var plan = _myInfoMateDbContext.SubscriptionPlans.FirstOrDefault(p => p.Id == EssentielPlanId);

View File

@ -35,5 +35,19 @@ namespace ManagerService.DTOs
public bool isAssistant { get; set; }
public bool hasStats { get; set; }
/// <summary>Slug web de l'Instance. Lecture seule, rempli par le contrôleur.</summary>
public string? webSlug { get; set; }
/// <summary>Nom de l'Instance. Lecture seule, rempli par le contrôleur.</summary>
public string? instanceName { get; set; }
public bool isQRCodeEnabled { get; set; } = true;
public List<TranslationDTO>? appName { get; set; }
public string? appStoreUrl { get; set; }
public string? playStoreUrl { get; set; }
}
}

View File

@ -54,6 +54,14 @@ namespace ManagerService.Data
public bool IsAssistant { get; set; } = false;
public bool IsQRCodeEnabled { get; set; } = true;
public List<TranslationDTO>? AppName { get; set; } // Specific Mobile et web
// Chaque client a sa propre app publiée par Unov : seul un SuperAdmin les modifie.
public string? AppStoreUrl { get; set; } // Specific Mobile
public string? PlayStoreUrl { get; set; } // Specific Mobile
public ApplicationInstanceDTO ToDTO(MyInfoMateDbContext myInfoMateDbContext)
{
@ -80,7 +88,11 @@ namespace ManagerService.Data
languages = Languages,
sectionEventId = SectionEventId,
sectionEventDTO = sectionEventDTO,
isAssistant = IsAssistant
isAssistant = IsAssistant,
isQRCodeEnabled = IsQRCodeEnabled,
appName = AppName,
appStoreUrl = AppStoreUrl,
playStoreUrl = PlayStoreUrl
};
}
@ -98,6 +110,10 @@ namespace ManagerService.Data
Configurations = dto.configurations;
SectionEventId = dto.sectionEventId;
IsAssistant = dto.isAssistant;
IsQRCodeEnabled = dto.isQRCodeEnabled;
AppName = dto.appName;
AppStoreUrl = dto.appStoreUrl;
PlayStoreUrl = dto.playStoreUrl;
return this;
}

View File

@ -265,6 +265,13 @@ namespace ManagerService.Data
v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize<List<TranslationDTO>>(v, options));
modelBuilder.Entity<ApplicationInstance>()
.Property(ai => ai.AppName)
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize<List<TranslationDTO>>(v, options));
// Guide IA — recherche sémantique.
// Le type vector n'existe que sur Npgsql : les tests tournent sur EF InMemory,
// qui échoue à la validation du modèle s'il rencontre une propriété Vector.

View File

@ -6,6 +6,15 @@ namespace ManagerService.Helpers
{
public static class SlugHelper
{
/// <summary>
/// Premiers segments d'URL déjà pris par des routes de visitapp-web : un segment
/// statique l'emporte sur [slug], l'instance serait donc inaccessible.
/// </summary>
private static readonly string[] ReservedSlugs = { "download", "demo", "api" };
public static bool IsTaken(MyInfoMateDbContext db, string slug) =>
ReservedSlugs.Contains(slug) || db.Instances.Any(i => i.WebSlug == slug);
/// <summary>
/// Generate a URL-safe slug from a name, appending "-1", "-2", etc. until unique among Instances.WebSlug.
/// </summary>
@ -24,7 +33,7 @@ namespace ManagerService.Helpers
var baseSlug = slug;
var counter = 1;
while (db.Instances.Any(i => i.WebSlug == slug))
while (IsTaken(db, slug))
slug = $"{baseSlug}-{counter++}";
return slug;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,59 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ManagerService.Migrations
{
/// <inheritdoc />
public partial class AddAppNameQrAndStoresToApplicationInstance : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "AppName",
table: "ApplicationInstances",
type: "jsonb",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "AppStoreUrl",
table: "ApplicationInstances",
type: "text",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsQRCodeEnabled",
table: "ApplicationInstances",
type: "boolean",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<string>(
name: "PlayStoreUrl",
table: "ApplicationInstances",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AppName",
table: "ApplicationInstances");
migrationBuilder.DropColumn(
name: "AppStoreUrl",
table: "ApplicationInstances");
migrationBuilder.DropColumn(
name: "IsQRCodeEnabled",
table: "ApplicationInstances");
migrationBuilder.DropColumn(
name: "PlayStoreUrl",
table: "ApplicationInstances");
}
}
}

View File

@ -147,6 +147,12 @@ namespace ManagerService.Migrations
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("AppName")
.HasColumnType("jsonb");
b.Property<string>("AppStoreUrl")
.HasColumnType("text");
b.Property<int>("AppType")
.HasColumnType("integer");
@ -163,6 +169,9 @@ namespace ManagerService.Migrations
b.Property<bool>("IsAssistant")
.HasColumnType("boolean");
b.Property<bool>("IsQRCodeEnabled")
.HasColumnType("boolean");
b.PrimitiveCollection<List<string>>("Languages")
.HasColumnType("text[]");
@ -178,6 +187,9 @@ namespace ManagerService.Migrations
b.Property<string>("MainImageUrl")
.HasColumnType("text");
b.Property<string>("PlayStoreUrl")
.HasColumnType("text");
b.Property<string>("PrimaryColor")
.HasColumnType("text");