feat(presets): admin preset stories (premade example videos) end-to-end
Build backend images / build content-svc (push) Failing after 31s
Build backend images / build file-svc (push) Failing after 31s
Build backend images / build gateway (push) Failing after 31s
Build backend images / build identity-svc (push) Failing after 30s
Build backend images / build notification-svc (push) Failing after 30s
Build backend images / build render-svc (push) Failing after 31s
Build backend images / build studio-svc (push) Failing after 31s

Epic A — admins author premade example videos per template; users pick one
on the template detail page to start a pre-filled project.

Backend (content-svc):
- PresetStory DTOs + PresetStoryService (admin CRUD + public published-only
  filter via role check + soft-delete) + PresetStoriesController (/v1/preset-stories)
- DI registration; gateway route /v1/preset-stories (optionalAuth, public read)

Frontend:
- ProjectPresetStories admin authoring UI (name/description/demo upload/published/
  sort + scene picker with order+duration + advanced scenes_spa); «ویدیوهای نمونه»
  button + modal in ProjectsAdmin
- TemplateDetailExamples renders real published stories (image/video preview,
  hover → "use this example" → creates a pre-bound project), falls back to
  placeholders when none; selected aspect's variant id keys the fetch
- public /api/preset-stories route; preset_story_id plumbed through
  createProjectFromTemplate + projects POST route; usePreset i18n (fa+en)

