Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ dist/
build/
out/
.artifacts/
artifacts/
.dotnet-cli-home/
restore*.log
*.log

# .NET
packages/
*.user
*.rsuser
*.suo
Expand Down Expand Up @@ -50,3 +52,5 @@ docker-compose.override.local.yml
Thumbs.db
Desktop.ini
.DS_Store
*.tmp
*.swp
131 changes: 131 additions & 0 deletions .specify/inputs/github-issues/11-create-movie.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# GitHub Issue Context

## Source

- Repository: marciomyst/SmartMovieCatalog
- Issue: #11
- URL: https://github.com/marciomyst/SmartMovieCatalog/issues/11
- State: OPEN
- Created: 05/02/2026 22:05:54
- Updated: 05/02/2026 22:25:51
- Milestone: M1 — Core Movie Catalog

## Title

Create movie

## Labels

- type:feature
- priority:high
- area:api
- area:backend
- area:catalog
- v1

## Assignees

- marciomyst



## Issue Body

## Summary

Implement the first real product vertical slice for creating a movie in Smart Movie Catalog.

This issue replaces part of the scaffold direction with an actual movie catalog behavior, while keeping the implementation intentionally simple and aligned with the current Clean Architecture structure.

## Goal

Allow a user to create a movie with basic metadata through the application.

## Scope

- Add a create movie API endpoint.
- Define explicit request/response DTOs in `SmartMovieCatalog.Contracts`.
- Add minimal application use case/orchestration in `SmartMovieCatalog.Application`.
- Add minimal domain model or domain representation only if required by the behavior.
- Persist or store the movie using the currently accepted persistence approach.
- Return a stable response containing the created movie identifier and main movie data.
- Add/update basic frontend flow if the movie creation page already exists or is part of this slice.
- Update documentation if API contracts or architecture behavior changes.

## Suggested API

```http
POST /api/movies

{
"title": "Central do Brasil",
"originalTitle": "Central do Brasil",
"releaseYear": 1998,
"countryCode": "BR",
"originalLanguage": "pt-BR",
"genres": ["Drama"],
"director": "Walter Salles",
"synopsis": "A retired teacher and a young boy travel through Brazil in search of his father.",
"durationMinutes": 110,
"ageRating": "12"
}
```


### Acceptance Criteria

- A movie can be created through the API.
- The endpoint returns `201 Created` when creation succeeds.
- The response includes the created movie ID.
- Required fields are validated.
- Invalid input returns a consistent validation/error response.
- Business rules do not live directly in controllers.
- API contracts do not expose persistence models.
- The implementation respects Clean Architecture dependency direction.
- No authentication is required in this issue.
- No Gemini, SignalR, CQRS, Wolverine, RAG, semantic search, or event-driven behavior is introduced.

### Technical Notes

- Keep the first implementation small.
- Prefer explicit DTOs.
- Do not introduce speculative abstractions.
- If persistence is not fully implemented yet, this issue depends on the persistence foundation decision or must use a clearly documented temporary storage approach.
- Do not leak database-specific details through API responses.

### Out of Scope

- Poster upload.
- Gemini Vision analysis.
- SignalR notifications.
- Authentication/authorization.
- Semantic search.
- Advanced duplicate detection.
- Bulk import.
- TMDb integration.

## Comments

_No comments_

## Instructions for Spec Kit

Use this GitHub issue as the primary source of truth.

Convert the issue into a Spec Kit feature specification before creating the implementation plan.

Preserve:

- business goal;
- user stories;
- acceptance criteria;
- technical constraints;
- non-goals;
- dependencies;
- open questions.

If information is missing, add it under a clearly marked **Clarifications Needed** section instead of inventing requirements.

If the issue conflicts with existing project documentation, explicitly call out the conflict.

Prefer a small, incremental implementation plan aligned with the repository's existing architecture, folder structure, language, framework, and conventions.
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ Do not read, modify, or base analysis on generated/vendor output unless explicit
- `test:`

<!-- SPECKIT START -->
Current Spec Kit plan: `specs/023-authentication-login-screen/plan.md`.
Current Spec Kit plan: `specs/011-create-movie/plan.md`.

Before using Spec Kit skills, read `.specify/memory/constitution.md`.
If the spec, plan, or implementation touches backend, API, contracts, domain,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using Microsoft.AspNetCore.Mvc;
using SmartMovieCatalog.Api.Common;
using SmartMovieCatalog.Application.Features.Movies;
using SmartMovieCatalog.Contracts.Movies;
using Wolverine;

namespace SmartMovieCatalog.Api.Features.Movies.CreateMovie;

