diff --git a/DysonNetwork.Develop/AppDatabase.cs b/DysonNetwork.Develop/AppDatabase.cs index 29aff8a7..7ca2cc7c 100644 --- a/DysonNetwork.Develop/AppDatabase.cs +++ b/DysonNetwork.Develop/AppDatabase.cs @@ -18,6 +18,7 @@ public class AppDatabase( public DbSet CustomApps { get; set; } = null!; public DbSet CustomAppSecrets { get; set; } = null!; public DbSet BotAccounts { get; set; } = null!; + public DbSet MiniApps { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { diff --git a/DysonNetwork.Develop/DysonNetwork.Develop.csproj b/DysonNetwork.Develop/DysonNetwork.Develop.csproj index b114741d..11e2293f 100644 --- a/DysonNetwork.Develop/DysonNetwork.Develop.csproj +++ b/DysonNetwork.Develop/DysonNetwork.Develop.csproj @@ -28,5 +28,9 @@ + + + + diff --git a/DysonNetwork.Develop/Migrations/20260117175714_AddMiniApp.Designer.cs b/DysonNetwork.Develop/Migrations/20260117175714_AddMiniApp.Designer.cs new file mode 100644 index 00000000..b220d5af --- /dev/null +++ b/DysonNetwork.Develop/Migrations/20260117175714_AddMiniApp.Designer.cs @@ -0,0 +1,382 @@ +// +using System; +using DysonNetwork.Develop; +using DysonNetwork.Shared.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace DysonNetwork.Develop.Migrations +{ + [DbContext(typeof(AppDatabase))] + [Migration("20260117175714_AddMiniApp")] + partial class AddMiniApp + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnBotAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("ProjectId") + .HasColumnType("uuid") + .HasColumnName("project_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("slug"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_bot_accounts"); + + b.HasIndex("ProjectId") + .HasDatabaseName("ix_bot_accounts_project_id"); + + b.ToTable("bot_accounts", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnCustomApp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Background") + .HasColumnType("jsonb") + .HasColumnName("background"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Description") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("description"); + + b.Property("Links") + .HasColumnType("jsonb") + .HasColumnName("links"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("name"); + + b.Property("OauthConfig") + .HasColumnType("jsonb") + .HasColumnName("oauth_config"); + + b.Property("Picture") + .HasColumnType("jsonb") + .HasColumnName("picture"); + + b.Property("ProjectId") + .HasColumnType("uuid") + .HasColumnName("project_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Verification") + .HasColumnType("jsonb") + .HasColumnName("verification"); + + b.HasKey("Id") + .HasName("pk_custom_apps"); + + b.HasIndex("ProjectId") + .HasDatabaseName("ix_custom_apps_project_id"); + + b.ToTable("custom_apps", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnCustomAppSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AppId") + .HasColumnType("uuid") + .HasColumnName("app_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Description") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("description"); + + b.Property("ExpiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expired_at"); + + b.Property("IsOidc") + .HasColumnType("boolean") + .HasColumnName("is_oidc"); + + b.Property("Secret") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("secret"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_custom_app_secrets"); + + b.HasIndex("AppId") + .HasDatabaseName("ix_custom_app_secrets_app_id"); + + b.ToTable("custom_app_secrets", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnDevProject", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("description"); + + b.Property("DeveloperId") + .HasColumnType("uuid") + .HasColumnName("developer_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("slug"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_dev_projects"); + + b.HasIndex("DeveloperId") + .HasDatabaseName("ix_dev_projects_developer_id"); + + b.ToTable("dev_projects", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnDeveloper", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("PublisherId") + .HasColumnType("uuid") + .HasColumnName("publisher_id"); + + b.HasKey("Id") + .HasName("pk_developers"); + + b.ToTable("developers", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnMiniApp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Manifest") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("manifest"); + + b.Property("ProjectId") + .HasColumnType("uuid") + .HasColumnName("project_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("slug"); + + b.Property("Stage") + .HasColumnType("integer") + .HasColumnName("stage"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_mini_apps"); + + b.HasIndex("ProjectId") + .HasDatabaseName("ix_mini_apps_project_id"); + + b.ToTable("mini_apps", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnBotAccount", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnDevProject", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_bot_accounts_dev_projects_project_id"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnCustomApp", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnDevProject", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_custom_apps_dev_projects_project_id"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnCustomAppSecret", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnCustomApp", "App") + .WithMany("Secrets") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_custom_app_secrets_custom_apps_app_id"); + + b.Navigation("App"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnDevProject", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnDeveloper", "Developer") + .WithMany("Projects") + .HasForeignKey("DeveloperId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_dev_projects_developers_developer_id"); + + b.Navigation("Developer"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnMiniApp", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnDevProject", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_mini_apps_dev_projects_project_id"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnCustomApp", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnDeveloper", b => + { + b.Navigation("Projects"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DysonNetwork.Develop/Migrations/20260117175714_AddMiniApp.cs b/DysonNetwork.Develop/Migrations/20260117175714_AddMiniApp.cs new file mode 100644 index 00000000..9f31a130 --- /dev/null +++ b/DysonNetwork.Develop/Migrations/20260117175714_AddMiniApp.cs @@ -0,0 +1,53 @@ +using System; +using DysonNetwork.Shared.Models; +using Microsoft.EntityFrameworkCore.Migrations; +using NodaTime; + +#nullable disable + +namespace DysonNetwork.Develop.Migrations +{ + /// + public partial class AddMiniApp : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "mini_apps", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + slug = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: false), + stage = table.Column(type: "integer", nullable: false), + manifest = table.Column(type: "jsonb", nullable: false), + project_id = table.Column(type: "uuid", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false), + deleted_at = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_mini_apps", x => x.id); + table.ForeignKey( + name: "fk_mini_apps_dev_projects_project_id", + column: x => x.project_id, + principalTable: "dev_projects", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_mini_apps_project_id", + table: "mini_apps", + column: "project_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "mini_apps"); + } + } +} diff --git a/DysonNetwork.Develop/Migrations/AppDatabaseModelSnapshot.cs b/DysonNetwork.Develop/Migrations/AppDatabaseModelSnapshot.cs index 79812221..26f97052 100644 --- a/DysonNetwork.Develop/Migrations/AppDatabaseModelSnapshot.cs +++ b/DysonNetwork.Develop/Migrations/AppDatabaseModelSnapshot.cs @@ -19,12 +19,12 @@ namespace DysonNetwork.Develop.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "9.0.7") + .HasAnnotation("ProductVersion", "10.0.1") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("DysonNetwork.Develop.Identity.BotAccount", b => + modelBuilder.Entity("DysonNetwork.Shared.Models.SnBotAccount", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -66,7 +66,7 @@ namespace DysonNetwork.Develop.Migrations b.ToTable("bot_accounts", (string)null); }); - modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b => + modelBuilder.Entity("DysonNetwork.Shared.Models.SnCustomApp", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -139,7 +139,7 @@ namespace DysonNetwork.Develop.Migrations b.ToTable("custom_apps", (string)null); }); - modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b => + modelBuilder.Entity("DysonNetwork.Shared.Models.SnCustomAppSecret", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -190,24 +190,7 @@ namespace DysonNetwork.Develop.Migrations b.ToTable("custom_app_secrets", (string)null); }); - modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("PublisherId") - .HasColumnType("uuid") - .HasColumnName("publisher_id"); - - b.HasKey("Id") - .HasName("pk_developers"); - - b.ToTable("developers", (string)null); - }); - - modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", b => + modelBuilder.Entity("DysonNetwork.Shared.Models.SnDevProject", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -257,9 +240,73 @@ namespace DysonNetwork.Develop.Migrations b.ToTable("dev_projects", (string)null); }); - modelBuilder.Entity("DysonNetwork.Develop.Identity.BotAccount", b => + modelBuilder.Entity("DysonNetwork.Shared.Models.SnDeveloper", b => { - b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project") + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("PublisherId") + .HasColumnType("uuid") + .HasColumnName("publisher_id"); + + b.HasKey("Id") + .HasName("pk_developers"); + + b.ToTable("developers", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnMiniApp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Manifest") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("manifest"); + + b.Property("ProjectId") + .HasColumnType("uuid") + .HasColumnName("project_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("slug"); + + b.Property("Stage") + .HasColumnType("integer") + .HasColumnName("stage"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_mini_apps"); + + b.HasIndex("ProjectId") + .HasDatabaseName("ix_mini_apps_project_id"); + + b.ToTable("mini_apps", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnBotAccount", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnDevProject", "Project") .WithMany() .HasForeignKey("ProjectId") .OnDelete(DeleteBehavior.Cascade) @@ -269,9 +316,9 @@ namespace DysonNetwork.Develop.Migrations b.Navigation("Project"); }); - modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b => + modelBuilder.Entity("DysonNetwork.Shared.Models.SnCustomApp", b => { - b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project") + b.HasOne("DysonNetwork.Shared.Models.SnDevProject", "Project") .WithMany() .HasForeignKey("ProjectId") .OnDelete(DeleteBehavior.Cascade) @@ -281,9 +328,9 @@ namespace DysonNetwork.Develop.Migrations b.Navigation("Project"); }); - modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b => + modelBuilder.Entity("DysonNetwork.Shared.Models.SnCustomAppSecret", b => { - b.HasOne("DysonNetwork.Develop.Identity.CustomApp", "App") + b.HasOne("DysonNetwork.Shared.Models.SnCustomApp", "App") .WithMany("Secrets") .HasForeignKey("AppId") .OnDelete(DeleteBehavior.Cascade) @@ -293,9 +340,9 @@ namespace DysonNetwork.Develop.Migrations b.Navigation("App"); }); - modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", b => + modelBuilder.Entity("DysonNetwork.Shared.Models.SnDevProject", b => { - b.HasOne("DysonNetwork.Develop.Identity.Developer", "Developer") + b.HasOne("DysonNetwork.Shared.Models.SnDeveloper", "Developer") .WithMany("Projects") .HasForeignKey("DeveloperId") .OnDelete(DeleteBehavior.Cascade) @@ -305,12 +352,24 @@ namespace DysonNetwork.Develop.Migrations b.Navigation("Developer"); }); - modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b => + modelBuilder.Entity("DysonNetwork.Shared.Models.SnMiniApp", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnDevProject", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_mini_apps_dev_projects_project_id"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnCustomApp", b => { b.Navigation("Secrets"); }); - modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b => + modelBuilder.Entity("DysonNetwork.Shared.Models.SnDeveloper", b => { b.Navigation("Projects"); }); diff --git a/DysonNetwork.Develop/MiniApp/MiniAppController.cs b/DysonNetwork.Develop/MiniApp/MiniAppController.cs new file mode 100644 index 00000000..d7131e20 --- /dev/null +++ b/DysonNetwork.Develop/MiniApp/MiniAppController.cs @@ -0,0 +1,185 @@ +using System.ComponentModel.DataAnnotations; +using DysonNetwork.Develop.Project; +using DysonNetwork.Shared.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace DysonNetwork.Develop.MiniApp; + +[ApiController] +[Route("/api/developers/{pubName}/projects/{projectId:guid}/miniapps")] +public class MiniAppController(MiniAppService miniAppService, Identity.DeveloperService ds, DevProjectService projectService) + : ControllerBase +{ + public record MiniAppRequest( + [MaxLength(1024)] string? Slug, + MiniAppStage? Stage, + MiniAppManifest? Manifest + ); + + public record CreateMiniAppRequest( + [Required] + [MinLength(2)] + [MaxLength(1024)] + [RegularExpression(@"^[A-Za-z0-9_-]+$", + ErrorMessage = "Slug can only contain letters, numbers, underscores, and hyphens.")] + string Slug, + + MiniAppStage Stage = MiniAppStage.Development, + + [Required] MiniAppManifest Manifest = null! + ); + + [HttpGet] + [Authorize] + public async Task ListMiniApps([FromRoute] string pubName, [FromRoute] Guid projectId) + { + if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser) + return Unauthorized(); + + var developer = await ds.GetDeveloperByName(pubName); + if (developer is null) return NotFound("Developer not found"); + + var accountId = Guid.Parse(currentUser.Id); + if (!await ds.IsMemberWithRole(developer.PublisherId, accountId, Shared.Proto.PublisherMemberRole.Viewer)) + return StatusCode(403, "You must be a viewer of the developer to list mini apps"); + + var project = await projectService.GetProjectAsync(projectId, developer.Id); + if (project is null) return NotFound("Project not found or you don't have access"); + + var miniApps = await miniAppService.GetMiniAppsByProjectAsync(projectId); + return Ok(miniApps); + } + + [HttpGet("{miniAppId:guid}")] + [Authorize] + public async Task GetMiniApp([FromRoute] string pubName, [FromRoute] Guid projectId, + [FromRoute] Guid miniAppId) + { + if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser) + return Unauthorized(); + + var developer = await ds.GetDeveloperByName(pubName); + if (developer is null) return NotFound("Developer not found"); + + var accountId = Guid.Parse(currentUser.Id); + if (!await ds.IsMemberWithRole(developer.PublisherId, accountId, Shared.Proto.PublisherMemberRole.Viewer)) + return StatusCode(403, "You must be a viewer of the developer to view mini app details"); + + var project = await projectService.GetProjectAsync(projectId, developer.Id); + if (project is null) return NotFound("Project not found or you don't have access"); + + var miniApp = await miniAppService.GetMiniAppByIdAsync(miniAppId); + if (miniApp == null || miniApp.ProjectId != projectId) + return NotFound("Mini app not found"); + + return Ok(miniApp); + } + + [HttpPost] + [Authorize] + public async Task CreateMiniApp( + [FromRoute] string pubName, + [FromRoute] Guid projectId, + [FromBody] CreateMiniAppRequest request) + { + if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser) + return Unauthorized(); + + var developer = await ds.GetDeveloperByName(pubName); + if (developer is null) + return NotFound("Developer not found"); + + if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), Shared.Proto.PublisherMemberRole.Editor)) + return StatusCode(403, "You must be an editor of the developer to create a mini app"); + + var project = await projectService.GetProjectAsync(projectId, developer.Id); + if (project is null) + return NotFound("Project not found or you don't have access"); + + try + { + var miniApp = await miniAppService.CreateMiniAppAsync(projectId, request.Slug, request.Stage, request.Manifest); + return CreatedAtAction( + nameof(GetMiniApp), + new { pubName, projectId, miniAppId = miniApp.Id }, + miniApp + ); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + [HttpPatch("{miniAppId:guid}")] + [Authorize] + public async Task UpdateMiniApp( + [FromRoute] string pubName, + [FromRoute] Guid projectId, + [FromRoute] Guid miniAppId, + [FromBody] MiniAppRequest request + ) + { + if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser) + return Unauthorized(); + + var developer = await ds.GetDeveloperByName(pubName); + if (developer is null) + return NotFound("Developer not found"); + + if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), Shared.Proto.PublisherMemberRole.Editor)) + return StatusCode(403, "You must be an editor of the developer to update a mini app"); + + var project = await projectService.GetProjectAsync(projectId, developer.Id); + if (project is null) + return NotFound("Project not found or you don't have access"); + + var miniApp = await miniAppService.GetMiniAppByIdAsync(miniAppId); + if (miniApp == null || miniApp.ProjectId != projectId) + return NotFound("Mini app not found"); + + try + { + miniApp = await miniAppService.UpdateMiniAppAsync(miniApp, request.Slug, request.Stage, request.Manifest); + return Ok(miniApp); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + [HttpDelete("{miniAppId:guid}")] + [Authorize] + public async Task DeleteMiniApp( + [FromRoute] string pubName, + [FromRoute] Guid projectId, + [FromRoute] Guid miniAppId + ) + { + if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser) + return Unauthorized(); + + var developer = await ds.GetDeveloperByName(pubName); + if (developer is null) + return NotFound("Developer not found"); + + if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), Shared.Proto.PublisherMemberRole.Editor)) + return StatusCode(403, "You must be an editor of the developer to delete a mini app"); + + var project = await projectService.GetProjectAsync(projectId, developer.Id); + if (project is null) + return NotFound("Project not found or you don't have access"); + + var miniApp = await miniAppService.GetMiniAppByIdAsync(miniAppId); + if (miniApp == null || miniApp.ProjectId != projectId) + return NotFound("Mini app not found"); + + var result = await miniAppService.DeleteMiniAppAsync(miniAppId); + if (!result) + return NotFound("Failed to delete mini app"); + + return NoContent(); + } +} diff --git a/DysonNetwork.Develop/MiniApp/MiniAppPublicController.cs b/DysonNetwork.Develop/MiniApp/MiniAppPublicController.cs new file mode 100644 index 00000000..2792e166 --- /dev/null +++ b/DysonNetwork.Develop/MiniApp/MiniAppPublicController.cs @@ -0,0 +1,22 @@ +using DysonNetwork.Shared.Models; +using Microsoft.AspNetCore.Mvc; + +namespace DysonNetwork.Develop.MiniApp; + +[ApiController] +[Route("api/miniapps")] +public class MiniAppPublicController(MiniAppService miniAppService, Identity.DeveloperService developerService) : ControllerBase +{ + [HttpGet("{slug}")] + public async Task> GetMiniAppBySlug([FromRoute] string slug) + { + var miniApp = await miniAppService.GetMiniAppBySlugAsync(slug); + if (miniApp is null) return NotFound("Mini app not found"); + + var developer = await developerService.GetDeveloperById(miniApp.Project.DeveloperId); + if (developer is null) return NotFound("Developer not found"); + miniApp.Developer = await developerService.LoadDeveloperPublisher(developer); + + return Ok(miniApp); + } +} diff --git a/DysonNetwork.Develop/MiniApp/MiniAppService.cs b/DysonNetwork.Develop/MiniApp/MiniAppService.cs new file mode 100644 index 00000000..c8b0f4a3 --- /dev/null +++ b/DysonNetwork.Develop/MiniApp/MiniAppService.cs @@ -0,0 +1,92 @@ +using DysonNetwork.Shared.Models; +using Microsoft.EntityFrameworkCore; + +namespace DysonNetwork.Develop.MiniApp; + +public class MiniAppService(AppDatabase db) +{ + public async Task GetMiniAppByIdAsync(Guid id) + { + return await db.MiniApps + .Include(m => m.Project) + .FirstOrDefaultAsync(m => m.Id == id); + } + + public async Task GetMiniAppBySlugAsync(string slug) + { + return await db.MiniApps + .Include(m => m.Project) + .FirstOrDefaultAsync(m => m.Slug == slug); + } + + public async Task> GetMiniAppsByProjectAsync(Guid projectId) + { + return await db.MiniApps + .Where(m => m.ProjectId == projectId) + .ToListAsync(); + } + + public async Task CreateMiniAppAsync(Guid projectId, string slug, MiniAppStage stage, MiniAppManifest manifest) + { + var project = await db.DevProjects.FindAsync(projectId); + if (project == null) + throw new ArgumentException("Project not found"); + + // Check if a mini app with this slug already exists globally + var existingMiniApp = await db.MiniApps + .FirstOrDefaultAsync(m => m.Slug == slug); + + if (existingMiniApp != null) + throw new InvalidOperationException("A mini app with this slug already exists."); + + var miniApp = new SnMiniApp + { + Id = Guid.NewGuid(), + Slug = slug, + Stage = stage, + Manifest = manifest, + ProjectId = projectId, + Project = project + }; + + db.MiniApps.Add(miniApp); + await db.SaveChangesAsync(); + + return miniApp; + } + + public async Task UpdateMiniAppAsync(SnMiniApp miniApp, string? slug, MiniAppStage? stage, MiniAppManifest? manifest) + { + if (slug != null && slug != miniApp.Slug) + { + // Check if another mini app with this slug already exists globally + var existingMiniApp = await db.MiniApps + .FirstOrDefaultAsync(m => m.Slug == slug && m.Id != miniApp.Id); + + if (existingMiniApp != null) + throw new InvalidOperationException("A mini app with this slug already exists."); + + miniApp.Slug = slug; + } + + if (stage.HasValue) miniApp.Stage = stage.Value; + if (manifest != null) miniApp.Manifest = manifest; + + db.Update(miniApp); + await db.SaveChangesAsync(); + + return miniApp; + } + + public async Task DeleteMiniAppAsync(Guid id) + { + var miniApp = await db.MiniApps.FindAsync(id); + if (miniApp == null) + return false; + + db.MiniApps.Remove(miniApp); + await db.SaveChangesAsync(); + + return true; + } +} diff --git a/DysonNetwork.Develop/Startup/ServiceCollectionExtensions.cs b/DysonNetwork.Develop/Startup/ServiceCollectionExtensions.cs index a1669b62..1db60140 100644 --- a/DysonNetwork.Develop/Startup/ServiceCollectionExtensions.cs +++ b/DysonNetwork.Develop/Startup/ServiceCollectionExtensions.cs @@ -48,6 +48,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/DysonNetwork.Shared/Models/MiniApp.cs b/DysonNetwork.Shared/Models/MiniApp.cs new file mode 100644 index 00000000..5c503e7e --- /dev/null +++ b/DysonNetwork.Shared/Models/MiniApp.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace DysonNetwork.Shared.Models; + +public enum MiniAppStage +{ + Development, + Staging, + Production +} + +public class MiniAppManifest +{ + public string EntryUrl { get; set; } = null!; +} + +public class SnMiniApp : ModelBase +{ + public Guid Id { get; set; } = Guid.NewGuid(); + [MaxLength(1024)] public string Slug { get; set; } = null!; + + public MiniAppStage Stage { get; set; } = MiniAppStage.Development; + + [Column(TypeName = "jsonb")] public MiniAppManifest Manifest { get; set; } = null!; + + public Guid ProjectId { get; set; } + public SnDevProject Project { get; set; } = null!; + + [NotMapped] public SnDeveloper? Developer { get; set; } +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Post/PostActionController.cs b/DysonNetwork.Sphere/Post/PostActionController.cs index 84d9b6f5..f308cffe 100644 --- a/DysonNetwork.Sphere/Post/PostActionController.cs +++ b/DysonNetwork.Sphere/Post/PostActionController.cs @@ -114,7 +114,7 @@ public class PostActionController( Content = request.Content, Visibility = request.Visibility ?? Shared.Models.PostVisibility.Public, PublishedAt = request.PublishedAt, - Type = request.Type ?? Shared.Models.PostType.Moment, + Type = request.Type ?? PostType.Moment, Metadata = request.Meta, EmbedView = request.EmbedView, Publisher = publisher,