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
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
# Story 12.1: Work Order PDF Generation Service

Status: review

## Story

As a **developer**,
I want **a backend service that generates work order PDFs**,
So that **users can get professional documents for their work orders**.

## Acceptance Criteria

1. **Given** the PDF generation service exists **When** I call `POST /api/v1/work-orders/{id}/pdf` **Then** a PDF is generated and returned with Content-Type `application/pdf`

2. **Given** the PDF is generated **When** I view the document **Then** it includes these sections:
- **Header:** "Work Order" title, work order ID (short format), generated date, status badge (Reported/Assigned/Completed)
- **Property Information:** property name, full address
- **Work Order Details:** description (full text), category (if set), tags (comma-separated), created date, created by (user name)
- **Assignment:** vendor name with contact info (phone, email) and trade tags OR "Self (DIY)"
- **Notes Section:** all notes chronologically, each with content + author + timestamp. If none: "No notes recorded"
- **Linked Expenses Section:** table with Date, Description, Category, Amount columns + total row. If none: "No expenses linked"
- **Footer:** "Generated by Property Manager" + page numbers (if multi-page)

3. **Given** a work order has photos **When** the PDF is generated **Then** photos are NOT included in the PDF and a note indicates "X photos attached - view online"

4. **Given** a work order doesn't exist or belongs to another account **When** I try to generate PDF **Then** I receive 404 Not Found

5. **Given** PDF generation is called **When** the process runs **Then** it completes within 10 seconds (NFR7)

## Tasks / Subtasks

