diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock index 1d16352..408ae32 100644 --- a/.claude/scheduled_tasks.lock +++ b/.claude/scheduled_tasks.lock @@ -1 +1 @@ -{"sessionId":"49d9d3ba-3c81-4fe7-ab85-f0734baf92f6","pid":42320,"acquiredAt":1782462035015} \ No newline at end of file +{"sessionId":"c3cb93c0-7adf-4d3a-817d-6c01e0e0f09f","pid":26816,"acquiredAt":1782481349474} \ No newline at end of file diff --git a/TaxBaik.Application/DependencyInjection.cs b/TaxBaik.Application/DependencyInjection.cs index 324ecf3..ae0bf2b 100644 --- a/TaxBaik.Application/DependencyInjection.cs +++ b/TaxBaik.Application/DependencyInjection.cs @@ -9,6 +9,7 @@ public static class DependencyInjection { services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } } diff --git a/TaxBaik.Application/Services/BlogService.cs b/TaxBaik.Application/Services/BlogService.cs index 2091ba3..7d65791 100644 --- a/TaxBaik.Application/Services/BlogService.cs +++ b/TaxBaik.Application/Services/BlogService.cs @@ -1,6 +1,7 @@ namespace TaxBaik.Application.Services; using System.Text.RegularExpressions; +using TaxBaik.Application.DTOs; using TaxBaik.Domain.Entities; using TaxBaik.Domain.Interfaces; @@ -13,6 +14,9 @@ public class BlogService(IBlogPostRepository repository) int page, int pageSize, int? categoryId = null, CancellationToken ct = default) => await repository.GetPublishedPagedAsync(page, pageSize, categoryId, ct); + public async Task> GetAllAsync(CancellationToken ct = default) => + await repository.GetAllForAdminAsync(ct); + public async Task> GetAllForAdminAsync(CancellationToken ct = default) => await repository.GetAllForAdminAsync(ct); @@ -23,9 +27,49 @@ public class BlogService(IBlogPostRepository repository) return await repository.CreateAsync(post, ct); } + public async Task CreateAsync(CreateBlogPostDto dto, CancellationToken ct = default) + { + var post = new BlogPost + { + Title = dto.Title, + Content = dto.Content, + CategoryId = dto.CategoryId, + Tags = dto.Tags, + SeoTitle = dto.SeoTitle, + SeoDescription = dto.SeoDescription, + ThumbnailUrl = dto.ThumbnailUrl, + IsPublished = dto.IsPublished, + AuthorId = dto.AuthorId, + CreatedAt = DateTime.UtcNow + }; + + var id = await CreateAsync(post, ct); + post.Id = id; + return post; + } + public async Task UpdateAsync(BlogPost post, CancellationToken ct = default) => await repository.UpdateAsync(post, ct); + public async Task UpdateAsync(int id, CreateBlogPostDto dto, CancellationToken ct = default) + { + var post = await repository.GetByIdAsync(id, ct); + if (post == null) + return null; + + post.Title = dto.Title; + post.Content = dto.Content; + post.CategoryId = dto.CategoryId; + post.Tags = dto.Tags; + post.SeoTitle = dto.SeoTitle; + post.SeoDescription = dto.SeoDescription; + post.ThumbnailUrl = dto.ThumbnailUrl; + post.IsPublished = dto.IsPublished; + + await UpdateAsync(post, ct); + return post; + } + public async Task DeleteAsync(int id, CancellationToken ct = default) => await repository.DeleteAsync(id, ct); diff --git a/TaxBaik.Application/Services/CategoryService.cs b/TaxBaik.Application/Services/CategoryService.cs new file mode 100644 index 0000000..dc76692 --- /dev/null +++ b/TaxBaik.Application/Services/CategoryService.cs @@ -0,0 +1,54 @@ +namespace TaxBaik.Application.Services; + +using System.Text.RegularExpressions; +using TaxBaik.Domain.Entities; +using TaxBaik.Domain.Interfaces; + +public class CategoryService(ICategoryRepository repository) +{ + public async Task> GetAllAsync(CancellationToken ct = default) => + await repository.GetAllAsync(ct); + + public async Task GetBySlugAsync(string slug, CancellationToken ct = default) => + await repository.GetBySlugAsync(slug, ct); + + public async Task GetByIdAsync(int id, CancellationToken ct = default) => + await repository.GetByIdAsync(id, ct); + + public async Task CreateAsync(string name, string? description, CancellationToken ct = default) + { + var slug = GenerateSlug(name); + var category = new Category + { + Name = name.Trim(), + Slug = slug, + SortOrder = 0 + }; + + var id = await repository.CreateAsync(category, ct); + return new Category { Id = id, Name = category.Name, Slug = category.Slug, SortOrder = category.SortOrder }; + } + + public async Task UpdateAsync(int id, string name, string? description, CancellationToken ct = default) + { + var category = await repository.GetByIdAsync(id, ct); + if (category == null) + return null; + + category.Name = name.Trim(); + category.Slug = GenerateSlug(name); + await repository.UpdateAsync(category, ct); + return category; + } + + public async Task DeleteAsync(int id, CancellationToken ct = default) => + await repository.DeleteAsync(id, ct); + + private static string GenerateSlug(string name) + { + var slug = Regex.Replace(name.ToLowerInvariant(), @"[^\w\s-]", ""); + slug = Regex.Replace(slug, @"\s+", "-"); + slug = Regex.Replace(slug, @"-+", "-").Trim('-'); + return slug.Length > 100 ? slug[..100] : slug; + } +} diff --git a/TaxBaik.Domain/Interfaces/ICategoryRepository.cs b/TaxBaik.Domain/Interfaces/ICategoryRepository.cs index 4250746..9a80aba 100644 --- a/TaxBaik.Domain/Interfaces/ICategoryRepository.cs +++ b/TaxBaik.Domain/Interfaces/ICategoryRepository.cs @@ -6,4 +6,8 @@ public interface ICategoryRepository { Task> GetAllAsync(CancellationToken cancellationToken = default); Task GetBySlugAsync(string slug, CancellationToken cancellationToken = default); + Task GetByIdAsync(int id, CancellationToken cancellationToken = default); + Task CreateAsync(Category category, CancellationToken cancellationToken = default); + Task UpdateAsync(Category category, CancellationToken cancellationToken = default); + Task DeleteAsync(int id, CancellationToken cancellationToken = default); } diff --git a/TaxBaik.Infrastructure/Repositories/CategoryRepository.cs b/TaxBaik.Infrastructure/Repositories/CategoryRepository.cs index bad0949..686b099 100644 --- a/TaxBaik.Infrastructure/Repositories/CategoryRepository.cs +++ b/TaxBaik.Infrastructure/Repositories/CategoryRepository.cs @@ -20,4 +20,36 @@ public class CategoryRepository(IDbConnectionFactory connectionFactory) : BaseRe "SELECT id, name, slug, sort_order FROM categories WHERE slug = @Slug", new { Slug = slug }); } + + public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + return await conn.QueryFirstOrDefaultAsync( + "SELECT id, name, slug, sort_order FROM categories WHERE id = @Id", + new { Id = id }); + } + + public async Task CreateAsync(Category category, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + return await conn.QueryFirstAsync( + @"INSERT INTO categories (name, slug, sort_order) + VALUES (@Name, @Slug, @SortOrder) + RETURNING id", + category); + } + + public async Task UpdateAsync(Category category, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + await conn.ExecuteAsync( + "UPDATE categories SET name = @Name, slug = @Slug, sort_order = @SortOrder WHERE id = @Id", + category); + } + + public async Task DeleteAsync(int id, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + await conn.ExecuteAsync("DELETE FROM categories WHERE id = @Id", new { Id = id }); + } } diff --git a/TaxBaik.Web/Controllers/AuthController.cs b/TaxBaik.Web/Controllers/AuthController.cs new file mode 100644 index 0000000..0f98900 --- /dev/null +++ b/TaxBaik.Web/Controllers/AuthController.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Mvc; +using TaxBaik.Web.Services; + +namespace TaxBaik.Web.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class AuthController : ControllerBase +{ + private readonly AuthService _authService; + + public AuthController(AuthService authService) + { + _authService = authService; + } + + [HttpPost("login")] + public async Task Login([FromBody] LoginRequest request) + { + if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password)) + return BadRequest(new { message = "Username and password are required" }); + + var token = await _authService.AuthenticateAndGenerateTokenAsync(request.Username, request.Password); + if (token == null) + return Unauthorized(new { message = "Invalid username or password" }); + + return Ok(new { token, expiresIn = 28800 }); + } +} + +public class LoginRequest +{ + public string Username { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; +} diff --git a/TaxBaik.Web/Controllers/BlogController.cs b/TaxBaik.Web/Controllers/BlogController.cs new file mode 100644 index 0000000..33721af --- /dev/null +++ b/TaxBaik.Web/Controllers/BlogController.cs @@ -0,0 +1,71 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using TaxBaik.Application.Services; +using TaxBaik.Application.DTOs; + +namespace TaxBaik.Web.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class BlogController : ControllerBase +{ + private readonly BlogService _blogService; + + public BlogController(BlogService blogService) + { + _blogService = blogService; + } + + [HttpGet] + public async Task GetPublished([FromQuery] int page = 1, [FromQuery] int pageSize = 12) + { + var (items, total) = await _blogService.GetPublishedPagedAsync(page, pageSize); + return Ok(new { data = items, total, page, pageSize }); + } + + [HttpGet("{slug}")] + public async Task GetBySlug(string slug) + { + var post = await _blogService.GetBySlugAsync(slug); + if (post == null) + return NotFound(new { message = "Post not found" }); + return Ok(post); + } + + [HttpGet("admin/all")] + [Authorize] + public async Task GetAll() + { + var posts = await _blogService.GetAllAsync(); + return Ok(posts); + } + + [HttpPost] + [Authorize] + public async Task Create([FromBody] CreateBlogPostDto dto) + { + if (string.IsNullOrWhiteSpace(dto.Title) || string.IsNullOrWhiteSpace(dto.Content)) + return BadRequest(new { message = "Title and content are required" }); + + var result = await _blogService.CreateAsync(dto); + return CreatedAtAction(nameof(GetBySlug), new { slug = result.Slug }, result); + } + + [HttpPut("{id}")] + [Authorize] + public async Task Update(int id, [FromBody] CreateBlogPostDto dto) + { + var result = await _blogService.UpdateAsync(id, dto); + if (result == null) + return NotFound(new { message = "Post not found" }); + return Ok(result); + } + + [HttpDelete("{id}")] + [Authorize] + public async Task Delete(int id) + { + await _blogService.DeleteAsync(id); + return NoContent(); + } +} diff --git a/TaxBaik.Web/Controllers/CategoryController.cs b/TaxBaik.Web/Controllers/CategoryController.cs new file mode 100644 index 0000000..88c1b94 --- /dev/null +++ b/TaxBaik.Web/Controllers/CategoryController.cs @@ -0,0 +1,59 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using TaxBaik.Application.Services; + +namespace TaxBaik.Web.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class CategoryController : ControllerBase +{ + private readonly CategoryService _categoryService; + + public CategoryController(CategoryService categoryService) + { + _categoryService = categoryService; + } + + [HttpGet] + public async Task GetAll() + { + var categories = await _categoryService.GetAllAsync(); + return Ok(categories); + } + + [HttpPost] + [Authorize] + public async Task Create([FromBody] CreateCategoryRequest request) + { + if (string.IsNullOrWhiteSpace(request.Name)) + return BadRequest(new { message = "Category name is required" }); + + var category = await _categoryService.CreateAsync(request.Name, request.Description); + return CreatedAtAction(nameof(GetAll), category); + } + + [HttpPut("{id}")] + [Authorize] + public async Task Update(int id, [FromBody] CreateCategoryRequest request) + { + var category = await _categoryService.UpdateAsync(id, request.Name, request.Description); + if (category == null) + return NotFound(new { message = "Category not found" }); + return Ok(category); + } + + [HttpDelete("{id}")] + [Authorize] + public async Task Delete(int id) + { + await _categoryService.DeleteAsync(id); + return NoContent(); + } +} + +public class CreateCategoryRequest +{ + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } +} diff --git a/TaxBaik.Web/Controllers/InquiryController.cs b/TaxBaik.Web/Controllers/InquiryController.cs new file mode 100644 index 0000000..0836acf --- /dev/null +++ b/TaxBaik.Web/Controllers/InquiryController.cs @@ -0,0 +1,74 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using TaxBaik.Application.Services; +using TaxBaik.Domain.Interfaces; + +namespace TaxBaik.Web.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class InquiryController : ControllerBase +{ + private readonly InquiryService _inquiryService; + private readonly IInquiryRepository _inquiryRepository; + + public InquiryController(InquiryService inquiryService, IInquiryRepository inquiryRepository) + { + _inquiryService = inquiryService; + _inquiryRepository = inquiryRepository; + } + + [HttpPost] + public async Task Submit([FromBody] SubmitInquiryRequest request) + { + if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.Phone)) + return BadRequest(new { message = "Name and phone are required" }); + + await _inquiryService.SubmitAsync(request.Name, request.Phone, request.ServiceType, request.Message); + return Ok(new { message = "Inquiry submitted successfully" }); + } + + [HttpGet] + [Authorize] + public async Task GetPaged([FromQuery] int page = 1, [FromQuery] int pageSize = 20) + { + var (inquiries, total) = await _inquiryRepository.GetPagedAsync(page, pageSize); + return Ok(new { data = inquiries, total, page, pageSize }); + } + + [HttpGet("{id}")] + [Authorize] + public async Task GetById(int id) + { + var inquiry = await _inquiryRepository.GetByIdAsync(id); + if (inquiry == null) + return NotFound(new { message = "Inquiry not found" }); + return Ok(inquiry); + } + + [HttpPut("{id}/status")] + [Authorize] + public async Task UpdateStatus(int id, [FromBody] UpdateStatusRequest request) + { + var inquiry = await _inquiryRepository.GetByIdAsync(id); + if (inquiry == null) + return NotFound(new { message = "Inquiry not found" }); + + await _inquiryRepository.UpdateStatusAsync(id, request.Status); + return Ok(new { message = "Status updated" }); + } +} + +public class SubmitInquiryRequest +{ + public string Name { get; set; } = string.Empty; + public string Phone { get; set; } = string.Empty; + public string? Email { get; set; } + public string ServiceType { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; +} + +public class UpdateStatusRequest +{ + public string Status { get; set; } = string.Empty; +} diff --git a/TaxBaik.Web/Program.cs b/TaxBaik.Web/Program.cs index 19a2382..841f4a4 100644 --- a/TaxBaik.Web/Program.cs +++ b/TaxBaik.Web/Program.cs @@ -13,6 +13,9 @@ using TaxBaik.Web.Services; var builder = WebApplication.CreateBuilder(args); +// Controllers (API) +builder.Services.AddControllers(); + // Razor Pages + Blazor Server 통합 builder.Services.AddRazorPages(); builder.Services.AddRazorComponents().AddInteractiveServerComponents(); @@ -107,7 +110,8 @@ if (!app.Environment.IsDevelopment()) app.UseHsts(); } -// Razor Pages + Blazor 매핑 +// API + Razor Pages + Blazor 매핑 +app.MapControllers(); app.MapRazorPages(); app.MapRazorComponents().AddInteractiveServerRenderMode();