feat: integration cloudfile

This commit is contained in:
2026-09-06 12:33:38 +07:00
parent c2088f42dc
commit 888dd41dcd
24 changed files with 1148 additions and 28 deletions
@@ -0,0 +1,71 @@
using Microsoft.Extensions.Logging;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.application.Documents;
namespace mws.backend.dotnet.infrastructure.CloudFile;
public class CloudFileDocumentContentStore(
ICloudFileHttpClient cf,
ILogger<CloudFileDocumentContentStore> log) : IDocumentContentStore
{
private static string Bucket(Guid projectId) => $"mws-p-{projectId:N}";
private static string Key(Guid documentId) => $"{documentId:N}.html";
public Task EnsureBucketAsync(Guid projectId, CancellationToken ct) =>
cf.EnsureBucketAsync(Bucket(projectId), ct);
public async Task UploadAsync(Guid projectId, Guid documentId, string contentType, Stream body, CancellationToken ct)
{
try
{
var meta = await cf.UploadAsync(Bucket(projectId), Key(documentId), contentType, body, ct);
log.LogDebug("Uploaded document {DocumentId} to cloudfile: {Hash}", documentId, meta?.ContentHash);
}
catch (HttpRequestException ex)
{
log.LogError(ex, "CloudFile upload failed for document {DocumentId}", documentId);
throw new StorageUnavailableException("Cloud file upload failed", ex);
}
}
public async Task<Stream> DownloadAsync(Guid projectId, Guid documentId, CancellationToken ct)
{
try
{
return await cf.DownloadAsync(Bucket(projectId), Key(documentId), ct);
}
catch (HttpRequestException ex)
{
log.LogError(ex, "CloudFile download failed for document {DocumentId}", documentId);
throw new StorageUnavailableException("Cloud file download failed", ex);
}
}
public async Task<bool> ExistsAsync(Guid projectId, Guid documentId, CancellationToken ct)
{
try
{
return await cf.ExistsAsync(Bucket(projectId), Key(documentId), ct);
}
catch (HttpRequestException)
{
return false;
}
}
public async Task DeleteAsync(Guid projectId, Guid documentId, CancellationToken ct)
{
var bucket = Bucket(projectId);
var key = Key(documentId);
log.LogInformation("CloudFile delete: bucket={Bucket}, key={Key}", bucket, key);
try
{
await cf.DeleteAsync(bucket, key, ct);
log.LogInformation("CloudFile delete OK for document {DocumentId}", documentId);
}
catch (HttpRequestException ex)
{
log.LogWarning(ex, "CloudFile delete failed for document {DocumentId} (bucket={Bucket}, key={Key})", documentId, bucket, key);
}
}
}
@@ -0,0 +1,146 @@
using System.Net;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace mws.backend.dotnet.infrastructure.CloudFile;
public interface ICloudFileHttpClient
{
Task<CloudFileMetadata?> UploadAsync(string bucket, string key, string contentType, Stream body, CancellationToken ct);
Task<Stream> DownloadAsync(string bucket, string key, CancellationToken ct);
Task<bool> ExistsAsync(string bucket, string key, CancellationToken ct);
Task DeleteAsync(string bucket, string key, CancellationToken ct);
Task EnsureBucketAsync(string bucket, CancellationToken ct);
}
public class CloudFileHttpClient(
HttpClient http,
IOptions<CloudFileOptions> opt,
ILogger<CloudFileHttpClient> log) : ICloudFileHttpClient
{
private static readonly JsonSerializerOptions Json = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
private HttpRequestMessage Req(HttpMethod method, string url)
{
var request = new HttpRequestMessage(method, url);
request.Headers.Add("X-API-Key", opt.Value.ApiKey);
return request;
}
public async Task<CloudFileMetadata?> UploadAsync(string bucket, string key, string contentType, Stream body, CancellationToken ct)
{
using var form = new MultipartFormDataContent
{
{ new StreamContent(body), "file", key },
{ new StringContent(bucket), "bucket" },
{ new StringContent(key), "key" }
};
var request = Req(HttpMethod.Post, "/api/files/upload");
request.Content = form;
var resp = await http.SendAsync(request, ct);
var responseText = await resp.Content.ReadAsStringAsync(ct);
if (!resp.IsSuccessStatusCode)
{
log.LogWarning("CloudFile upload failed {Status}: {Body}", resp.StatusCode, responseText);
resp.EnsureSuccessStatusCode();
}
var dto = JsonSerializer.Deserialize<CloudFileUploadResponse>(responseText, Json);
return dto?.Metadata;
}
public async Task<Stream> DownloadAsync(string bucket, string key, CancellationToken ct)
{
var request = Req(HttpMethod.Get, $"/api/files/download/{bucket}/{Uri.EscapeDataString(key)}");
var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
if (resp.StatusCode == HttpStatusCode.NotFound)
{
return Stream.Null;
}
resp.EnsureSuccessStatusCode();
return await resp.Content.ReadAsStreamAsync(ct);
}
public async Task<bool> ExistsAsync(string bucket, string key, CancellationToken ct)
{
var request = Req(HttpMethod.Get, $"/api/files/{bucket}/metadata/{Uri.EscapeDataString(key)}");
var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
return resp.IsSuccessStatusCode;
}
public async Task DeleteAsync(string bucket, string key, CancellationToken ct)
{
var request = Req(HttpMethod.Delete, $"/api/files/{bucket}/{Uri.EscapeDataString(key)}");
var resp = await http.SendAsync(request, ct);
if (resp.StatusCode != HttpStatusCode.NotFound)
{
resp.EnsureSuccessStatusCode();
}
}
public async Task EnsureBucketAsync(string bucket, CancellationToken ct)
{
var request = Req(HttpMethod.Post, "/api/buckets");
var json = JsonSerializer.Serialize(new { name = bucket }, Json);
request.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var resp = await http.SendAsync(request, ct);
var responseText = await resp.Content.ReadAsStringAsync(ct);
if (!resp.IsSuccessStatusCode && resp.StatusCode != HttpStatusCode.BadRequest)
{
log.LogWarning("CloudFile ensureBucket failed {Status}: {Body}", resp.StatusCode, responseText);
resp.EnsureSuccessStatusCode();
}
}
}
public class CloudFileUploadResponse
{
public bool Success { get; set; }
public CloudFileMetadata? Metadata { get; set; }
}
public class CloudFileMetadata
{
[JsonPropertyName("fileId")]
public string FileId { get; set; } = string.Empty;
[JsonPropertyName("fileName")]
public string FileName { get; set; } = string.Empty;
[JsonPropertyName("extension")]
public string Extension { get; set; } = string.Empty;
[JsonPropertyName("contentType")]
public string ContentType { get; set; } = string.Empty;
[JsonPropertyName("size")]
public long Size { get; set; }
[JsonPropertyName("bucket")]
public string Bucket { get; set; } = string.Empty;
[JsonPropertyName("key")]
public string Key { get; set; } = string.Empty;
[JsonPropertyName("contentHash")]
public string ContentHash { get; set; } = string.Empty;
[JsonPropertyName("createdAt")]
public DateTime CreatedAt { get; set; }
[JsonPropertyName("modifiedAt")]
public DateTime ModifiedAt { get; set; }
}
@@ -0,0 +1,9 @@
namespace mws.backend.dotnet.infrastructure.CloudFile;
public class CloudFileOptions
{
public string BaseUrl { get; set; } = string.Empty;
public string ApiKey { get; set; } = string.Empty;
public string BucketPrefix { get; set; } = "mws-p-";
public int HttpTimeoutSeconds { get; set; } = 30;
}
+13
View File
@@ -3,8 +3,10 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using mws.backend.dotnet.application.Auth;
using mws.backend.dotnet.application.Common;
using mws.backend.dotnet.application.Documents;
using mws.backend.dotnet.application.Permissions;
using mws.backend.dotnet.infrastructure.Authentication;
using mws.backend.dotnet.infrastructure.CloudFile;
using mws.backend.dotnet.infrastructure.Persistence;
namespace mws.backend.dotnet.infrastructure;
@@ -37,6 +39,17 @@ public static class DependencyInjection
services.AddScoped<IPermissionService, PermissionService>();
services.Configure<CloudFileOptions>(configuration.GetSection("CloudFile"));
services.AddHttpClient<ICloudFileHttpClient, CloudFileHttpClient>((sp, http) =>
{
var options = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<CloudFileOptions>>().Value;
#pragma warning disable S1075 // hardcoded URL — this is a configurable base address from appsettings
http.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/') + "/");
#pragma warning restore S1075
http.Timeout = TimeSpan.FromSeconds(options.HttpTimeoutSeconds);
});
services.AddScoped<IDocumentContentStore, CloudFileDocumentContentStore>();
return services;
}
}
@@ -0,0 +1,492 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using mws.backend.dotnet.infrastructure.Persistence;
#nullable disable
namespace mws.backend.dotnet.infrastructure.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260906043840_DropDocumentContent_AddStorageFields")]
partial class DropDocumentContent_AddStorageFields
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("mws.backend.dotnet.domain.Documents.Document", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ContentHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<long?>("ContentSize")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.Property<Guid>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uuid");
b.Property<DateTime?>("UpdatedContentAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("ParentId");
b.HasIndex("ProjectId", "ParentId");
b.ToTable("documents", (string)null);
});
modelBuilder.Entity("mws.backend.dotnet.domain.MasterData.MasterDataEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Group")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("Id");
b.HasIndex("Group", "Value")
.IsUnique();
b.ToTable("master_data", (string)null);
});
modelBuilder.Entity("mws.backend.dotnet.domain.Projects.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("Description")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("CreatedBy");
b.HasIndex("UpdatedBy");
b.ToTable("projects", (string)null);
});
modelBuilder.Entity("mws.backend.dotnet.domain.Projects.ProjectMember", b =>
{
b.Property<Guid>("ProjectId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.HasKey("ProjectId", "UserId");
b.HasIndex("UserId");
b.ToTable("project_members", (string)null);
});
modelBuilder.Entity("mws.backend.dotnet.domain.Projects.ProjectMemberPermission", b =>
{
b.Property<Guid>("ProjectId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("Screen")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<bool>("CanCreate")
.HasColumnType("boolean");
b.Property<bool>("CanDelete")
.HasColumnType("boolean");
b.Property<bool>("CanEdit")
.HasColumnType("boolean");
b.Property<bool>("CanView")
.HasColumnType("boolean");
b.HasKey("ProjectId", "UserId", "Screen");
b.ToTable("project_member_permissions", (string)null);
});
modelBuilder.Entity("mws.backend.dotnet.domain.Roles.Role", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsSystem")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("roles", (string)null);
});
modelBuilder.Entity("mws.backend.dotnet.domain.Roles.RolePermission", b =>
{
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.Property<string>("Screen")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<bool>("CanCreate")
.HasColumnType("boolean");
b.Property<bool>("CanDelete")
.HasColumnType("boolean");
b.Property<bool>("CanEdit")
.HasColumnType("boolean");
b.Property<bool>("CanView")
.HasColumnType("boolean");
b.HasKey("RoleId", "Screen");
b.ToTable("role_permissions", (string)null);
});
modelBuilder.Entity("mws.backend.dotnet.domain.Tasks.TaskItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AssigneeId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<DateTime?>("DueDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Priority")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<Guid>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("AssigneeId");
b.HasIndex("ProjectId");
b.HasIndex("Status");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("mws.backend.dotnet.domain.Users.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("users", (string)null);
});
modelBuilder.Entity("mws.backend.dotnet.domain.Users.UserRole", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("user_roles", (string)null);
});
modelBuilder.Entity("mws.backend.dotnet.domain.Documents.Document", b =>
{
b.HasOne("mws.backend.dotnet.domain.Documents.Document", "Parent")
.WithMany("Children")
.HasForeignKey("ParentId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Parent");
});
modelBuilder.Entity("mws.backend.dotnet.domain.Projects.Project", b =>
{
b.HasOne("mws.backend.dotnet.domain.Users.User", "CreatedByUser")
.WithMany()
.HasForeignKey("CreatedBy")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("mws.backend.dotnet.domain.Users.User", "UpdatedByUser")
.WithMany()
.HasForeignKey("UpdatedBy")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("CreatedByUser");
b.Navigation("UpdatedByUser");
});
modelBuilder.Entity("mws.backend.dotnet.domain.Projects.ProjectMember", b =>
{
b.HasOne("mws.backend.dotnet.domain.Projects.Project", "Project")
.WithMany("Members")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("mws.backend.dotnet.domain.Users.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Project");
b.Navigation("User");
});
modelBuilder.Entity("mws.backend.dotnet.domain.Projects.ProjectMemberPermission", b =>
{
b.HasOne("mws.backend.dotnet.domain.Projects.ProjectMember", null)
.WithMany()
.HasForeignKey("ProjectId", "UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("mws.backend.dotnet.domain.Roles.RolePermission", b =>
{
b.HasOne("mws.backend.dotnet.domain.Roles.Role", "Role")
.WithMany("Permissions")
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Role");
});
modelBuilder.Entity("mws.backend.dotnet.domain.Tasks.TaskItem", b =>
{
b.HasOne("mws.backend.dotnet.domain.Users.User", "Assignee")
.WithMany()
.HasForeignKey("AssigneeId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Assignee");
});
modelBuilder.Entity("mws.backend.dotnet.domain.Users.UserRole", b =>
{
b.HasOne("mws.backend.dotnet.domain.Roles.Role", "Role")
.WithMany("UserRoles")
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("mws.backend.dotnet.domain.Users.User", "User")
.WithMany("UserRoles")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Role");
b.Navigation("User");
});
modelBuilder.Entity("mws.backend.dotnet.domain.Documents.Document", b =>
{
b.Navigation("Children");
});
modelBuilder.Entity("mws.backend.dotnet.domain.Projects.Project", b =>
{
b.Navigation("Members");
});
modelBuilder.Entity("mws.backend.dotnet.domain.Roles.Role", b =>
{
b.Navigation("Permissions");
b.Navigation("UserRoles");
});
modelBuilder.Entity("mws.backend.dotnet.domain.Users.User", b =>
{
b.Navigation("UserRoles");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,60 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace mws.backend.dotnet.infrastructure.Migrations
{
/// <inheritdoc />
public partial class DropDocumentContent_AddStorageFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Content",
table: "documents");
migrationBuilder.AddColumn<string>(
name: "ContentHash",
table: "documents",
type: "character varying(64)",
maxLength: 64,
nullable: true);
migrationBuilder.AddColumn<long>(
name: "ContentSize",
table: "documents",
type: "bigint",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "UpdatedContentAt",
table: "documents",
type: "timestamp with time zone",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ContentHash",
table: "documents");
migrationBuilder.DropColumn(
name: "ContentSize",
table: "documents");
migrationBuilder.DropColumn(
name: "UpdatedContentAt",
table: "documents");
migrationBuilder.AddColumn<string>(
name: "Content",
table: "documents",
type: "text",
nullable: true);
}
}
}
@@ -28,8 +28,12 @@ namespace mws.backend.dotnet.infrastructure.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Content")
.HasColumnType("text");
b.Property<string>("ContentHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<long?>("ContentSize")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
@@ -59,6 +63,9 @@ namespace mws.backend.dotnet.infrastructure.Migrations
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uuid");
b.Property<DateTime?>("UpdatedContentAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("ParentId");
@@ -12,7 +12,9 @@ public class DocumentConfiguration : IEntityTypeConfiguration<Document>
e.HasKey(d => d.Id);
e.Property(d => d.Title).HasMaxLength(300).IsRequired();
e.Property(d => d.Type).HasConversion<string>().HasMaxLength(20);
e.Property(d => d.Content).HasColumnType("text");
e.Property(d => d.ContentHash).HasMaxLength(64);
e.Property(d => d.ContentSize);
e.Property(d => d.UpdatedContentAt);
e.HasIndex(d => new { d.ProjectId, d.ParentId });
e.HasIndex(d => d.ParentId);
-3
View File
@@ -82,21 +82,18 @@ public static class DbSeeder
{
Id = Guid.NewGuid(), ProjectId = project.Id, ParentId = requirements.Id,
Title = "Authentication", Type = DocumentType.Document,
Content = "<h2>Authentication</h2><p>Phase 1 uses simple username/password with JWT.</p>",
CreatedBy = admin.Id, CreatedAt = now, UpdatedBy = admin.Id, UpdatedAt = now,
});
db.Documents.Add(new Document
{
Id = Guid.NewGuid(), ProjectId = project.Id, ParentId = requirements.Id,
Title = "User Management", Type = DocumentType.Document,
Content = "<h2>User Management</h2><p>Users are created via seeding in Phase 1.</p>",
CreatedBy = admin.Id, CreatedAt = now, UpdatedBy = admin.Id, UpdatedAt = now,
});
db.Documents.Add(new Document
{
Id = Guid.NewGuid(), ProjectId = project.Id, ParentId = design.Id,
Title = "API Design", Type = DocumentType.Document,
Content = "<h2>API Design</h2><p>REST API with ASP.NET Core Web API.</p>",
CreatedBy = admin.Id, CreatedAt = now, UpdatedBy = admin.Id, UpdatedAt = now,
});
await db.SaveChangesAsync();