Verified: full CRUD via gateway (public hides unpublished); creating a project
with presetStoryId persists selected_preset_story_id on the saved project.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-11 05:24:14 +03:30
parent 23624f7db9
commit ab568c0663
14 changed files with 550 additions and 23 deletions
@@ -0,0 +1,99 @@
using FlatRender.ContentSvc.Domain.Entities;
using FlatRender.ContentSvc.Infrastructure.Data;
using FlatRender.ContentSvc.Models;
using Microsoft.EntityFrameworkCore;
namespace FlatRender.ContentSvc.Application.Services;
/// <summary>
/// CRUD for admin-authored preset stories (premade example videos) per template.
/// Public callers see published stories only; admins see drafts too.
/// </summary>
public class PresetStoryService(ContentDbContext db)
{
public async Task<List<PresetStorySummary>> GetByProjectAsync(Guid projectId, bool publishedOnly) =>
await db.PresetStories
.Where(s => s.ProjectId == projectId && (!publishedOnly || s.IsPublished))
.OrderBy(s => s.Sort).ThenByDescending(s => s.CreatedAt)
.Select(s => new PresetStorySummary(
s.Id, s.ProjectId, s.Name, s.Description, s.Demo, s.MusicId,
s.Sort, s.IsPublished, s.Scenes.Count, s.CreatedAt, s.UpdatedAt))
.ToListAsync();
public async Task<PresetStoryResponse?> GetAsync(Guid id, bool publishedOnly)
{
var s = await db.PresetStories
.Include(x => x.Scenes).ThenInclude(ps => ps.Scene)
.FirstOrDefaultAsync(x => x.Id == id);
if (s == null || (publishedOnly && !s.IsPublished)) return null;
return ToResponse(s);
}
public async Task<PresetStoryResponse> CreateAsync(SavePresetStoryRequest req)
{
var story = new PresetStory
{
ProjectId = req.ProjectId,
Name = req.Name,
Description = req.Description,
Demo = req.Demo,
MusicId = req.MusicId,
ScenesSpa = req.ScenesSpa,
Sort = req.Sort,
IsPublished = req.IsPublished,
Scenes = MapScenes(req.Scenes),
};
db.PresetStories.Add(story);
await db.SaveChangesAsync();
return await ReloadAsync(story.Id);
}
public async Task<PresetStoryResponse> UpdateAsync(Guid id, SavePresetStoryRequest req)
{
var story = await db.PresetStories.Include(s => s.Scenes).FirstOrDefaultAsync(s => s.Id == id)
?? throw new KeyNotFoundException($"Preset story {id} not found");
story.Name = req.Name;
story.Description = req.Description;
story.Demo = req.Demo;
story.MusicId = req.MusicId;
if (req.ScenesSpa != null) story.ScenesSpa = req.ScenesSpa;
story.Sort = req.Sort;
story.IsPublished = req.IsPublished;
story.UpdatedAt = DateTime.UtcNow;
// Replace the scene set wholesale (small, ordered list).
if (req.Scenes != null)
{
db.PresetScenes.RemoveRange(story.Scenes);
story.Scenes = MapScenes(req.Scenes);
}
await db.SaveChangesAsync();
return await ReloadAsync(story.Id);
}
public async Task DeleteAsync(Guid id)
{
var story = await db.PresetStories.FirstOrDefaultAsync(s => s.Id == id)
?? throw new KeyNotFoundException($"Preset story {id} not found");
story.DeletedAt = DateTime.UtcNow; // soft delete (global query filter hides it)
await db.SaveChangesAsync();
}
// ── helpers ───────────────────────────────────────────────────────────────
private static List<PresetScene> MapScenes(List<PresetSceneInput>? scenes) =>
(scenes ?? []).Select(s => new PresetScene
{
SceneId = s.SceneId,
Sort = s.Sort,
DefaultDurationSec = s.DefaultDurationSec,
}).ToList();
private async Task<PresetStoryResponse> ReloadAsync(Guid id) =>
ToResponse(await db.PresetStories.Include(x => x.Scenes).ThenInclude(ps => ps.Scene)
.FirstAsync(x => x.Id == id));
private static PresetStoryResponse ToResponse(PresetStory s) => new(
s.Id, s.ProjectId, s.Name, s.Description, s.Demo, s.MusicId, s.ScenesSpa,
s.Sort, s.IsPublished, s.CreatedAt, s.UpdatedAt,
s.Scenes.OrderBy(ps => ps.Sort).Select(ps => new PresetSceneResponse(
ps.Id, ps.SceneId, ps.Scene?.Key, ps.Scene?.Title, ps.Sort, ps.DefaultDurationSec)).ToList());
}
@@ -0,0 +1,43 @@
using FlatRender.ContentSvc.Application.Services;
using FlatRender.ContentSvc.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace FlatRender.ContentSvc.Controllers;
[ApiController]
[Route("v1/preset-stories")]
public class PresetStoriesController(PresetStoryService svc) : ControllerBase
{
// Anonymous + non-admin callers only see published stories; admins see drafts too.
private bool IsAdmin => User.IsInRole("Admin");
[HttpGet]
public async Task<IActionResult> List([FromQuery(Name = "project_id")] Guid projectId) =>
Ok(await svc.GetByProjectAsync(projectId, publishedOnly: !IsAdmin));
[HttpGet("{id:guid}")]
public async Task<IActionResult> Get(Guid id)
{
var s = await svc.GetAsync(id, publishedOnly: !IsAdmin);
return s == null ? NotFound() : Ok(s);
}
[Authorize(Roles = "Admin")]
[HttpPost]
public async Task<IActionResult> Create([FromBody] SavePresetStoryRequest req) =>
Ok(await svc.CreateAsync(req));
[Authorize(Roles = "Admin")]
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, [FromBody] SavePresetStoryRequest req) =>
Ok(await svc.UpdateAsync(id, req));
[Authorize(Roles = "Admin")]
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id)
{
await svc.DeleteAsync(id);
return NoContent();
}
}
@@ -0,0 +1,32 @@
namespace FlatRender.ContentSvc.Models;
// ── Preset stories (admin-authored premade example videos per template) ───────
// A preset story is a ready-made, pre-filled version of a template that a user can
// pick to start a project already populated with text + media. The filled values
// live in ScenesSpa (the studio scene JSON); PresetScene rows order the scenes.
public record PresetSceneInput(Guid SceneId, int Sort, decimal? DefaultDurationSec);
public record PresetSceneResponse(
Guid Id, Guid SceneId, string? SceneKey, string? SceneTitle, int Sort, decimal? DefaultDurationSec
);
// Light projection for list endpoints (omits the heavy ScenesSpa blob).
public record PresetStorySummary(
Guid Id, Guid ProjectId, string Name, string? Description, string? Demo,
Guid? MusicId, int Sort, bool IsPublished, int SceneCount,
DateTime CreatedAt, DateTime UpdatedAt
);
// Full story incl. scenes + the filled-values blob (GET one).
public record PresetStoryResponse(
Guid Id, Guid ProjectId, string Name, string? Description, string? Demo,
Guid? MusicId, string? ScenesSpa, int Sort, bool IsPublished,
DateTime CreatedAt, DateTime UpdatedAt, List<PresetSceneResponse> Scenes
);
public record SavePresetStoryRequest(
Guid ProjectId, string Name, string? Description = null, string? Demo = null,
Guid? MusicId = null, string? ScenesSpa = null, int Sort = 0, bool IsPublished = true,
List<PresetSceneInput>? Scenes = null
);
@@ -65,6 +65,7 @@ builder.Services.AddScoped<TemplateService>();
builder.Services.AddScoped<CmsService>();
builder.Services.AddScoped<AiContentService>();
builder.Services.AddScoped<SceneColorService>();
builder.Services.AddScoped<PresetStoryService>();
builder.Services.AddScoped<AepImportService>();
// HTTP client for the OpenAI-compatible AI provider (base URL is per-tenant config).
+1
View File
@@ -121,6 +121,7 @@ func main() {
v1.Any("/scene-elements/*path", apiRL, optionalAuth, content.Handler())
v1.Any("/shared-colors/*path", apiRL, optionalAuth, content.Handler())
v1.Any("/color-presets/*path", apiRL, optionalAuth, content.Handler())
v1.Any("/preset-stories/*path", apiRL, optionalAuth, content.Handler())
// ── File Service ─────────────────────────────────────────────────────────
v1.Any("/files/*path", apiRL, auth, file.Handler())