feat(content): scenes + shared-colors + colour-presets endpoints
Build backend images / build content-svc (push) Failing after 2m53s
Build backend images / build file-svc (push) Failing after 3m55s
Build backend images / build gateway (push) Failing after 53s
Build backend images / build identity-svc (push) Failing after 3m26s
Build backend images / build notification-svc (push) Failing after 3m5s
Build backend images / build render-svc (push) Failing after 46s
Build backend images / build studio-svc (push) Failing after 2m22s

Completes the content backend for the studio building blocks (all project-scoped):
- GET /v1/scenes?project_id= + POST/PUT/DELETE (scene metadata CRUD)
- GET /v1/shared-colors?project_id= + POST/PUT/DELETE
- GET /v1/color-presets?project_id= + POST/PUT/DELETE (palette + items)
SceneColorService + DTOs; reads open, writes [Authorize(Roles=Admin)].
Gateway routes /v1/{scenes,shared-colors,color-presets}/* → content.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-02 15:35:32 +03:30
parent 9a1d60e9d0
commit e7cdf35b65
5 changed files with 331 additions and 0 deletions
@@ -0,0 +1,190 @@
using FlatRender.ContentSvc.Domain.Entities;
using FlatRender.ContentSvc.Domain.Enums;
using FlatRender.ContentSvc.Infrastructure.Data;
using FlatRender.ContentSvc.Models;
using Microsoft.EntityFrameworkCore;
namespace FlatRender.ContentSvc.Application.Services;
/// <summary>CRUD for project-scoped scenes, shared colors and colour presets.</summary>
public class SceneColorService(ContentDbContext db)
{
// ── Scenes ──────────────────────────────────────────────────────────────
public async Task<List<SceneResponse>> GetScenesAsync(Guid projectId) =>
await db.Scenes.Where(s => s.ProjectId == projectId && s.DeletedAt == null)
.OrderBy(s => s.Sort)
.Select(s => ToSceneResponse(s)).ToListAsync();
public async Task<SceneResponse> CreateSceneAsync(CreateSceneRequest req)
{
var scene = new Scene
{
ProjectId = req.ProjectId,
Key = req.Key,
Title = req.Title,
LocalizedTitle = req.LocalizedTitle,
SceneType = ParseSceneKind(req.SceneType),
Image = req.Image,
Demo = req.Demo,
SceneColorSvg = req.SceneColorSvg,
SnapshotUrl = req.SnapshotUrl,
GenerateKf = req.GenerateKf,
DefaultDurationSec = req.DefaultDurationSec,
MinDurationSec = req.MinDurationSec,
MaxDurationSec = req.MaxDurationSec,
OverlapAtEndSec = req.OverlapAtEndSec,
CanHandleDuration = req.CanHandleDuration,
ManualColorSelection = req.ManualColorSelection,
Sort = req.Sort,
IsActive = req.IsActive,
};
db.Scenes.Add(scene);
await db.SaveChangesAsync();
return ToSceneResponse(scene);
}
public async Task<SceneResponse> UpdateSceneAsync(Guid id, UpdateSceneRequest req)
{
var scene = await db.Scenes.FindAsync(id) ?? throw new KeyNotFoundException($"Scene {id} not found");
scene.Key = req.Key;
scene.Title = req.Title;
scene.LocalizedTitle = req.LocalizedTitle;
scene.SceneType = ParseSceneKind(req.SceneType);
scene.Image = req.Image;
scene.Demo = req.Demo;
scene.SceneColorSvg = req.SceneColorSvg;
scene.SnapshotUrl = req.SnapshotUrl;
scene.GenerateKf = req.GenerateKf;
scene.DefaultDurationSec = req.DefaultDurationSec;
scene.MinDurationSec = req.MinDurationSec;
scene.MaxDurationSec = req.MaxDurationSec;
scene.OverlapAtEndSec = req.OverlapAtEndSec;
scene.CanHandleDuration = req.CanHandleDuration;
scene.ManualColorSelection = req.ManualColorSelection;
scene.Sort = req.Sort;
scene.IsActive = req.IsActive;
scene.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return ToSceneResponse(scene);
}
public async Task DeleteSceneAsync(Guid id)
{
var scene = await db.Scenes.FindAsync(id) ?? throw new KeyNotFoundException($"Scene {id} not found");
scene.DeletedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
// ── Shared colours ──────────────────────────────────────────────────────
public async Task<List<SharedColorResponse>> GetSharedColorsAsync(Guid projectId) =>
await db.SharedColors.Where(c => c.ProjectId == projectId).OrderBy(c => c.Sort)
.Select(c => new SharedColorResponse(c.Id, c.ProjectId, c.ElementKey, c.Title, c.Icon,
c.AttrValue.ToString(), c.DefaultColor, c.Sort)).ToListAsync();
public async Task<SharedColorResponse> CreateSharedColorAsync(SaveSharedColorRequest req)
{
var c = new SharedColor
{
ProjectId = req.ProjectId,
ElementKey = req.ElementKey,
Title = req.Title,
Icon = req.Icon,
AttrValue = ParseAttrValue(req.AttrValue),
DefaultColor = req.DefaultColor,
Sort = req.Sort,
};
db.SharedColors.Add(c);
await db.SaveChangesAsync();
return new SharedColorResponse(c.Id, c.ProjectId, c.ElementKey, c.Title, c.Icon, c.AttrValue.ToString(), c.DefaultColor, c.Sort);
}
public async Task<SharedColorResponse> UpdateSharedColorAsync(Guid id, SaveSharedColorRequest req)
{
var c = await db.SharedColors.FindAsync(id) ?? throw new KeyNotFoundException($"Color {id} not found");
c.ElementKey = req.ElementKey;
c.Title = req.Title;
c.Icon = req.Icon;
c.AttrValue = ParseAttrValue(req.AttrValue);
c.DefaultColor = req.DefaultColor;
c.Sort = req.Sort;
c.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return new SharedColorResponse(c.Id, c.ProjectId, c.ElementKey, c.Title, c.Icon, c.AttrValue.ToString(), c.DefaultColor, c.Sort);
}
public async Task DeleteSharedColorAsync(Guid id)
{
var c = await db.SharedColors.FindAsync(id) ?? throw new KeyNotFoundException($"Color {id} not found");
db.SharedColors.Remove(c);
await db.SaveChangesAsync();
}
// ── Colour presets (palettes) ───────────────────────────────────────────
public async Task<List<ColorPresetResponse>> GetColorPresetsAsync(Guid projectId) =>
await db.SharedColorPresets.Where(p => p.ProjectId == projectId).OrderBy(p => p.Sort)
.Select(p => new ColorPresetResponse(p.Id, p.ProjectId, p.Name, p.Sort,
p.Items.OrderBy(i => i.Sort).Select(i => new ColorPresetItemResponse(i.Id, i.ElementKey, i.Value, i.Sort)).ToList()))
.ToListAsync();
public async Task<ColorPresetResponse> CreateColorPresetAsync(SaveColorPresetRequest req)
{
var preset = new SharedColorPreset
{
ProjectId = req.ProjectId,
Name = req.Name,
Sort = req.Sort,
Items = (req.Items ?? []).Select(i => new SharedColorPresetItem
{
ElementKey = i.ElementKey, Value = i.Value, Sort = i.Sort,
}).ToList(),
};
db.SharedColorPresets.Add(preset);
await db.SaveChangesAsync();
return await GetPresetAsync(preset.Id);
}
public async Task<ColorPresetResponse> UpdateColorPresetAsync(Guid id, SaveColorPresetRequest req)
{
var preset = await db.SharedColorPresets.Include(p => p.Items).FirstOrDefaultAsync(p => p.Id == id)
?? throw new KeyNotFoundException($"Preset {id} not found");
preset.Name = req.Name;
preset.Sort = req.Sort;
db.SharedColorPresetItems.RemoveRange(preset.Items);
preset.Items = (req.Items ?? []).Select(i => new SharedColorPresetItem
{
PresetId = preset.Id, ElementKey = i.ElementKey, Value = i.Value, Sort = i.Sort,
}).ToList();
await db.SaveChangesAsync();
return await GetPresetAsync(preset.Id);
}
public async Task DeleteColorPresetAsync(Guid id)
{
var preset = await db.SharedColorPresets.FindAsync(id) ?? throw new KeyNotFoundException($"Preset {id} not found");
db.SharedColorPresets.Remove(preset);
await db.SaveChangesAsync();
}
private async Task<ColorPresetResponse> GetPresetAsync(Guid id) =>
await db.SharedColorPresets.Where(p => p.Id == id)
.Select(p => new ColorPresetResponse(p.Id, p.ProjectId, p.Name, p.Sort,
p.Items.OrderBy(i => i.Sort).Select(i => new ColorPresetItemResponse(i.Id, i.ElementKey, i.Value, i.Sort)).ToList()))
.FirstAsync();
// ── helpers ─────────────────────────────────────────────────────────────
private static SceneResponse ToSceneResponse(Scene s) => new(
s.Id, s.ProjectId, s.Key, s.Title, s.LocalizedTitle, s.SceneType.ToString(),
s.Image, s.Demo, s.SceneColorSvg, s.SnapshotUrl, s.GenerateKf,
s.DefaultDurationSec, s.MinDurationSec, s.MaxDurationSec, s.OverlapAtEndSec,
s.CanHandleDuration, s.ManualColorSelection, s.Sort, s.IsActive);
private static SceneKind ParseSceneKind(string? v) =>
Enum.TryParse<SceneKind>(v, true, out var k) ? k : SceneKind.Normal;
private static AttrValueKind ParseAttrValue(string? v) =>
Enum.TryParse<AttrValueKind>(v, true, out var a) ? a : AttrValueKind.fill;
}
@@ -0,0 +1,87 @@
using FlatRender.ContentSvc.Application.Services;
using FlatRender.ContentSvc.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace FlatRender.ContentSvc.Controllers;
[ApiController]
[Route("v1/scenes")]
public class ScenesController(SceneColorService svc) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> List([FromQuery(Name = "project_id")] Guid projectId) =>
Ok(await svc.GetScenesAsync(projectId));
[Authorize(Roles = "Admin")]
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateSceneRequest req) =>
Ok(await svc.CreateSceneAsync(req));
[Authorize(Roles = "Admin")]
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateSceneRequest req) =>
Ok(await svc.UpdateSceneAsync(id, req));
[Authorize(Roles = "Admin")]
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id)
{
await svc.DeleteSceneAsync(id);
return NoContent();
}
}
[ApiController]
[Route("v1/shared-colors")]
public class SharedColorsController(SceneColorService svc) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> List([FromQuery(Name = "project_id")] Guid projectId) =>
Ok(await svc.GetSharedColorsAsync(projectId));
[Authorize(Roles = "Admin")]
[HttpPost]
public async Task<IActionResult> Create([FromBody] SaveSharedColorRequest req) =>
Ok(await svc.CreateSharedColorAsync(req));
[Authorize(Roles = "Admin")]
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, [FromBody] SaveSharedColorRequest req) =>
Ok(await svc.UpdateSharedColorAsync(id, req));
[Authorize(Roles = "Admin")]
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id)
{
await svc.DeleteSharedColorAsync(id);
return NoContent();
}
}
[ApiController]
[Route("v1/color-presets")]
public class ColorPresetsController(SceneColorService svc) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> List([FromQuery(Name = "project_id")] Guid projectId) =>
Ok(await svc.GetColorPresetsAsync(projectId));
[Authorize(Roles = "Admin")]
[HttpPost]
public async Task<IActionResult> Create([FromBody] SaveColorPresetRequest req) =>
Ok(await svc.CreateColorPresetAsync(req));
[Authorize(Roles = "Admin")]
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, [FromBody] SaveColorPresetRequest req) =>
Ok(await svc.UpdateColorPresetAsync(id, req));
[Authorize(Roles = "Admin")]
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id)
{
await svc.DeleteColorPresetAsync(id);
return NoContent();
}
}
@@ -0,0 +1,50 @@
namespace FlatRender.ContentSvc.Models;
// ── Scenes (project-scoped scene templates) ──────────────────────────────────
public record SceneResponse(
Guid Id, Guid ProjectId, string Key, string Title, string? LocalizedTitle,
string SceneType, string? Image, string? Demo, string? SceneColorSvg, string? SnapshotUrl,
bool GenerateKf, decimal? DefaultDurationSec, decimal? MinDurationSec, decimal? MaxDurationSec,
decimal OverlapAtEndSec, bool CanHandleDuration, bool ManualColorSelection, int Sort, bool IsActive
);
public record CreateSceneRequest(
Guid ProjectId, string Key, string Title, string? LocalizedTitle, string? SceneType,
string? Image, string? Demo, string? SceneColorSvg, string? SnapshotUrl, bool GenerateKf,
decimal? DefaultDurationSec, decimal? MinDurationSec, decimal? MaxDurationSec, decimal OverlapAtEndSec,
bool CanHandleDuration, bool ManualColorSelection, int Sort, bool IsActive
);
public record UpdateSceneRequest(
string Key, string Title, string? LocalizedTitle, string? SceneType,
string? Image, string? Demo, string? SceneColorSvg, string? SnapshotUrl, bool GenerateKf,
decimal? DefaultDurationSec, decimal? MinDurationSec, decimal? MaxDurationSec, decimal OverlapAtEndSec,
bool CanHandleDuration, bool ManualColorSelection, int Sort, bool IsActive
);
// ── Shared colors (project-scoped named colors) ──────────────────────────────
public record SharedColorResponse(
Guid Id, Guid ProjectId, string ElementKey, string Title, string? Icon,
string AttrValue, string DefaultColor, int Sort
);
public record SaveSharedColorRequest(
Guid ProjectId, string ElementKey, string Title, string? Icon,
string? AttrValue, string DefaultColor, int Sort
);
// ── Color presets (named palettes of colors) ─────────────────────────────────
public record ColorPresetItemResponse(Guid Id, string ElementKey, string Value, int Sort);
public record ColorPresetResponse(
Guid Id, Guid ProjectId, string? Name, int Sort, List<ColorPresetItemResponse> Items
);
public record ColorPresetItemInput(string ElementKey, string Value, int Sort);
public record SaveColorPresetRequest(
Guid ProjectId, string? Name, int Sort, List<ColorPresetItemInput> Items
);
@@ -64,6 +64,7 @@ builder.Services.AddScoped<TaxonomyService>();
builder.Services.AddScoped<TemplateService>();
builder.Services.AddScoped<CmsService>();
builder.Services.AddScoped<AiContentService>();
builder.Services.AddScoped<SceneColorService>();
// HTTP client for the OpenAI-compatible AI provider (base URL is per-tenant config).
builder.Services.AddHttpClient("openai");