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
103 changes: 103 additions & 0 deletions backend/src/Taskdeck.Api/Controllers/NoteImportController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Taskdeck.Api.Contracts;
using Taskdeck.Api.Extensions;
using Taskdeck.Api.RateLimiting;
using Taskdeck.Application.DTOs;
using Taskdeck.Application.Interfaces;
using Taskdeck.Application.Services;

namespace Taskdeck.Api.Controllers;

/// <summary>
/// Note-style import endpoints for markdown files and web clips.
/// All imported content routes through the standard capture pipeline —
/// no direct board mutations occur here.
/// </summary>
[ApiController]
[Authorize]
[Route("api/import/notes")]
[Produces("application/json")]
public class NoteImportController : AuthenticatedControllerBase
{
private readonly INoteImportService _noteImportService;

public NoteImportController(
INoteImportService noteImportService,
IUserContext userContext)
: base(userContext)
{
_noteImportService = noteImportService;
}

/// <summary>
/// Import a markdown file. The content is parsed into sections and
/// each section becomes a capture item in the standard pipeline.
/// </summary>
/// <param name="dto">Markdown import request with filename and content.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Import result with created capture item IDs.</returns>
/// <response code="200">Markdown imported successfully — capture items created.</response>
/// <response code="400">Validation error (empty content, oversized file, etc.).</response>
/// <response code="401">Authentication required.</response>
/// <response code="429">Rate limit exceeded.</response>
[HttpPost("markdown")]
[EnableRateLimiting(RateLimitingPolicyNames.NoteImportPerUser)]
[ProducesResponseType(typeof(NoteImportResultDto), StatusCodes.Status200OK)]
Comment on lines +45 to +47
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ImportMarkdown can enqueue up to 50 capture items in a single request, but it uses the same fixed-window rate limit policy as single-item capture writes. This effectively multiplies the allowed write throughput per permit. Consider a dedicated policy for note imports and/or enforcing a stricter per-request max section count based on the configured capture write rate.

Copilot uses AI. Check for mistakes.
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)]
public async Task<IActionResult> ImportMarkdown(
[FromBody] MarkdownImportRequestDto? dto,
CancellationToken cancellationToken)
{
if (!TryGetCurrentUserId(out var userId, out var errorResult))
return errorResult!;

if (dto == null)
{
return BadRequest(new ApiErrorResponse(
"VALIDATION_ERROR",
"Request body is required"));
}

var result = await _noteImportService.ImportMarkdownAsync(userId, dto, cancellationToken);
return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult();
}

/// <summary>
/// Import a web clip (URL + content snippet). Creates a single capture
/// item with the URL preserved as source provenance.
/// </summary>
/// <param name="dto">Web clip import request with URL, content, and optional title.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Import result with created capture item ID.</returns>
/// <response code="200">Web clip imported successfully — capture item created.</response>
/// <response code="400">Validation error (invalid URL, empty content, etc.).</response>
/// <response code="401">Authentication required.</response>
/// <response code="429">Rate limit exceeded.</response>
[HttpPost("webclip")]
[EnableRateLimiting(RateLimitingPolicyNames.NoteImportPerUser)]
[ProducesResponseType(typeof(NoteImportResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)]
public async Task<IActionResult> ImportWebClip(
[FromBody] WebClipImportRequestDto? dto,
CancellationToken cancellationToken)
{
if (!TryGetCurrentUserId(out var userId, out var errorResult))
return errorResult!;

if (dto == null)
{
return BadRequest(new ApiErrorResponse(
"VALIDATION_ERROR",
"Request body is required"));
}

var result = await _noteImportService.ImportWebClipAsync(userId, dto, cancellationToken);
return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection
services.AddScoped<IExternalImportAdapter, CsvExternalImportAdapter>();
services.AddScoped<LlmQueueService>();
services.AddScoped<ICaptureService, CaptureService>();
services.AddScoped<INoteImportService, NoteImportService>();
services.AddScoped<ICaptureTriageService, CaptureTriageService>();
services.AddScoped<HistoryService>();
services.AddScoped<IHistoryService>(sp => sp.GetRequiredService<HistoryService>());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ await context.HttpContext.Response.WriteAsJsonAsync(
return BuildFixedWindowPartition(partitionKey, settings.CaptureWritePerUser);
});

options.AddPolicy(RateLimitingPolicyNames.NoteImportPerUser, httpContext =>
{
var partitionKey = $"note-import-user:{ResolveUserOrClientIdentifier(httpContext)}";
return BuildFixedWindowPartition(partitionKey, settings.NoteImportPerUser);
});

options.AddPolicy(RateLimitingPolicyNames.McpPerApiKey, httpContext =>
{
// Partition by API key user or fall back to IP for unauthenticated attempts.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ public static class RateLimitingPolicyNames
public const string AuthPerIp = "AuthPerIp";
public const string HotPathPerUser = "HotPathPerUser";
public const string CaptureWritePerUser = "CaptureWritePerUser";
public const string NoteImportPerUser = "NoteImportPerUser";
public const string McpPerApiKey = "McpPerApiKey";
}
49 changes: 49 additions & 0 deletions backend/src/Taskdeck.Application/DTOs/NoteImportDtos.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
namespace Taskdeck.Application.DTOs;

/// <summary>
/// Request to import a markdown file as one or more capture items.
/// The content is parsed, split into logical sections, and routed
/// through the standard capture pipeline.
/// </summary>
public sealed record MarkdownImportRequestDto(
string FileName,
string Content,
Guid? BoardId = null);

/// <summary>
/// Request to import a web clip (URL + content snippet) as a capture item.
/// The content is routed through the standard capture pipeline with
/// source provenance preserved.
/// </summary>
public sealed record WebClipImportRequestDto(
string Url,
string Content,
string? Title = null,
Guid? BoardId = null);

/// <summary>
/// Result of a note-style import operation.
/// </summary>
public sealed record NoteImportResultDto(
int ItemsCreated,
IReadOnlyList<NoteImportItemResultDto> Items,
IReadOnlyList<string>? Warnings = null,
IReadOnlyList<NoteImportItemErrorDto>? Errors = null);

/// <summary>
/// Error detail for a section that failed to import.
/// </summary>
public sealed record NoteImportItemErrorDto(
int SectionIndex,
string? Heading,
string ErrorCode,
string ErrorMessage);

/// <summary>
/// Result for a single capture item created from a note import.
/// </summary>
public sealed record NoteImportItemResultDto(
Guid CaptureItemId,
string TextExcerpt,
string SourceType,
string? SourceRef);
28 changes: 28 additions & 0 deletions backend/src/Taskdeck.Application/Services/INoteImportService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using Taskdeck.Application.DTOs;
using Taskdeck.Domain.Common;

namespace Taskdeck.Application.Services;

/// <summary>
/// Handles note-style import (markdown files, web clips) by routing
/// imported content through the standard capture pipeline.
/// No direct board mutations — all content enters the capture → triage → proposal flow.
/// </summary>
public interface INoteImportService
{
/// <summary>
/// Parses a markdown file and creates capture items for each logical section.
/// </summary>
Task<Result<NoteImportResultDto>> ImportMarkdownAsync(
Guid userId,
MarkdownImportRequestDto request,
CancellationToken cancellationToken = default);

/// <summary>
/// Creates a capture item from a web clip (URL + content snippet).
/// </summary>
Task<Result<NoteImportResultDto>> ImportWebClipAsync(
Guid userId,
WebClipImportRequestDto request,
CancellationToken cancellationToken = default);
}
Loading
Loading