public static class CreateMovieEndpoint
{
public static void MapCreateMovie(this IEndpointRouteBuilder app)
{
app.MapPost("/api/movies", HandleAsync)
.WithName("CreateMovie")
.WithTags("Movies")
.AllowAnonymous()
.AddEndpointFilter<ValidationFilter<CreateMovieRequest>>()
.Produces<MovieResponse>(StatusCodes.Status201Created)
.ProducesValidationProblem(StatusCodes.Status400BadRequest);
}

private static async Task<IResult> HandleAsync(
[FromBody] CreateMovieRequest? request,
IMessageBus messageBus,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);

CreatedMovie createdMovie = await messageBus.InvokeAsync<CreatedMovie>(
new CreateMovieCommand(
request.Title!,
request.OriginalTitle,
request.ReleaseYear!.Value,
request.CountryCode!,
request.OriginalLanguage!,
request.Genres,
request.Director,
request.Synopsis,
request.DurationMinutes,
request.AgeRating),
cancellationToken);

MovieResponse response = new(
createdMovie.Id.ToString(),
createdMovie.Title,
createdMovie.OriginalTitle,
createdMovie.ReleaseYear,
createdMovie.CountryCode,
createdMovie.OriginalLanguage,
createdMovie.Genres,
createdMovie.Director,
createdMovie.Synopsis,
createdMovie.DurationMinutes,
createdMovie.AgeRating);

return Results.Created($"/api/movies/{response.Id}", response);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using FluentValidation;
using SmartMovieCatalog.Contracts.Movies;

namespace SmartMovieCatalog.Api.Features.Movies.CreateMovie;

public sealed class CreateMovieRequestValidator : AbstractValidator<CreateMovieRequest>
{
public CreateMovieRequestValidator()
{
RuleFor(request => request.Title)
.NotEmpty();

RuleFor(request => request.ReleaseYear)
.NotNull()
.InclusiveBetween(1888, DateTimeOffset.UtcNow.Year + 1);

RuleFor(request => request.CountryCode)
.NotEmpty()
.Must(countryCode => countryCode is not null &&
countryCode.Trim().Length == 2 &&
countryCode.Trim().All(char.IsLetter))
.WithMessage("Country code must contain exactly two letters.");

RuleFor(request => request.OriginalLanguage)
.NotEmpty();

RuleForEach(request => request.Genres)
.Must(genre => !string.IsNullOrWhiteSpace(genre))
.WithMessage("Genre must not be empty.");

RuleFor(request => request.DurationMinutes)
.GreaterThan(0)
.When(request => request.DurationMinutes.HasValue);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using SmartMovieCatalog.Api.Features.Movies.CreateMovie;

namespace SmartMovieCatalog.Api.Features.Movies;

public static class MoviesEndpoints
{
public static void MapMovieEndpoints(this IEndpointRouteBuilder app)
{
app.MapCreateMovie();
}
}
5 changes: 5 additions & 0 deletions backend/src/SmartMovieCatalog.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@
using SmartMovieCatalog.Api.Common;
using SmartMovieCatalog.Api.Features.Auth;
using SmartMovieCatalog.Api.Features.Auth.Authenticate;
using SmartMovieCatalog.Api.Features.Movies;
using SmartMovieCatalog.Api.Features.Movies.CreateMovie;
using SmartMovieCatalog.Application;
using SmartMovieCatalog.Contracts.Auth;
using SmartMovieCatalog.Contracts.Movies;
using SmartMovieCatalog.Infrastructure;
using SmartMovieCatalog.Infrastructure.Persistence;
using Wolverine;
Expand All @@ -30,6 +33,7 @@ public static int Main(string[] args)
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddScoped<IValidator<AuthenticateRequest>, AuthenticateRequestValidator>();
builder.Services.AddScoped<IValidator<CreateMovieRequest>, CreateMovieRequestValidator>();
builder.Services.AddHealthChecks();

builder.Services.AddOpenApi();
Expand Down Expand Up @@ -69,6 +73,7 @@ public static int Main(string[] args)


app.MapAuthEndpoints();
app.MapMovieEndpoints();

app.MapHealthChecks("/health");

Expand Down
19 changes: 19 additions & 0 deletions backend/src/SmartMovieCatalog.Api/SmartMovieCatalog.Api.http
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,22 @@ Authorization: Bearer {{access_token}}
Accept: application/json

###

POST {{SmartMovieCatalog.Api_HostAddress}}/api/movies
Content-Type: application/json
Accept: application/json

{
"title": "Central do Brasil",
"originalTitle": "Central do Brasil",
"releaseYear": 1998,
"countryCode": "br",
"originalLanguage": "pt-BR",
"genres": ["Drama"],
"director": "Walter Salles",
"synopsis": "A retired teacher and a young boy travel through Brazil in search of his father.",
"durationMinutes": 110,
"ageRating": "12"
}

###
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using SmartMovieCatalog.Domain.Movies;

namespace SmartMovieCatalog.Application.Abstractions.Persistence;

public interface IMovieRepository
{
Task AddAsync(Movie movie, CancellationToken cancellationToken);

Task SaveChangesAsync(CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace SmartMovieCatalog.Application.Features.Movies;

public sealed record CreateMovieCommand(
string Title,
string? OriginalTitle,
int ReleaseYear,
string CountryCode,
string OriginalLanguage,
IReadOnlyCollection<string>? Genres,
string? Director,
string? Synopsis,
int? DurationMinutes,
string? AgeRating);
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using SmartMovieCatalog.Application.Abstractions.Persistence;
using SmartMovieCatalog.Application.Abstractions.Time;
using SmartMovieCatalog.Domain.Movies;

namespace SmartMovieCatalog.Application.Features.Movies;

public sealed class CreateMovieHandler
{
private readonly IMovieRepository _movieRepository;
private readonly IClock _clock;

public CreateMovieHandler(IMovieRepository movieRepository, IClock clock)
{
_movieRepository = movieRepository;
_clock = clock;
}

public async Task<CreatedMovie> Handle(CreateMovieCommand command, CancellationToken cancellationToken)
{
Movie movie = Movie.Create(
MovieId.New(),
command.Title,
command.OriginalTitle,
command.ReleaseYear,
command.CountryCode,
command.OriginalLanguage,
command.Genres?.Select(MovieGenre.Create),
command.Director,
command.Synopsis,
command.DurationMinutes,
command.AgeRating,
_clock.UtcNow);

await _movieRepository.AddAsync(movie, cancellationToken);
await _movieRepository.SaveChangesAsync(cancellationToken);

return new CreatedMovie(
movie.Id,
movie.Title,
movie.OriginalTitle,
movie.ReleaseYear,
movie.CountryCode,
movie.OriginalLanguage,
movie.Genres.Select(genre => genre.Name).ToArray(),
movie.Director,
movie.Synopsis,
movie.DurationMinutes,
movie.AgeRating);
}
}
Loading
Loading