From 665595b8b428e2f13ae85d503f982e73c939b923 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Mon, 18 Aug 2025 20:49:09 +0800 Subject: [PATCH] :sparkles: Developer projects --- DysonNetwork.Develop/AppDatabase.cs | 3 + DysonNetwork.Develop/Identity/CustomApp.cs | 12 +- .../Identity/CustomAppController.cs | 94 ++++-- .../Identity/CustomAppService.cs | 32 ++- DysonNetwork.Develop/Identity/Developer.cs | 3 + .../Identity/DeveloperController.cs | 3 +- .../20250818124844_AddDevProject.Designer.cs | 270 ++++++++++++++++++ .../20250818124844_AddDevProject.cs | 96 +++++++ .../Migrations/AppDatabaseModelSnapshot.cs | 87 +++++- DysonNetwork.Develop/Project/DevProject.cs | 16 ++ .../Project/DevProjectController.cs | 107 +++++++ .../Project/DevProjectService.cs | 77 +++++ .../Startup/ServiceCollectionExtensions.cs | 2 + DysonNetwork.Shared/Proto/develop.proto | 2 +- 14 files changed, 752 insertions(+), 52 deletions(-) create mode 100644 DysonNetwork.Develop/Migrations/20250818124844_AddDevProject.Designer.cs create mode 100644 DysonNetwork.Develop/Migrations/20250818124844_AddDevProject.cs create mode 100644 DysonNetwork.Develop/Project/DevProject.cs create mode 100644 DysonNetwork.Develop/Project/DevProjectController.cs create mode 100644 DysonNetwork.Develop/Project/DevProjectService.cs diff --git a/DysonNetwork.Develop/AppDatabase.cs b/DysonNetwork.Develop/AppDatabase.cs index af17c2c..d90234d 100644 --- a/DysonNetwork.Develop/AppDatabase.cs +++ b/DysonNetwork.Develop/AppDatabase.cs @@ -1,4 +1,5 @@ using DysonNetwork.Develop.Identity; +using DysonNetwork.Develop.Project; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; @@ -11,6 +12,8 @@ public class AppDatabase( { public DbSet Developers { get; set; } = null!; + public DbSet DevProjects { get; set; } = null!; + public DbSet CustomApps { get; set; } = null!; public DbSet CustomAppSecrets { get; set; } = null!; diff --git a/DysonNetwork.Develop/Identity/CustomApp.cs b/DysonNetwork.Develop/Identity/CustomApp.cs index 7f63632..ae32340 100644 --- a/DysonNetwork.Develop/Identity/CustomApp.cs +++ b/DysonNetwork.Develop/Identity/CustomApp.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json.Serialization; +using DysonNetwork.Develop.Project; using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Proto; using Google.Protobuf; @@ -37,8 +38,11 @@ public class CustomApp : ModelBase, IIdentifiedResource [JsonIgnore] public ICollection Secrets { get; set; } = new List(); - public Guid DeveloperId { get; set; } - public Developer Developer { get; set; } = null!; + public Guid ProjectId { get; set; } + public DevProject Project { get; set; } = null!; + + [NotMapped] + public Developer Developer => Project.Developer; [NotMapped] public string ResourceIdentifier => "custom-app:" + Id; @@ -72,7 +76,7 @@ public class CustomApp : ModelBase, IIdentifiedResource RequirePkce = OauthConfig.RequirePkce, AllowOfflineAccess = OauthConfig.AllowOfflineAccess }, - DeveloperId = DeveloperId.ToString(), + ProjectId = ProjectId.ToString(), CreatedAt = CreatedAt.ToTimestamp(), UpdatedAt = UpdatedAt.ToTimestamp() }; @@ -92,7 +96,7 @@ public class CustomApp : ModelBase, IIdentifiedResource Shared.Proto.CustomAppStatus.Suspended => CustomAppStatus.Suspended, _ => CustomAppStatus.Developing }; - DeveloperId = string.IsNullOrEmpty(p.DeveloperId) ? Guid.Empty : Guid.Parse(p.DeveloperId); + ProjectId = string.IsNullOrEmpty(p.ProjectId) ? Guid.Empty : Guid.Parse(p.ProjectId); CreatedAt = p.CreatedAt.ToInstant(); UpdatedAt = p.UpdatedAt.ToInstant(); if (p.Picture.Length > 0) Picture = System.Text.Json.JsonSerializer.Deserialize(p.Picture.ToStringUtf8()); diff --git a/DysonNetwork.Develop/Identity/CustomAppController.cs b/DysonNetwork.Develop/Identity/CustomAppController.cs index 3fd7600..4e27e72 100644 --- a/DysonNetwork.Develop/Identity/CustomAppController.cs +++ b/DysonNetwork.Develop/Identity/CustomAppController.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using DysonNetwork.Develop.Project; using DysonNetwork.Shared.Proto; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -6,8 +7,8 @@ using Microsoft.AspNetCore.Mvc; namespace DysonNetwork.Develop.Identity; [ApiController] -[Route("/api/developers/{pubName}/apps")] -public class CustomAppController(CustomAppService customApps, DeveloperService ds) : ControllerBase +[Route("/api/developers/{pubName}/projects/{projectId:guid}/apps")] +public class CustomAppController(CustomAppService customApps, DeveloperService ds, DevProjectService projectService) : ControllerBase { public record CustomAppRequest( [MaxLength(1024)] string? Slug, @@ -21,21 +22,28 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d ); [HttpGet] - public async Task ListApps([FromRoute] string pubName) + public async Task ListApps([FromRoute] string pubName, [FromRoute] Guid projectId) { var developer = await ds.GetDeveloperByName(pubName); if (developer is null) return NotFound(); - var apps = await customApps.GetAppsByPublisherAsync(developer.Id); + + var project = await projectService.GetProjectAsync(projectId, developer.Id); + if (project is null) return NotFound(); + + var apps = await customApps.GetAppsByProjectAsync(projectId); return Ok(apps); } - [HttpGet("{id:guid}")] - public async Task GetApp([FromRoute] string pubName, Guid id) + [HttpGet("{appId:guid}")] + public async Task GetApp([FromRoute] string pubName, [FromRoute] Guid projectId, [FromRoute] Guid appId) { var developer = await ds.GetDeveloperByName(pubName); if (developer is null) return NotFound(); + + var project = await projectService.GetProjectAsync(projectId, developer.Id); + if (project is null) return NotFound(); - var app = await customApps.GetAppAsync(id, developerId: developer.Id); + var app = await customApps.GetAppAsync(appId, projectId); if (app == null) return NotFound(); @@ -44,23 +52,40 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d [HttpPost] [Authorize] - public async Task CreateApp([FromRoute] string pubName, [FromBody] CustomAppRequest request) + public async Task CreateApp( + [FromRoute] string pubName, + [FromRoute] Guid projectId, + [FromBody] CustomAppRequest request) { - if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return Unauthorized(); + + var developer = await ds.GetDeveloperByName(pubName); + var accountId = Guid.Parse(currentUser.Id); + if (developer is null || developer.Id != accountId) + return Forbid(); + + var project = await projectService.GetProjectAsync(projectId, developer.Id); + if (project is null) + return NotFound("Project not found or you don't have access"); if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.Slug)) return BadRequest("Name and slug are required"); - var developer = await ds.GetDeveloperByName(pubName); - if (developer is null) return NotFound(); - if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor)) return StatusCode(403, "You must be an editor of the developer to create a custom app"); try { - var app = await customApps.CreateAppAsync(developer, request); - return Ok(app); + var app = await customApps.CreateAppAsync(projectId, request); + if (app == null) + return BadRequest("Failed to create app"); + + return CreatedAtAction( + nameof(GetApp), + new { pubName, projectId, appId = app.Id }, + app + ); } catch (InvalidOperationException ex) { @@ -68,23 +93,30 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d } } - [HttpPatch("{id:guid}")] + [HttpPatch("{appId:guid}")] [Authorize] public async Task UpdateApp( [FromRoute] string pubName, - [FromRoute] Guid id, + [FromRoute] Guid projectId, + [FromRoute] Guid appId, [FromBody] CustomAppRequest request ) { - if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return Unauthorized(); var developer = await ds.GetDeveloperByName(pubName); - if (developer is null) return NotFound(); - + if (developer is null) + return NotFound("Developer not found"); + if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor)) return StatusCode(403, "You must be an editor of the developer to update a custom 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 app = await customApps.GetAppAsync(id, developerId: developer.Id); + var app = await customApps.GetAppAsync(appId, projectId); if (app == null) return NotFound(); @@ -99,28 +131,36 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d } } - [HttpDelete("{id:guid}")] + [HttpDelete("{appId:guid}")] [Authorize] public async Task DeleteApp( [FromRoute] string pubName, - [FromRoute] Guid id + [FromRoute] Guid projectId, + [FromRoute] Guid appId ) { - if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return Unauthorized(); var developer = await ds.GetDeveloperByName(pubName); - if (developer is null) return NotFound(); - + if (developer is null) + return NotFound("Developer not found"); + if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor)) return StatusCode(403, "You must be an editor of the developer to delete a custom 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 app = await customApps.GetAppAsync(id, developerId: developer.Id); + var app = await customApps.GetAppAsync(appId, projectId); if (app == null) return NotFound(); - var result = await customApps.DeleteAppAsync(id); + var result = await customApps.DeleteAppAsync(appId); if (!result) return NotFound(); + return NoContent(); } } \ No newline at end of file diff --git a/DysonNetwork.Develop/Identity/CustomAppService.cs b/DysonNetwork.Develop/Identity/CustomAppService.cs index e065998..c763402 100644 --- a/DysonNetwork.Develop/Identity/CustomAppService.cs +++ b/DysonNetwork.Develop/Identity/CustomAppService.cs @@ -1,3 +1,4 @@ +using DysonNetwork.Develop.Project; using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Proto; using Microsoft.EntityFrameworkCore; @@ -11,10 +12,17 @@ public class CustomAppService( ) { public async Task CreateAppAsync( - Developer pub, + Guid projectId, CustomAppController.CustomAppRequest request ) { + var project = await db.DevProjects + .Include(p => p.Developer) + .FirstOrDefaultAsync(p => p.Id == projectId); + + if (project == null) + return null; + var app = new CustomApp { Slug = request.Slug!, @@ -23,7 +31,7 @@ public class CustomAppService( Status = request.Status ?? CustomAppStatus.Developing, Links = request.Links, OauthConfig = request.OauthConfig, - DeveloperId = pub.Id + ProjectId = projectId }; if (request.PictureId is not null) @@ -74,17 +82,23 @@ public class CustomAppService( return app; } - public async Task GetAppAsync(Guid id, Guid? developerId = null) + public async Task GetAppAsync(Guid id, Guid? projectId = null) { - var query = db.CustomApps.Where(a => a.Id == id).AsQueryable(); - if (developerId.HasValue) - query = query.Where(a => a.DeveloperId == developerId.Value); - return await query.FirstOrDefaultAsync(); + var query = db.CustomApps.AsQueryable(); + + if (projectId.HasValue) + { + query = query.Where(a => a.ProjectId == projectId.Value); + } + + return await query.FirstOrDefaultAsync(a => a.Id == id); } - public async Task> GetAppsByPublisherAsync(Guid publisherId) + public async Task> GetAppsByProjectAsync(Guid projectId) { - return await db.CustomApps.Where(a => a.DeveloperId == publisherId).ToListAsync(); + return await db.CustomApps + .Where(a => a.ProjectId == projectId) + .ToListAsync(); } public async Task UpdateAppAsync(CustomApp app, CustomAppController.CustomAppRequest request) diff --git a/DysonNetwork.Develop/Identity/Developer.cs b/DysonNetwork.Develop/Identity/Developer.cs index 0fcb98b..f8f5b2c 100644 --- a/DysonNetwork.Develop/Identity/Developer.cs +++ b/DysonNetwork.Develop/Identity/Developer.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations.Schema; +using DysonNetwork.Develop.Project; using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Data; using VerificationMark = DysonNetwork.Shared.Data.VerificationMark; @@ -10,6 +11,8 @@ public class Developer public Guid Id { get; set; } = Guid.NewGuid(); public Guid PublisherId { get; set; } + public List Projects { get; set; } = []; + [NotMapped] public PublisherInfo? Publisher { get; set; } } diff --git a/DysonNetwork.Develop/Identity/DeveloperController.cs b/DysonNetwork.Develop/Identity/DeveloperController.cs index 589b0bc..9d63f54 100644 --- a/DysonNetwork.Develop/Identity/DeveloperController.cs +++ b/DysonNetwork.Develop/Identity/DeveloperController.cs @@ -33,7 +33,8 @@ public class DeveloperController( // Get custom apps count var customAppsCount = await db.CustomApps - .Where(a => a.DeveloperId == developer.Id) + .Include(a => a.Project) + .Where(a => a.Project.DeveloperId == developer.Id) .CountAsync(); var stats = new DeveloperStats diff --git a/DysonNetwork.Develop/Migrations/20250818124844_AddDevProject.Designer.cs b/DysonNetwork.Develop/Migrations/20250818124844_AddDevProject.Designer.cs new file mode 100644 index 0000000..b5c34a9 --- /dev/null +++ b/DysonNetwork.Develop/Migrations/20250818124844_AddDevProject.Designer.cs @@ -0,0 +1,270 @@ +// +using System; +using DysonNetwork.Develop; +using DysonNetwork.Develop.Identity; +using DysonNetwork.Shared.Data; +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("20250818124844_AddDevProject")] + partial class AddDevProject + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", 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.Develop.Identity.CustomAppSecret", 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.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 => + { + 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.Develop.Identity.CustomApp", b => + { + b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_custom_apps_dev_projects_project_id"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b => + { + b.HasOne("DysonNetwork.Develop.Identity.CustomApp", "App") + .WithMany("Secrets") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_custom_app_secrets_custom_apps_app_id"); + + b.Navigation("App"); + }); + + modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", b => + { + b.HasOne("DysonNetwork.Develop.Identity.Developer", "Developer") + .WithMany("Projects") + .HasForeignKey("DeveloperId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_dev_projects_developers_developer_id"); + + b.Navigation("Developer"); + }); + + modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b => + { + b.Navigation("Projects"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DysonNetwork.Develop/Migrations/20250818124844_AddDevProject.cs b/DysonNetwork.Develop/Migrations/20250818124844_AddDevProject.cs new file mode 100644 index 0000000..74d1631 --- /dev/null +++ b/DysonNetwork.Develop/Migrations/20250818124844_AddDevProject.cs @@ -0,0 +1,96 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using NodaTime; + +#nullable disable + +namespace DysonNetwork.Develop.Migrations +{ + /// + public partial class AddDevProject : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "fk_custom_apps_developers_developer_id", + table: "custom_apps"); + + migrationBuilder.RenameColumn( + name: "developer_id", + table: "custom_apps", + newName: "project_id"); + + migrationBuilder.RenameIndex( + name: "ix_custom_apps_developer_id", + table: "custom_apps", + newName: "ix_custom_apps_project_id"); + + migrationBuilder.CreateTable( + name: "dev_projects", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + slug = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: false), + name = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: false), + description = table.Column(type: "character varying(4096)", maxLength: 4096, nullable: false), + developer_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_dev_projects", x => x.id); + table.ForeignKey( + name: "fk_dev_projects_developers_developer_id", + column: x => x.developer_id, + principalTable: "developers", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_dev_projects_developer_id", + table: "dev_projects", + column: "developer_id"); + + migrationBuilder.AddForeignKey( + name: "fk_custom_apps_dev_projects_project_id", + table: "custom_apps", + column: "project_id", + principalTable: "dev_projects", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "fk_custom_apps_dev_projects_project_id", + table: "custom_apps"); + + migrationBuilder.DropTable( + name: "dev_projects"); + + migrationBuilder.RenameColumn( + name: "project_id", + table: "custom_apps", + newName: "developer_id"); + + migrationBuilder.RenameIndex( + name: "ix_custom_apps_project_id", + table: "custom_apps", + newName: "ix_custom_apps_developer_id"); + + migrationBuilder.AddForeignKey( + name: "fk_custom_apps_developers_developer_id", + table: "custom_apps", + column: "developer_id", + principalTable: "developers", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + } + } +} diff --git a/DysonNetwork.Develop/Migrations/AppDatabaseModelSnapshot.cs b/DysonNetwork.Develop/Migrations/AppDatabaseModelSnapshot.cs index 9105098..2937391 100644 --- a/DysonNetwork.Develop/Migrations/AppDatabaseModelSnapshot.cs +++ b/DysonNetwork.Develop/Migrations/AppDatabaseModelSnapshot.cs @@ -49,10 +49,6 @@ namespace DysonNetwork.Develop.Migrations .HasColumnType("character varying(4096)") .HasColumnName("description"); - b.Property("DeveloperId") - .HasColumnType("uuid") - .HasColumnName("developer_id"); - b.Property("Links") .HasColumnType("jsonb") .HasColumnName("links"); @@ -71,6 +67,10 @@ namespace DysonNetwork.Develop.Migrations .HasColumnType("jsonb") .HasColumnName("picture"); + b.Property("ProjectId") + .HasColumnType("uuid") + .HasColumnName("project_id"); + b.Property("Slug") .IsRequired() .HasMaxLength(1024) @@ -92,8 +92,8 @@ namespace DysonNetwork.Develop.Migrations b.HasKey("Id") .HasName("pk_custom_apps"); - b.HasIndex("DeveloperId") - .HasDatabaseName("ix_custom_apps_developer_id"); + b.HasIndex("ProjectId") + .HasDatabaseName("ix_custom_apps_project_id"); b.ToTable("custom_apps", (string)null); }); @@ -166,16 +166,66 @@ namespace DysonNetwork.Develop.Migrations b.ToTable("developers", (string)null); }); + modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", 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.Develop.Identity.CustomApp", b => { - b.HasOne("DysonNetwork.Develop.Identity.Developer", "Developer") + b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project") .WithMany() - .HasForeignKey("DeveloperId") + .HasForeignKey("ProjectId") .OnDelete(DeleteBehavior.Cascade) .IsRequired() - .HasConstraintName("fk_custom_apps_developers_developer_id"); + .HasConstraintName("fk_custom_apps_dev_projects_project_id"); - b.Navigation("Developer"); + b.Navigation("Project"); }); modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b => @@ -190,10 +240,27 @@ namespace DysonNetwork.Develop.Migrations b.Navigation("App"); }); + modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", b => + { + b.HasOne("DysonNetwork.Develop.Identity.Developer", "Developer") + .WithMany("Projects") + .HasForeignKey("DeveloperId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_dev_projects_developers_developer_id"); + + b.Navigation("Developer"); + }); + modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b => { b.Navigation("Secrets"); }); + + modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b => + { + b.Navigation("Projects"); + }); #pragma warning restore 612, 618 } } diff --git a/DysonNetwork.Develop/Project/DevProject.cs b/DysonNetwork.Develop/Project/DevProject.cs new file mode 100644 index 0000000..5566a43 --- /dev/null +++ b/DysonNetwork.Develop/Project/DevProject.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; +using DysonNetwork.Develop.Identity; +using DysonNetwork.Shared.Data; + +namespace DysonNetwork.Develop.Project; + +public class DevProject : ModelBase +{ + public Guid Id { get; set; } = Guid.NewGuid(); + [MaxLength(1024)] public string Slug { get; set; } = string.Empty; + [MaxLength(1024)] public string Name { get; set; } = string.Empty; + [MaxLength(4096)] public string Description { get; set; } = string.Empty; + + public Developer Developer { get; set; } = null!; + public Guid DeveloperId { get; set; } +} \ No newline at end of file diff --git a/DysonNetwork.Develop/Project/DevProjectController.cs b/DysonNetwork.Develop/Project/DevProjectController.cs new file mode 100644 index 0000000..c793c29 --- /dev/null +++ b/DysonNetwork.Develop/Project/DevProjectController.cs @@ -0,0 +1,107 @@ +using System.ComponentModel.DataAnnotations; +using DysonNetwork.Develop.Identity; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using DysonNetwork.Shared.Proto; + +namespace DysonNetwork.Develop.Project; + +[ApiController] +[Route("/api/developers/{pubName}/projects")] +public class DevProjectController(DevProjectService projectService, DeveloperService developerService) : ControllerBase +{ + public record DevProjectRequest( + [MaxLength(1024)] string? Slug, + [MaxLength(1024)] string? Name, + [MaxLength(4096)] string? Description + ); + + [HttpGet] + public async Task ListProjects([FromRoute] string pubName) + { + var developer = await developerService.GetDeveloperByName(pubName); + if (developer is null) return NotFound(); + + var projects = await projectService.GetProjectsByDeveloperAsync(developer.Id); + return Ok(projects); + } + + [HttpGet("{id:guid}")] + public async Task GetProject([FromRoute] string pubName, Guid id) + { + var developer = await developerService.GetDeveloperByName(pubName); + if (developer is null) return NotFound(); + + var project = await projectService.GetProjectAsync(id, developer.Id); + if (project is null) return NotFound(); + + return Ok(project); + } + + [HttpPost] + [Authorize] + public async Task CreateProject([FromRoute] string pubName, [FromBody] DevProjectRequest request) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return Unauthorized(); + + var developer = await developerService.GetDeveloperByName(pubName); + if (developer is null) + return NotFound("Developer not found"); + + if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor)) + return StatusCode(403, "You must be an editor of the developer to create a project"); + + if (string.IsNullOrWhiteSpace(request.Slug) || string.IsNullOrWhiteSpace(request.Name)) + return BadRequest("Slug and Name are required"); + + var project = await projectService.CreateProjectAsync(developer, request); + return CreatedAtAction( + nameof(GetProject), + new { pubName, id = project.Id }, + project + ); + } + + [HttpPut("{id:guid}")] + [Authorize] + public async Task UpdateProject( + [FromRoute] string pubName, + [FromRoute] Guid id, + [FromBody] DevProjectRequest request + ) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return Unauthorized(); + + var developer = await developerService.GetDeveloperByName(pubName); + var accountId = Guid.Parse(currentUser.Id); + if (developer is null || developer.Id != accountId) + return Forbid(); + + var project = await projectService.UpdateProjectAsync(id, developer.Id, request); + if (project is null) + return NotFound(); + + return Ok(project); + } + + [HttpDelete("{id:guid}")] + [Authorize] + public async Task DeleteProject([FromRoute] string pubName, [FromRoute] Guid id) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return Unauthorized(); + + var developer = await developerService.GetDeveloperByName(pubName); + var accountId = Guid.Parse(currentUser.Id); + if (developer is null || developer.Id != accountId) + return Forbid(); + + var success = await projectService.DeleteProjectAsync(id, developer.Id); + if (!success) + return NotFound(); + + return NoContent(); + } +} diff --git a/DysonNetwork.Develop/Project/DevProjectService.cs b/DysonNetwork.Develop/Project/DevProjectService.cs new file mode 100644 index 0000000..5bd27cf --- /dev/null +++ b/DysonNetwork.Develop/Project/DevProjectService.cs @@ -0,0 +1,77 @@ +using DysonNetwork.Develop.Identity; +using Microsoft.EntityFrameworkCore; +using DysonNetwork.Shared.Proto; + +namespace DysonNetwork.Develop.Project; + +public class DevProjectService( + AppDatabase db, + FileReferenceService.FileReferenceServiceClient fileRefs, + FileService.FileServiceClient files +) +{ + public async Task CreateProjectAsync( + Developer developer, + DevProjectController.DevProjectRequest request + ) + { + var project = new DevProject + { + Slug = request.Slug!, + Name = request.Name!, + Description = request.Description ?? string.Empty, + DeveloperId = developer.Id + }; + + db.DevProjects.Add(project); + await db.SaveChangesAsync(); + + return project; + } + + public async Task GetProjectAsync(Guid id, Guid? developerId = null) + { + var query = db.DevProjects.AsQueryable(); + + if (developerId.HasValue) + { + query = query.Where(p => p.DeveloperId == developerId.Value); + } + + return await query.FirstOrDefaultAsync(p => p.Id == id); + } + + public async Task> GetProjectsByDeveloperAsync(Guid developerId) + { + return await db.DevProjects + .Where(p => p.DeveloperId == developerId) + .ToListAsync(); + } + + public async Task UpdateProjectAsync( + Guid id, + Guid developerId, + DevProjectController.DevProjectRequest request + ) + { + var project = await GetProjectAsync(id, developerId); + if (project == null) return null; + + if (request.Slug != null) project.Slug = request.Slug; + if (request.Name != null) project.Name = request.Name; + if (request.Description != null) project.Description = request.Description; + + await db.SaveChangesAsync(); + return project; + } + + public async Task DeleteProjectAsync(Guid id, Guid developerId) + { + var project = await GetProjectAsync(id, developerId); + if (project == null) return false; + + db.DevProjects.Remove(project); + await db.SaveChangesAsync(); + return true; + } +} diff --git a/DysonNetwork.Develop/Startup/ServiceCollectionExtensions.cs b/DysonNetwork.Develop/Startup/ServiceCollectionExtensions.cs index a1041f5..faa5b7b 100644 --- a/DysonNetwork.Develop/Startup/ServiceCollectionExtensions.cs +++ b/DysonNetwork.Develop/Startup/ServiceCollectionExtensions.cs @@ -4,6 +4,7 @@ using NodaTime; using NodaTime.Serialization.SystemTextJson; using System.Text.Json; using DysonNetwork.Develop.Identity; +using DysonNetwork.Develop.Project; using DysonNetwork.Shared.Cache; using StackExchange.Redis; @@ -50,6 +51,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/DysonNetwork.Shared/Proto/develop.proto b/DysonNetwork.Shared/Proto/develop.proto index 85be2b4..bb4067d 100644 --- a/DysonNetwork.Shared/Proto/develop.proto +++ b/DysonNetwork.Shared/Proto/develop.proto @@ -39,7 +39,7 @@ enum CustomAppStatus { bytes links = 9; CustomAppOauthConfig oauth_config = 13; - string developer_id = 10; + string project_id = 10; google.protobuf.Timestamp created_at = 11; google.protobuf.Timestamp updated_at = 12;