- [x] Task 1: Create `WorkOrderPdfReportDto` and related DTOs (AC: #2)
- [x] 1.1 Create `WorkOrderPdfReportDto` record in `Application/WorkOrders/GenerateWorkOrderPdf.cs`
- [x] 1.2 Include nested DTOs for vendor contact, notes, and expenses already defined in codebase
- [x] Task 2: Create `IWorkOrderPdfGenerator` interface (AC: #1)
- [x] 2.1 Add interface to `Application/Common/Interfaces/IWorkOrderPdfGenerator.cs`
- [x] 2.2 Single method: `byte[] Generate(WorkOrderPdfReportDto report)`
- [x] Task 3: Create `GenerateWorkOrderPdf` query + handler (AC: #1, #2, #3, #4)
- [x] 3.1 Create MediatR query `GenerateWorkOrderPdfQuery(Guid WorkOrderId)` returning `WorkOrderPdfResult`
- [x] 3.2 Handler loads WorkOrder with `.Include(Property, Vendor, Vendor.TradeTagAssignments.TradeTag, Category, TagAssignments.Tag, Photos)`
- [x] 3.3 Handler loads Notes via `_dbContext.Notes.Where(EntityType == "WorkOrder" && EntityId == workOrderId)`
- [x] 3.4 Handler loads linked Expenses via `_dbContext.Expenses.Where(WorkOrderId == id)`
- [x] 3.5 Handler resolves user display names for note authors and work order creator
- [x] 3.6 Handler maps to `WorkOrderPdfReportDto` and calls `_pdfGenerator.Generate()`
- [x] 3.7 Validator: WorkOrderId must not be empty
- [x] Task 4: Create `WorkOrderPdfGenerator` QuestPDF implementation (AC: #2, #3)
- [x] 4.1 Create `Infrastructure/Reports/WorkOrderPdfGenerator.cs` implementing `IWorkOrderPdfGenerator`
- [x] 4.2 Compose Header section (title, ID, date, status)
- [x] 4.3 Compose Property Information section
- [x] 4.4 Compose Work Order Details section (description, category, tags, created info)
- [x] 4.5 Compose Assignment section (vendor + contact OR DIY)
- [x] 4.6 Compose Notes section (chronological list or "No notes recorded")
- [x] 4.7 Compose Linked Expenses section (table with total or "No expenses linked")
- [x] 4.8 Compose photo count note if photos > 0
- [x] 4.9 Compose Footer (branding + page numbers)
- [x] Task 5: Add endpoint to `WorkOrdersController` (AC: #1, #4)
- [x] 5.1 Add `POST /api/v1/work-orders/{id}/pdf` endpoint
- [x] 5.2 Return `File(pdfBytes, "application/pdf", filename)` with filename format `WorkOrder-{PropertyName}-{Date}-{ShortId}.pdf`
- [x] 5.3 Add `[Produces("application/pdf")]` attribute
- [x] Task 6: Register DI service (AC: #1)
- [x] 6.1 Register `IWorkOrderPdfGenerator` -> `WorkOrderPdfGenerator` in `Program.cs`
- [x] Task 7: Unit tests (AC: #1-#5)
- [x] 7.1 Test handler: valid work order returns PDF bytes
- [x] 7.2 Test handler: non-existent work order throws NotFoundException
- [x] 7.3 Test handler: work order with no vendor (DIY) generates successfully
- [x] 7.4 Test handler: work order with no notes/expenses generates successfully
- [x] 7.5 Test handler: work order with photos includes photo count note
- [x] 7.6 Test PDF generator: output is valid (non-empty byte array)
- [ ] Task 8: Postman collection + smoke test
- [ ] 8.1 Add `POST /api/v1/work-orders/{id}/pdf` to Postman collection
- [ ] 8.2 Verify PDF opens in viewer and contains all expected sections

## Dev Notes

### Architecture Pattern: Follow Existing ScheduleE PDF Pattern

This story follows the exact same architecture as the existing Schedule E report generation. Do NOT invent new patterns.

**Existing pattern to replicate:**
- Interface: `Application/Common/Interfaces/IScheduleEPdfGenerator.cs` -> replicate as `IWorkOrderPdfGenerator.cs`
- Implementation: `Infrastructure/Reports/ScheduleEPdfGenerator.cs` -> replicate as `WorkOrderPdfGenerator.cs`
- QuestPDF is already installed (`QuestPDF 2025.12.3`) and licensed (`QuestPDF.Settings.License = LicenseType.Community` in Program.cs)

**Key difference from ScheduleE pattern:** This story does NOT persist the generated report to S3 or create a `GeneratedReport` record. It returns the PDF bytes directly to the client. Stories 12-2 and 12-3 handle download and preview UX on the frontend.

### Data Loading Strategy

The handler must load all related data in a single efficient query chain:

```
WorkOrder
-> .Include(w => w.Property)
-> .Include(w => w.Vendor)
-> .ThenInclude(v => v.TradeTagAssignments)
-> .ThenInclude(a => a.TradeTag)
-> .Include(w => w.Category)
-> .Include(w => w.TagAssignments)
-> .ThenInclude(a => a.Tag)
-> .Include(w => w.Photos) // for count only
```

Notes loaded separately (polymorphic table):
```csharp
var notes = await _dbContext.Notes
.Where(n => n.EntityType == "WorkOrder" && n.EntityId == workOrderId && n.DeletedAt == null)
.OrderBy(n => n.CreatedAt)
.ToListAsync(ct);
```

Expenses loaded separately:
```csharp
var expenses = await _dbContext.Expenses
.Where(e => e.WorkOrderId == workOrderId && e.DeletedAt == null)
.Include(e => e.Category)
.OrderByDescending(e => e.Date)
.ToListAsync(ct);
```

User display names resolved via `IIdentityService.GetUserDisplayNamesAsync()` (same pattern as `GetNotesQueryHandler`).

### Vendor Contact Info

Person entity (`Person.cs`) has:
- `Phones: List<PhoneNumber>` where `PhoneNumber(string Number, string? Label)`
- `Emails: List<string>`
- `FullName` computed property

Vendor extends Person, adds:
- `TradeTagAssignments` -> junction to `VendorTradeTag.Name`

For PDF: Display first phone + first email (if any). List trade tags comma-separated.

### QuestPDF Implementation Guidelines

Follow `ScheduleEPdfGenerator.cs` patterns exactly:
- `Document.Create(container => { container.Page(page => {...}); })`
- `page.Size(PageSizes.Letter)`, `page.Margin(50)`, `page.DefaultTextStyle(x => x.FontSize(10))`
- Color scheme: `Colors.Green.Darken2` for headers (matches existing brand)
- Use `column.Item().Row(row => {...})` for label-value pairs
- Use `column.Item().PaddingVertical(N).LineHorizontal(0.5f)` for section dividers
- Use `column.Item().Table(...)` for expenses table
- Currency formatting: `.ToString("$#,##0.00")`

### Status Badge Colors (in PDF text)

- Reported = Orange/Yellow
- Assigned = Blue
- Completed = Green

### File Naming Convention

Filename format: `WorkOrder-{PropertyNameSanitized}-{YYYY-MM-DD}-{ShortId}.pdf`
- Sanitize property name: remove special characters, replace spaces with hyphens
- ShortId: first 8 chars of the GUID
- Example: `WorkOrder-OakStreetDuplex-2026-02-06-abc12345.pdf`

### Project Structure Notes

**New files to create:**
```
backend/src/PropertyManager.Application/
Common/Interfaces/IWorkOrderPdfGenerator.cs
WorkOrders/GenerateWorkOrderPdf.cs (Query + Handler + DTO + Validator)

backend/src/PropertyManager.Infrastructure/
Reports/WorkOrderPdfGenerator.cs

backend/tests/PropertyManager.Application.Tests/
WorkOrders/GenerateWorkOrderPdfHandlerTests.cs
```

**Files to modify:**
```
backend/src/PropertyManager.Api/Controllers/WorkOrdersController.cs (add PDF endpoint)
backend/src/PropertyManager.Infrastructure/DependencyInjection.cs (register service)
OR backend/src/PropertyManager.Api/Program.cs (register service - check which pattern is used)
```

### References

- [Source: epics-work-orders-vendors.md#Epic 5 Story 5.1] - Story requirements and acceptance criteria
- [Source: architecture.md#Phase 2] - Work order entity, API contracts, FR48-FR49
- [Source: prd.md#FR48-FR51] - Document generation requirements
- [Source: Infrastructure/Reports/ScheduleEPdfGenerator.cs] - QuestPDF implementation pattern
- [Source: Application/Common/Interfaces/IScheduleEPdfGenerator.cs] - Interface pattern
- [Source: Api/Controllers/ReportsController.cs] - PDF return pattern from controller
- [Source: Application/Notes/GetNotes.cs] - Notes query pattern with user name resolution
- [Source: Application/WorkOrders/GetWorkOrderExpenses.cs] - Expenses query pattern
- [Source: Domain/Entities/Person.cs] - PhoneNumber value object, FullName property
- [Source: Domain/Entities/Vendor.cs] - TradeTagAssignments navigation
- [Source: Domain/Entities/Note.cs] - Polymorphic note entity

## Dev Agent Record

### Agent Model Used
Claude Opus 4.6

### Debug Log References
- Build: 0 errors, 0 warnings (in project code)
- Tests: 913 passed (full Application.Tests suite), 9 new tests for this story

### Completion Notes List
- Tasks 1-7 complete. Task 8 (Postman + smoke test) is manual verification.
- Followed existing ScheduleE PDF pattern exactly as specified in Dev Notes.
- No persistence to S3 or GeneratedReport table - returns PDF bytes directly per story spec.
- Added 3 bonus tests beyond story spec: vendor contact mapping, property address mapping, filename sanitization.
- DI registered in Program.cs (no DependencyInjection.cs exists in this project).

### File List
**New files:**
- `backend/src/PropertyManager.Application/Common/Interfaces/IWorkOrderPdfGenerator.cs`
- `backend/src/PropertyManager.Application/WorkOrders/GenerateWorkOrderPdf.cs`
- `backend/src/PropertyManager.Infrastructure/Reports/WorkOrderPdfGenerator.cs`
- `backend/tests/PropertyManager.Application.Tests/WorkOrders/GenerateWorkOrderPdfHandlerTests.cs`

**Modified files:**
- `backend/src/PropertyManager.Api/Controllers/WorkOrdersController.cs` (added PDF endpoint)
- `backend/src/PropertyManager.Api/Program.cs` (registered IWorkOrderPdfGenerator DI)
4 changes: 2 additions & 2 deletions _bmad-output/implementation-artifacts/sprint-status.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,8 @@ development_status:

# Epic 12: Work Order Output
# User Outcome: "I can generate a professional work order PDF to share with vendors"
epic-12: backlog
12-1-work-order-pdf-generation-service: backlog
epic-12: in-progress
12-1-work-order-pdf-generation-service: review
12-2-download-work-order-pdf: backlog
12-3-preview-work-order-pdf: backlog
epic-12-retrospective: optional
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public class WorkOrdersController : ControllerBase
private readonly IValidator<CreateWorkOrderCommand> _createValidator;
private readonly IValidator<UpdateWorkOrderCommand> _updateValidator;
private readonly IValidator<DeleteWorkOrderCommand> _deleteValidator;
private readonly IValidator<GenerateWorkOrderPdfQuery> _generatePdfValidator;
private readonly ILogger<WorkOrdersController> _logger;

public WorkOrdersController(
Expand All @@ -29,13 +30,15 @@ public WorkOrdersController(
IValidator<CreateWorkOrderCommand> createValidator,
IValidator<UpdateWorkOrderCommand> updateValidator,
IValidator<DeleteWorkOrderCommand> deleteValidator,
IValidator<GenerateWorkOrderPdfQuery> generatePdfValidator,
ILogger<WorkOrdersController> logger)
{
_mediator = mediator;
_getAllValidator = getAllValidator;
_createValidator = createValidator;
_updateValidator = updateValidator;
_deleteValidator = deleteValidator;
_generatePdfValidator = generatePdfValidator;
_logger = logger;
}

Expand Down Expand Up @@ -245,6 +248,39 @@ public async Task<IActionResult> GetWorkOrderExpenses(Guid id, CancellationToken
return Ok(result);
}

/// <summary>
/// Generate a PDF document for a specific work order (AC #1, #4).
/// </summary>
/// <param name="id">Work order GUID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>PDF document binary</returns>
/// <response code="200">Returns the PDF document</response>
/// <response code="401">If user is not authenticated</response>
/// <response code="404">If work order not found</response>
[HttpPost("{id:guid}/pdf")]
[Produces("application/pdf")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GenerateWorkOrderPdf(Guid id, CancellationToken cancellationToken)
{
var query = new GenerateWorkOrderPdfQuery(id);

var validationResult = await _generatePdfValidator.ValidateAsync(query, cancellationToken);
if (!validationResult.IsValid)
{
return ValidationProblem(new ValidationProblemDetails(
validationResult.Errors.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray())));
}

var result = await _mediator.Send(query, cancellationToken);

_logger.LogInformation("Generated PDF for work order {WorkOrderId}", id);

return File(result.PdfBytes, "application/pdf", result.FileName);
}

/// <summary>
/// Get work orders for a specific property (Story 9-11 AC #1, #5).
/// Used on property detail page to show maintenance history.
Expand Down
3 changes: 2 additions & 1 deletion backend/src/PropertyManager.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,9 @@
// Register thumbnail service (always available - no external dependencies)
builder.Services.AddScoped<IThumbnailService, ImageSharpThumbnailService>();

// Register PDF report generator (AC-6.1.4)
// Register PDF report generators (AC-6.1.4, AC-12.1)
builder.Services.AddScoped<IScheduleEPdfGenerator, ScheduleEPdfGenerator>();
builder.Services.AddScoped<IWorkOrderPdfGenerator, WorkOrderPdfGenerator>();

// Register report bundle service for ZIP creation (AC-6.2.4, AC-6.2.5)
builder.Services.AddScoped<IReportBundleService, ReportBundleService>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using PropertyManager.Application.WorkOrders;

namespace PropertyManager.Application.Common.Interfaces;

/// <summary>
/// Interface for generating Work Order PDF reports.
/// Implementation in Infrastructure layer using QuestPDF.
/// </summary>
public interface IWorkOrderPdfGenerator
{
/// <summary>
/// Generates a Work Order PDF report.
/// </summary>
/// <param name="report">The report data containing work order details.</param>
/// <returns>PDF document as byte array.</returns>
byte[] Generate(WorkOrderPdfReportDto report);
}
Loading
Loading