diff --git a/backend/src/PropertyManager.Api/Controllers/MaintenanceRequestsController.cs b/backend/src/PropertyManager.Api/Controllers/MaintenanceRequestsController.cs index d5657e83..7de90965 100644 --- a/backend/src/PropertyManager.Api/Controllers/MaintenanceRequestsController.cs +++ b/backend/src/PropertyManager.Api/Controllers/MaintenanceRequestsController.cs @@ -101,6 +101,29 @@ public async Task GetMaintenanceRequests( return Ok(response); } + /// + /// Get the current tenant's assigned property info (Story 20.5, AC #2). + /// Returns read-only property data (name, address) — no financial data. + /// + /// Cancellation token + /// Tenant property info + /// Returns the tenant's property info + /// If user is not authenticated + /// If property not found + [HttpGet("tenant-property")] + [ProducesResponseType(typeof(TenantPropertyDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetTenantProperty(CancellationToken cancellationToken) + { + var query = new GetTenantPropertyQuery(); + var result = await _mediator.Send(query, cancellationToken); + + _logger.LogInformation("Retrieved tenant property {PropertyId}", result.Id); + + return Ok(result); + } + /// /// Get a single maintenance request by ID (AC #7). /// diff --git a/backend/src/PropertyManager.Application/MaintenanceRequests/GetTenantProperty.cs b/backend/src/PropertyManager.Application/MaintenanceRequests/GetTenantProperty.cs new file mode 100644 index 00000000..495481e0 --- /dev/null +++ b/backend/src/PropertyManager.Application/MaintenanceRequests/GetTenantProperty.cs @@ -0,0 +1,74 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using PropertyManager.Application.Common.Interfaces; +using PropertyManager.Domain.Exceptions; + +namespace PropertyManager.Application.MaintenanceRequests; + +/// +/// Query to get the current tenant's assigned property info (Story 20.5, AC #2). +/// Returns read-only property data (no financial info). +/// +public record GetTenantPropertyQuery() : IRequest; + +/// +/// DTO for tenant property display — read-only, no financial data. +/// +public record TenantPropertyDto( + Guid Id, + string Name, + string Street, + string City, + string State, + string ZipCode +); + +/// +/// Handler for GetTenantPropertyQuery. +/// Returns property info for the current tenant user using PropertyId from JWT. +/// +public class GetTenantPropertyQueryHandler : IRequestHandler +{ + private readonly IAppDbContext _dbContext; + private readonly ICurrentUser _currentUser; + + public GetTenantPropertyQueryHandler(IAppDbContext dbContext, ICurrentUser currentUser) + { + _dbContext = dbContext; + _currentUser = currentUser; + } + + public async Task Handle(GetTenantPropertyQuery request, CancellationToken cancellationToken) + { + if (_currentUser.Role != "Tenant") + { + throw new BusinessRuleException("This endpoint is only accessible to Tenant users."); + } + + if (!_currentUser.PropertyId.HasValue) + { + throw new BusinessRuleException("Tenant user must have an assigned property."); + } + + var property = await _dbContext.Properties + .Where(p => p.Id == _currentUser.PropertyId.Value + && p.AccountId == _currentUser.AccountId + && p.DeletedAt == null) + .AsNoTracking() + .FirstOrDefaultAsync(cancellationToken); + + if (property is null) + { + throw new NotFoundException(nameof(Domain.Entities.Property), _currentUser.PropertyId.Value); + } + + return new TenantPropertyDto( + property.Id, + property.Name, + property.Street, + property.City, + property.State, + property.ZipCode + ); + } +} diff --git a/backend/tests/PropertyManager.Application.Tests/MaintenanceRequests/GetTenantPropertyHandlerTests.cs b/backend/tests/PropertyManager.Application.Tests/MaintenanceRequests/GetTenantPropertyHandlerTests.cs new file mode 100644 index 00000000..52ea7ccf --- /dev/null +++ b/backend/tests/PropertyManager.Application.Tests/MaintenanceRequests/GetTenantPropertyHandlerTests.cs @@ -0,0 +1,132 @@ +using FluentAssertions; +using MockQueryable.Moq; +using Moq; +using PropertyManager.Application.Common.Interfaces; +using PropertyManager.Application.MaintenanceRequests; +using PropertyManager.Domain.Entities; +using PropertyManager.Domain.Exceptions; + +namespace PropertyManager.Application.Tests.MaintenanceRequests; + +/// +/// Unit tests for GetTenantPropertyQueryHandler (Story 20.5, AC #2). +/// +public class GetTenantPropertyHandlerTests +{ + private readonly Mock _dbContextMock; + private readonly Mock _currentUserMock; + private readonly Guid _testAccountId = Guid.NewGuid(); + private readonly Guid _testUserId = Guid.NewGuid(); + private readonly Guid _testPropertyId = Guid.NewGuid(); + + public GetTenantPropertyHandlerTests() + { + _dbContextMock = new Mock(); + _currentUserMock = new Mock(); + + _currentUserMock.Setup(x => x.AccountId).Returns(_testAccountId); + _currentUserMock.Setup(x => x.UserId).Returns(_testUserId); + _currentUserMock.Setup(x => x.IsAuthenticated).Returns(true); + _currentUserMock.Setup(x => x.Role).Returns("Tenant"); + } + + private GetTenantPropertyQueryHandler CreateHandler() + { + return new GetTenantPropertyQueryHandler(_dbContextMock.Object, _currentUserMock.Object); + } + + private void SetupDbSet(List properties) + { + var filtered = properties + .Where(p => p.AccountId == _testAccountId && p.DeletedAt == null) + .ToList(); + var mockDbSet = filtered.BuildMockDbSet(); + _dbContextMock.Setup(x => x.Properties).Returns(mockDbSet.Object); + } + + [Fact] + public async Task Handle_ValidPropertyId_ReturnsPropertyInfo() + { + // Arrange + _currentUserMock.Setup(x => x.PropertyId).Returns(_testPropertyId); + + var property = new Property + { + Id = _testPropertyId, + AccountId = _testAccountId, + Name = "Sunset Apartments", + Street = "123 Main St", + City = "Austin", + State = "TX", + ZipCode = "78701" + }; + + SetupDbSet(new List { property }); + + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(new GetTenantPropertyQuery(), CancellationToken.None); + + // Assert + result.Id.Should().Be(_testPropertyId); + result.Name.Should().Be("Sunset Apartments"); + result.Street.Should().Be("123 Main St"); + result.City.Should().Be("Austin"); + result.State.Should().Be("TX"); + result.ZipCode.Should().Be("78701"); + } + + [Fact] + public async Task Handle_PropertyNotFound_ThrowsNotFoundException() + { + // Arrange + _currentUserMock.Setup(x => x.PropertyId).Returns(Guid.NewGuid()); + + SetupDbSet(new List()); + + var handler = CreateHandler(); + + // Act + var act = () => handler.Handle(new GetTenantPropertyQuery(), CancellationToken.None); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Handle_NullPropertyId_ThrowsBusinessRuleException() + { + // Arrange + _currentUserMock.Setup(x => x.PropertyId).Returns((Guid?)null); + + SetupDbSet(new List()); + + var handler = CreateHandler(); + + // Act + var act = () => handler.Handle(new GetTenantPropertyQuery(), CancellationToken.None); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Handle_NonTenantRole_ThrowsBusinessRuleException() + { + // Arrange + _currentUserMock.Setup(x => x.Role).Returns("Owner"); + _currentUserMock.Setup(x => x.PropertyId).Returns(_testPropertyId); + + SetupDbSet(new List()); + + var handler = CreateHandler(); + + // Act + var act = () => handler.Handle(new GetTenantPropertyQuery(), CancellationToken.None); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*Tenant*"); + } +} diff --git a/docs/project/sprint-status.yaml b/docs/project/sprint-status.yaml index 3340a4ef..bc27f3f6 100644 --- a/docs/project/sprint-status.yaml +++ b/docs/project/sprint-status.yaml @@ -327,3 +327,4 @@ development_status: 20-2-tenant-invitation-flow: done # FR-TP1, FR-TP4, FR-TP5, NFR-TP4 - Size 5 20-3-maintenance-request-entity-api: done # FR-TP18, FR-TP19, FR-TP20, NFR-TP6 - Size 5 20-4-maintenance-request-photos: done # FR-TP21 - Size 5 + 20-5-tenant-dashboard-role-routing: done # FR-TP6, FR-TP8, FR-TP9, FR-TP11, NFR-TP5, NFR-TP7 - Size 5 diff --git a/docs/project/stories/epic-20/20-5-tenant-dashboard-role-routing.md b/docs/project/stories/epic-20/20-5-tenant-dashboard-role-routing.md new file mode 100644 index 00000000..f0e7c339 --- /dev/null +++ b/docs/project/stories/epic-20/20-5-tenant-dashboard-role-routing.md @@ -0,0 +1,420 @@ +# Story 20.5: Tenant Dashboard & Role-Based Routing + +Status: done + +## Story + +As a tenant, +I want to log in and see a dashboard showing my property and maintenance requests, +so that I can manage my requests without seeing landlord workflows. + +## Acceptance Criteria + +1. **Given** a user with the Tenant role, + **When** they log in, + **Then** they are routed to the tenant dashboard (not the landlord dashboard) + +2. **Given** a tenant on their dashboard, + **When** the page loads, + **Then** they see their property information (address, name) in read-only format + +3. **Given** a tenant on their dashboard, + **When** the page loads, + **Then** they see a list of all maintenance requests for their property (submitted by any tenant on that property) + +4. **Given** a maintenance request in the list, + **When** the tenant views it, + **Then** they see the description, status (Submitted / In Progress / Resolved / Dismissed), and dismissal reason if dismissed + +5. **Given** a tenant, + **When** they navigate the app, + **Then** the navigation shows only tenant-relevant items (dashboard, submit request) — no properties list, expenses, income, reports, vendors, or work orders + +6. **Given** a tenant, + **When** they manually navigate to a landlord route (e.g., /expenses), + **Then** the Angular route guard redirects them to the tenant dashboard + +7. **Given** a landlord user, + **When** they log in, + **Then** they are routed to the existing landlord dashboard as before (no regression) + +8. **Given** the tenant dashboard, + **When** viewed on a mobile device, + **Then** the layout is mobile-first and usable on small screens + +## Tasks / Subtasks + +- [x] Task 1: Create tenant property API endpoint (AC: #2) + - [x] 1.1 Create `GetTenantProperty.cs` in `backend/src/PropertyManager.Application/MaintenanceRequests/` — a new query that returns property info for the current tenant user using `_currentUser.PropertyId` + - [x] 1.2 Define query: `public record GetTenantPropertyQuery() : IRequest;` + - [x] 1.3 Define DTO: `public record TenantPropertyDto(Guid Id, string Name, string Street, string City, string State, string ZipCode);` — read-only, no financial data + - [x] 1.4 Handler: query `_dbContext.Properties` filtered by `Id == _currentUser.PropertyId && AccountId == _currentUser.AccountId && DeletedAt == null`. Throw `NotFoundException` if not found. Throw `BusinessRuleException` if `_currentUser.PropertyId` is null. + - [x] 1.5 Add GET `tenant-property` endpoint to `MaintenanceRequestsController` — no authorization policy beyond JWT (handler verifies role + PropertyId). Returns 200 with `TenantPropertyDto`. + +- [x] Task 2: Backend unit tests for GetTenantProperty (AC: #2) + - [x] 2.1 Test: Handle returns property info for tenant with valid PropertyId + - [x] 2.2 Test: Handle throws NotFoundException when property not found + - [x] 2.3 Test: Handle throws BusinessRuleException when CurrentUser.PropertyId is null + +- [x] Task 3: Create frontend maintenance request service (AC: #3, #4) + - [x] 3.1 Create `frontend/src/app/features/tenant-dashboard/services/tenant.service.ts` + - [x] 3.2 Inject `HttpClient`. Methods: `getTenantProperty()` returns `Observable`, `getMaintenanceRequests(page?, pageSize?)` returns `Observable`, `getMaintenanceRequestById(id)` returns `Observable` + - [x] 3.3 Define TypeScript interfaces: `TenantPropertyDto`, `MaintenanceRequestDto`, `MaintenanceRequestPhotoDto`, `PaginatedMaintenanceRequests` matching backend DTOs + - [x] 3.4 API URLs: `/api/v1/maintenance-requests/tenant-property`, `/api/v1/maintenance-requests`, `/api/v1/maintenance-requests/{id}` + +- [x] Task 4: Create tenant dashboard signal store (AC: #2, #3, #4) + - [x] 4.1 Create `frontend/src/app/features/tenant-dashboard/stores/tenant-dashboard.store.ts` + - [x] 4.2 Use `signalStore()` with `withState()`, `withComputed()`, `withMethods()` + - [x] 4.3 State: `{ property: TenantPropertyDto | null, requests: MaintenanceRequestDto[], isLoading: boolean, error: string | null, totalCount: number, page: number, pageSize: number }` + - [x] 4.4 Methods: `loadProperty()` — calls `TenantService.getTenantProperty()`, patches state. `loadRequests(page?, pageSize?)` — calls `TenantService.getMaintenanceRequests()`, patches state. Both use `rxMethod(pipe(...))` pattern with `switchMap`, `tap`, `catchError`. + - [x] 4.5 Computed: `totalPages`, `isEmpty`, `propertyAddress` (formatted), `isPropertyLoaded` + +- [x] Task 5: Create tenant dashboard component (AC: #2, #3, #4, #8) + - [x] 5.1 Create `frontend/src/app/features/tenant-dashboard/tenant-dashboard.component.ts` — standalone component + - [x] 5.2 Template: property info card (name, full address) at top, maintenance request list below + - [x] 5.3 Request list: each item shows description (truncated), status badge (color-coded), date submitted. Clicking opens detail view (inline expand or separate section). + - [x] 5.4 Status badges: "Submitted" = neutral/gray, "In Progress" = blue/primary, "Resolved" = green/success, "Dismissed" = orange/warn with dismissal reason displayed + - [x] 5.5 Empty state: "No maintenance requests yet" with icon and message + - [x] 5.6 Loading state: use shared `LoadingSpinnerComponent` + - [x] 5.7 Error state: use shared `ErrorCardComponent` with retry + - [x] 5.8 Mobile-first SCSS: full-width cards, large text, stacked layout on small screens. Max-width 800px on desktop. + - [x] 5.9 Use Angular Material: `MatCardModule`, `MatIconModule`, `MatChipsModule` (for status badges), `MatListModule`, `MatButtonModule`, `MatPaginatorModule` + - [x] 5.10 Inject `TenantDashboardStore`, call `loadProperty()` and `loadRequests()` on init + +- [x] Task 6: Create request detail section within dashboard (AC: #4) + - [x] 6.1 Create `frontend/src/app/features/tenant-dashboard/components/request-detail/request-detail.component.ts` — standalone component + - [x] 6.2 Shows: full description, status with badge, submitted date, dismissal reason (if dismissed), photos (if any, with presigned URLs from backend) + - [x] 6.3 Input: maintenance request ID. Component fetches detail via `TenantService.getMaintenanceRequestById(id)`. + - [x] 6.4 Photos displayed as thumbnail grid (reuse existing photo viewer pattern if applicable, or simple img grid) + - [x] 6.5 "Back to list" button/link to return to dashboard list view + +- [x] Task 7: Update role-based routing — login redirect (AC: #1, #7) + - [x] 7.1 Modify `LoginComponent.getSafeReturnUrl()`: if `returnUrl` is not provided, check user role. If Tenant, return `/tenant`; otherwise return `/dashboard`. + - [x] 7.2 Modify `guestGuard`: when redirecting authenticated users, check role. If Tenant, redirect to `/tenant`; otherwise redirect to `/dashboard`. + - [x] 7.3 Verify: landlord login still routes to `/dashboard` (regression check) + +- [x] Task 8: Update role-based routing — route guards (AC: #6) + - [x] 8.1 Create `frontend/src/app/core/auth/tenant.guard.ts` — `tenantGuard: CanActivateFn` that allows only Tenant role users; redirects others to `/dashboard` + - [x] 8.2 Modify `ownerGuard`: when a Tenant user hits an owner-only route, redirect to `/tenant` instead of `/dashboard` + - [x] 8.3 Add route guard for the default shell redirect: when a Tenant user hits the empty path `''` inside the shell, redirect to `/tenant` instead of `/dashboard` + +- [x] Task 9: Update app.routes.ts with tenant routes (AC: #1, #6) + - [x] 9.1 Add tenant dashboard route inside shell children: `{ path: 'tenant', loadComponent: () => import('./features/tenant-dashboard/tenant-dashboard.component').then(m => m.TenantDashboardComponent), canActivate: [tenantGuard] }` + - [x] 9.2 Add tenant request detail route: `{ path: 'tenant/requests/:id', loadComponent: () => import('./features/tenant-dashboard/components/request-detail/request-detail.component').then(m => m.RequestDetailComponent), canActivate: [tenantGuard] }` + - [x] 9.3 Update the catch-all redirect: consider role-aware redirect (or keep `/dashboard` — the `DashboardComponent` or shell-level redirect will handle tenant routing) + - [x] 9.4 Update `PermissionService.canAccess()`: populate `tenantRoutes` array with `['/tenant']` + +- [x] Task 10: Update navigation for tenant role (AC: #5) + - [x] 10.1 Modify `SidebarNavComponent.navItems` computed: add Tenant case — return only `[{ label: 'Dashboard', route: '/tenant', icon: 'dashboard' }]` (Submit Request will be added in Story 20.6) + - [x] 10.2 Modify `BottomNavComponent.navItems` computed: add Tenant case — return only `[{ label: 'Dashboard', route: '/tenant', icon: 'dashboard' }]` + - [x] 10.3 Verify: sidebar and bottom nav show correct items for Owner (all), Contributor (subset), and Tenant (dashboard only) + +- [x] Task 11: Update shell component for tenant users (AC: #5) + - [x] 11.1 In `ShellComponent.ngOnInit()`: skip `receiptSignalR.initialize()` and `receiptStore.loadUnprocessedReceipts()` for Tenant users (tenants don't use receipts). Check `this.authService.currentUser()?.role === 'Tenant'`. + - [x] 11.2 In `ShellComponent` template: hide `MobileCaptureFabComponent` for Tenant users + - [x] 11.3 Verify: receipts FAB and SignalR init are still active for Owner/Contributor users + +- [x] Task 12: Backend unit tests for GetTenantProperty endpoint (AC: #2) + - [x] 12.1 (Covered in Task 2 — handler tests) + +- [x] Task 13: Frontend unit tests — TenantDashboardStore (AC: #2, #3, #4) + - [x] 13.1 Test: `loadProperty()` fetches and stores property data + - [x] 13.2 Test: `loadProperty()` error sets error state + - [x] 13.3 Test: `loadRequests()` fetches and stores maintenance requests + - [x] 13.4 Test: `loadRequests()` error sets error state + - [x] 13.5 Test: `totalPages` computed correctly + - [x] 13.6 Test: `propertyAddress` formats correctly + +- [x] Task 14: Frontend unit tests — TenantDashboardComponent (AC: #2, #3, #8) + - [x] 14.1 Test: component renders property info when loaded + - [x] 14.2 Test: component renders request list when loaded + - [x] 14.3 Test: component shows loading spinner during load + - [x] 14.4 Test: component shows empty state when no requests + - [x] 14.5 Test: component shows error card on error + +- [x] Task 15: Frontend unit tests — RequestDetailComponent (AC: #4) + - [x] 15.1 Test: renders full description and status + - [x] 15.2 Test: renders dismissal reason when status is Dismissed + - [x] 15.3 Test: hides dismissal reason when status is not Dismissed + +- [x] Task 16: Frontend unit tests — Route guards and navigation (AC: #1, #5, #6, #7) + - [x] 16.1 Test: `tenantGuard` allows Tenant role + - [x] 16.2 Test: `tenantGuard` redirects non-Tenant to `/dashboard` + - [x] 16.3 Test: `ownerGuard` redirects Tenant to `/tenant` + - [x] 16.4 Test: `guestGuard` redirects authenticated Tenant to `/tenant` + - [x] 16.5 Test: `SidebarNavComponent` shows only Dashboard for Tenant role + - [x] 16.6 Test: `BottomNavComponent` shows only Dashboard for Tenant role + - [x] 16.7 Test: `PermissionService.canAccess('/tenant')` returns true for Tenant role + - [x] 16.8 Test: `PermissionService.canAccess('/expenses')` returns false for Tenant role + +- [x] Task 17: Frontend unit tests — LoginComponent redirect (AC: #1, #7) + - [x] 17.1 Test: Tenant user redirected to `/tenant` after login (no returnUrl) + - [x] 17.2 Test: Owner user redirected to `/dashboard` after login (no returnUrl, regression check) + +- [x] Task 18: Frontend unit tests — ShellComponent tenant handling (AC: #5) + - [x] 18.1 Test: `ShellComponent` does NOT call `receiptSignalR.initialize()` for Tenant users + - [x] 18.2 Test: `ShellComponent` hides MobileCaptureFab for Tenant users + - [x] 18.3 Test: `ShellComponent` initializes receipts for Owner users (regression) + +- [x] Task 19: Verify all existing tests pass (AC: all) + - [x] 19.1 Run `dotnet test` — all backend tests pass + - [x] 19.2 Run `npm test` — all frontend tests pass + - [x] 19.3 Run `dotnet build` and `ng build` — both compile without errors + +## Dev Notes + +### Architecture: Full-Stack Frontend + Minimal Backend + +This is primarily a frontend story with a small backend addition. The major work is: +1. **Backend (small):** One new query handler (`GetTenantProperty`) + one new controller endpoint +2. **Frontend (large):** New feature module (`tenant-dashboard/`), route guards, navigation updates, shell modifications + +### Backend: New GetTenantProperty Endpoint + +The existing `GET /api/v1/properties/{id}` endpoint uses the `CanManageProperties` policy which maps to `Properties.Create` — tenants don't have this permission (they have only `Properties.ViewAssigned`). Rather than modifying the existing property endpoint's authorization (which could introduce security gaps), create a **new dedicated endpoint** for tenants. + +**Endpoint:** `GET /api/v1/maintenance-requests/tenant-property` +- Returns the current tenant's assigned property info (name, address only — no financial data) +- Uses `_currentUser.PropertyId` from JWT to identify the property +- No authorization policy beyond JWT — handler validates the user is a Tenant with a PropertyId +- Returns a lean `TenantPropertyDto` (no expenses, income, recent activity, photos) + +**Why on MaintenanceRequestsController?** The tenant property endpoint is semantically part of the tenant experience, and the MaintenanceRequestsController already handles both tenant and landlord requests without restrictive class-level policies. Adding a new controller for a single endpoint adds unnecessary complexity. + +### Frontend: Feature Structure + +``` +frontend/src/app/features/tenant-dashboard/ +├── tenant-dashboard.component.ts # Main dashboard page +├── tenant-dashboard.component.html # Template +├── tenant-dashboard.component.scss # Mobile-first styles +├── tenant-dashboard.component.spec.ts # Tests +├── components/ +│ └── request-detail/ +│ ├── request-detail.component.ts # Request detail view +│ ├── request-detail.component.html +│ ├── request-detail.component.scss +│ └── request-detail.component.spec.ts +├── services/ +│ └── tenant.service.ts # HTTP service for tenant API calls +│ └── tenant.service.spec.ts +└── stores/ + └── tenant-dashboard.store.ts # Signal store + └── tenant-dashboard.store.spec.ts +``` + +### Frontend: Routing Strategy + +**Route structure:** +- `/tenant` — Tenant dashboard (property info + request list) +- `/tenant/requests/:id` — Maintenance request detail + +**Guard strategy:** +- `tenantGuard` — allows only Tenant role, redirects others to `/dashboard` +- `ownerGuard` (modified) — redirects Tenant to `/tenant` instead of `/dashboard` +- `guestGuard` (modified) — redirects authenticated Tenant to `/tenant` +- `authGuard` — unchanged (shell-level, all authenticated users) + +**Login redirect:** +- `LoginComponent.getSafeReturnUrl()` — if no `returnUrl`, check role: Tenant goes to `/tenant`, others go to `/dashboard` + +### Frontend: Navigation Updates + +Both `SidebarNavComponent` and `BottomNavComponent` currently handle Owner and Contributor roles. Add a third case for Tenant: + +```typescript +// In navItems computed: +if (this.authService.currentUser()?.role === 'Tenant') { + return [ + { label: 'Dashboard', route: '/tenant', icon: 'dashboard' }, + ]; +} +``` + +The "Submit Request" nav item will be added in Story 20.6. + +### Frontend: Shell Component Modifications + +The `ShellComponent` currently initializes SignalR for receipts and loads unprocessed receipt counts. Tenants don't use receipts, so these should be skipped: + +```typescript +ngOnInit(): void { + if (this.authService.currentUser()?.role !== 'Tenant') { + this.receiptSignalR.initialize(); + this.receiptStore.loadUnprocessedReceipts(); + } +} +``` + +Also hide the `MobileCaptureFabComponent` in the template for Tenant users. + +### Frontend: Status Badge Design + +Use Angular Material chips (`MatChipsModule`) for status badges with these colors: +- **Submitted**: default/neutral chip +- **In Progress**: primary-colored chip (Upkeep Blue) +- **Resolved**: green-tinted chip (success) +- **Dismissed**: orange/warn chip, with dismissal reason displayed below + +### Frontend: Mobile-First Design + +The tenant dashboard is mobile-first (NFR-TP7). Key design decisions: +- Full-width cards on mobile, max-width 800px on desktop +- Large font sizes for readability on small screens +- Touch-friendly list items with adequate padding (min 48px height) +- Status badges visible at a glance in the request list +- Property info card at top is compact (just name + address) +- Stacked layout: property card → request list → pagination + +### Critical Patterns to Follow + +1. **Signal store pattern:** Use `signalStore()` with `{ providedIn: 'root' }`. Follow existing stores (e.g., `PropertyStore`, `ReceiptStore`) for `rxMethod` patterns. + +2. **Component pattern:** Standalone components with `inject()`, `input()` / `output()` signal-based API, `@if` / `@for` control flow. + +3. **Service pattern:** Injectable service with `HttpClient`, returns Observables. No generated NSwag client — manual HTTP calls (NSwag generation has had issues with .NET 10 per Story 20.2 notes). + +4. **Route guard pattern:** Functional `CanActivateFn` using `inject()`. Follow `ownerGuard` pattern. + +5. **SCSS pattern:** Use CSS custom properties (e.g., `var(--pm-text-primary)`, `var(--pm-text-secondary)`). Follow responsive breakpoints from existing components. + +6. **Validators called explicitly in controller** before `_mediator.Send()`. + +7. **Request/Response records at bottom of controller file.** + +8. **No try-catch in controllers** — global exception middleware handles domain exceptions. + +### API Endpoints Used by This Story + +| Method | URL | Description | Auth | +|--------|-----|-------------|------| +| GET | `/api/v1/maintenance-requests/tenant-property` | Get tenant's property info | JWT (any — handler validates) | +| GET | `/api/v1/maintenance-requests` | List requests (role-filtered) | JWT | +| GET | `/api/v1/maintenance-requests/{id}` | Request detail with photos | JWT | + +All three endpoints exist except `tenant-property` which is new. + +### Previous Story Intelligence + +From Story 20.1: +- `ICurrentUser.PropertyId` is available (from JWT claim) +- `PermissionService.isTenant` computed signal exists +- `PermissionService.canAccess()` has a placeholder for tenant routes (empty array) +- `User` interface has `propertyId: string | null` +- Owner guard redirects non-Owner to `/dashboard` — must be updated for Tenant +- Backend baseline: 1812 tests (after Story 20.4) +- Frontend baseline: 2703 tests (after Story 20.2, no frontend changes in 20.3/20.4) + +From Story 20.3: +- `MaintenanceRequestsController` exists at `api/v1/maintenance-requests` +- GET list and GET by ID endpoints are role-aware (handler filters by role) +- `MaintenanceRequestDto` has all fields including optional `Photos` list +- No authorization policy on GET endpoints — handler differentiates by role + +From Story 20.4: +- `MaintenanceRequestPhotoDto` has `ThumbnailUrl`, `ViewUrl`, `IsPrimary`, `DisplayOrder`, `OriginalFileName`, `FileSizeBytes`, `CreatedAt` +- Photos are included in `GetMaintenanceRequestById` response +- Photo entity type is `MaintenanceRequests` in `PhotoEntityType` enum + +From Story 20.2: +- NSwag generation may fail with .NET 10 — manual TypeScript interfaces are acceptable +- Property address format: `$"{property.Street}, {property.City}, {property.State} {property.ZipCode}"` + +### Testing Strategy + +- **Backend unit tests** (3 tests): GetTenantProperty handler (valid, not found, null PropertyId) +- **Frontend unit tests** (~25+ tests): + - TenantDashboardStore (6 tests: load property, load requests, errors, computeds) + - TenantDashboardComponent (5 tests: renders, loading, empty, error states) + - RequestDetailComponent (3 tests: description, status, dismissal reason) + - Route guards (4 tests: tenantGuard allow/deny, ownerGuard tenant redirect, guestGuard tenant redirect) + - Navigation components (2 tests: sidebar and bottom nav for Tenant role) + - PermissionService (2 tests: canAccess for tenant routes) + - LoginComponent (2 tests: tenant redirect, owner regression) + - ShellComponent (3 tests: skip receipts for tenant, hide FAB, owner regression) +- **No E2E tests** in this story — tenant E2E tests require a seeded tenant user which would need test infrastructure changes (Story 20.11 handles this with WebApplicationFactory integration tests) + +### References + +- Epic file: `docs/project/stories/epic-20/epic-20-tenant-portal.md` (Story 20.5) +- Previous stories: + - `docs/project/stories/epic-20/20-1-tenant-role-property-association.md` + - `docs/project/stories/epic-20/20-2-tenant-invitation-flow.md` + - `docs/project/stories/epic-20/20-3-maintenance-request-entity-api.md` + - `docs/project/stories/epic-20/20-4-maintenance-request-photos.md` +- PRD: `docs/project/prd-tenant-portal.md` (FR-TP6, FR-TP8, FR-TP9, FR-TP11, NFR-TP5, NFR-TP7) +- Architecture: `docs/project/architecture.md` +- Project Context: `docs/project/project-context.md` +- Reference implementations: + - Landlord dashboard: `frontend/src/app/features/dashboard/dashboard.component.ts` + - Sidebar nav: `frontend/src/app/core/components/sidebar-nav/sidebar-nav.component.ts` + - Bottom nav: `frontend/src/app/core/components/bottom-nav/bottom-nav.component.ts` + - Shell: `frontend/src/app/core/components/shell/shell.component.ts` + - Owner guard: `frontend/src/app/core/auth/owner.guard.ts` + - Auth guard: `frontend/src/app/core/auth/auth.guard.ts` + - Guest guard: `frontend/src/app/core/auth/auth.guard.ts` + - Permission service: `frontend/src/app/core/auth/permission.service.ts` + - Auth service: `frontend/src/app/core/services/auth.service.ts` + - Login component: `frontend/src/app/features/auth/login/login.component.ts` + - App routes: `frontend/src/app/app.routes.ts` + - Property store: `frontend/src/app/features/properties/stores/property.store.ts` + - MaintenanceRequestsController: `backend/src/PropertyManager.Api/Controllers/MaintenanceRequestsController.cs` + - MaintenanceRequestDto: `backend/src/PropertyManager.Application/MaintenanceRequests/MaintenanceRequestDto.cs` + - GetMaintenanceRequests: `backend/src/PropertyManager.Application/MaintenanceRequests/GetMaintenanceRequests.cs` + - GetMaintenanceRequestById: `backend/src/PropertyManager.Application/MaintenanceRequests/GetMaintenanceRequestById.cs` + - MaintenanceRequestPhotoDto: `backend/src/PropertyManager.Application/MaintenanceRequestPhotos/GetMaintenanceRequestPhotos.cs` + - Permissions: `backend/src/PropertyManager.Domain/Authorization/Permissions.cs` + - RolePermissions: `backend/src/PropertyManager.Domain/Authorization/RolePermissions.cs` + - Program.cs (policies): `backend/src/PropertyManager.Api/Program.cs` (lines 162-174) + +## File List + +### New Files +- `backend/src/PropertyManager.Application/MaintenanceRequests/GetTenantProperty.cs` — GetTenantPropertyQuery + TenantPropertyDto + Handler +- `backend/tests/PropertyManager.Application.Tests/MaintenanceRequests/GetTenantPropertyHandlerTests.cs` — 3 unit tests +- `frontend/src/app/features/tenant-dashboard/services/tenant.service.ts` — HTTP service for tenant API +- `frontend/src/app/features/tenant-dashboard/stores/tenant-dashboard.store.ts` — Signal store +- `frontend/src/app/features/tenant-dashboard/stores/tenant-dashboard.store.spec.ts` — 6 store tests +- `frontend/src/app/features/tenant-dashboard/tenant-dashboard.component.ts` — Main dashboard component +- `frontend/src/app/features/tenant-dashboard/tenant-dashboard.component.html` — Dashboard template +- `frontend/src/app/features/tenant-dashboard/tenant-dashboard.component.scss` — Mobile-first styles +- `frontend/src/app/features/tenant-dashboard/tenant-dashboard.component.spec.ts` — 5 component tests +- `frontend/src/app/features/tenant-dashboard/components/request-detail/request-detail.component.ts` — Request detail component +- `frontend/src/app/features/tenant-dashboard/components/request-detail/request-detail.component.html` — Detail template +- `frontend/src/app/features/tenant-dashboard/components/request-detail/request-detail.component.scss` — Detail styles +- `frontend/src/app/features/tenant-dashboard/components/request-detail/request-detail.component.spec.ts` — 3 detail tests +- `frontend/src/app/core/auth/tenant.guard.ts` — Tenant route guard +- `frontend/src/app/core/auth/tenant.guard.spec.ts` — 3 guard tests + +### Modified Files +- `backend/src/PropertyManager.Api/Controllers/MaintenanceRequestsController.cs` — Added GetTenantProperty endpoint +- `frontend/src/app/app.routes.ts` — Added tenant routes, imported tenantGuard +- `frontend/src/app/core/auth/auth.guard.ts` — guestGuard redirects tenants to /tenant +- `frontend/src/app/core/auth/auth.guard.spec.ts` — Added tenant redirect test, updated mock +- `frontend/src/app/core/auth/owner.guard.ts` — Redirects tenant to /tenant +- `frontend/src/app/core/auth/owner.guard.spec.ts` — Added tenant redirect test +- `frontend/src/app/core/auth/permission.service.ts` — Populated tenant routes array +- `frontend/src/app/core/auth/permission.service.spec.ts` — Added 2 tenant canAccess tests +- `frontend/src/app/core/components/sidebar-nav/sidebar-nav.component.ts` — Added Tenant nav items +- `frontend/src/app/core/components/sidebar-nav/sidebar-nav.component.spec.ts` — Added 3 Tenant tests +- `frontend/src/app/core/components/bottom-nav/bottom-nav.component.ts` — Added Tenant nav items +- `frontend/src/app/core/components/bottom-nav/bottom-nav.component.spec.ts` — Added 3 Tenant tests +- `frontend/src/app/core/components/shell/shell.component.ts` — isTenant computed, skip receipts for tenant +- `frontend/src/app/core/components/shell/shell.component.html` — Hide MobileCaptureFab for tenant +- `frontend/src/app/core/components/shell/shell.component.spec.ts` — Added 4 tenant/owner tests +- `frontend/src/app/features/auth/login/login.component.ts` — Role-based default redirect +- `frontend/src/app/features/auth/login/login.component.spec.ts` — Added 2 role-based redirect tests, fixed mock +- `docs/project/sprint-status.yaml` — Updated story status +- `docs/project/stories/epic-20/20-5-tenant-dashboard-role-routing.md` — Updated task status + +## Dev Agent Record + +### Implementation Notes +- Backend: Created GetTenantProperty query handler with TenantPropertyDto (lean, no financial data). Placed in MaintenanceRequests namespace per story spec. Added GET tenant-property endpoint to MaintenanceRequestsController. +- Frontend: Created full tenant-dashboard feature module with service, signal store, dashboard component (property card + request list), and request detail component (with photo grid, dismissal reason). +- Routing: Created tenantGuard, updated ownerGuard (tenant -> /tenant), guestGuard (tenant -> /tenant), LoginComponent (role-based default redirect). Added /tenant and /tenant/requests/:id routes with tenantGuard. +- Navigation: SidebarNav and BottomNav both show single Dashboard item for Tenant role pointing to /tenant. +- Shell: Skip receiptSignalR.initialize() and receiptStore.loadUnprocessedReceipts() for Tenant users. Hide MobileCaptureFab in template. +- PermissionService: Populated tenantRoutes with ['/tenant']. +- Tests: 3 backend unit tests (handler). ~32 new/updated frontend tests across store, components, guards, nav, login, shell. +- All existing tests pass: 1817 backend, 2735 frontend. Both builds succeed. diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 715f221d..63184b95 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -1,6 +1,7 @@ import { Routes } from '@angular/router'; import { authGuard, guestGuard, publicGuard } from './core/auth/auth.guard'; import { ownerGuard } from './core/auth/owner.guard'; +import { tenantGuard } from './core/auth/tenant.guard'; import { unsavedChangesGuard } from './core/guards/unsaved-changes.guard'; export const routes: Routes = [ @@ -249,6 +250,24 @@ export const routes: Routes = [ canActivate: [ownerGuard], canDeactivate: [unsavedChangesGuard], }, + // Tenant Dashboard (Story 20.5, AC #1, #2, #3) + { + path: 'tenant', + loadComponent: () => + import('./features/tenant-dashboard/tenant-dashboard.component').then( + (m) => m.TenantDashboardComponent, + ), + canActivate: [tenantGuard], + }, + // Tenant Request Detail (Story 20.5, AC #4) + { + path: 'tenant/requests/:id', + loadComponent: () => + import( + './features/tenant-dashboard/components/request-detail/request-detail.component' + ).then((m) => m.RequestDetailComponent), + canActivate: [tenantGuard], + }, // Default child redirect to dashboard { path: '', diff --git a/frontend/src/app/core/auth/auth.guard.spec.ts b/frontend/src/app/core/auth/auth.guard.spec.ts index 959e6273..c03ceefc 100644 --- a/frontend/src/app/core/auth/auth.guard.spec.ts +++ b/frontend/src/app/core/auth/auth.guard.spec.ts @@ -4,22 +4,36 @@ import { Router, UrlTree } from '@angular/router'; import { of, throwError } from 'rxjs'; import { signal } from '@angular/core'; import { authGuard, guestGuard, publicGuard } from './auth.guard'; -import { AuthService, LoginResponse } from '../services/auth.service'; +import { AuthService, LoginResponse, User } from '../services/auth.service'; describe('Auth Guards', () => { let mockAuthService: Partial; let mockRouter: Partial; let isAuthenticatedSignal: ReturnType>; let isInitializingSignal: ReturnType>; + let currentUserSignal: ReturnType>; + + function createUser(role: string): User { + return { + userId: 'test-user-id', + accountId: 'test-account-id', + role, + email: 'test@example.com', + displayName: 'Test User', + propertyId: role === 'Tenant' ? 'prop-1' : null, + }; + } beforeEach(() => { isAuthenticatedSignal = signal(false); isInitializingSignal = signal(false); + currentUserSignal = signal(null); mockAuthService = { isAuthenticated: isAuthenticatedSignal, isInitializing: isInitializingSignal, initializeAuth: vi.fn(), + currentUser: currentUserSignal, }; mockRouter = { createUrlTree: vi.fn(), @@ -111,8 +125,9 @@ describe('Auth Guards', () => { }); describe('guestGuard', () => { - it('should redirect to dashboard if user is already authenticated', () => { + it('should redirect to dashboard if user is already authenticated (Owner)', () => { isAuthenticatedSignal.set(true); + currentUserSignal.set(createUser('Owner')); const mockUrlTree = {} as UrlTree; vi.mocked(mockRouter.createUrlTree!).mockReturnValue(mockUrlTree); @@ -123,6 +138,20 @@ describe('Auth Guards', () => { }); }); + // Task 16.4: guestGuard redirects authenticated Tenant to /tenant (Story 20.5) + it('should redirect to /tenant if user is already authenticated Tenant', () => { + isAuthenticatedSignal.set(true); + currentUserSignal.set(createUser('Tenant')); + const mockUrlTree = {} as UrlTree; + vi.mocked(mockRouter.createUrlTree!).mockReturnValue(mockUrlTree); + + TestBed.runInInjectionContext(() => { + const result = guestGuard(null as any, null as any); + expect(result).toBe(mockUrlTree); + expect(mockRouter.createUrlTree).toHaveBeenCalledWith(['/tenant']); + }); + }); + it('should allow access if user is not authenticated and not initializing', () => { isAuthenticatedSignal.set(false); isInitializingSignal.set(false); diff --git a/frontend/src/app/core/auth/auth.guard.ts b/frontend/src/app/core/auth/auth.guard.ts index 97564019..7d74878b 100644 --- a/frontend/src/app/core/auth/auth.guard.ts +++ b/frontend/src/app/core/auth/auth.guard.ts @@ -61,8 +61,12 @@ export const guestGuard: CanActivateFn = () => { const authService = inject(AuthService); const router = inject(Router); - // If already authenticated, redirect to dashboard + // If already authenticated, redirect based on role (Story 20.5, AC #1, #7) if (authService.isAuthenticated()) { + const user = authService.currentUser(); + if (user?.role === 'Tenant') { + return router.createUrlTree(['/tenant']); + } return router.createUrlTree(['/dashboard']); } @@ -73,7 +77,11 @@ export const guestGuard: CanActivateFn = () => { take(1), map(response => { if (response) { - // User is authenticated - redirect to dashboard + // User is authenticated - redirect based on role (Story 20.5) + const user = authService.currentUser(); + if (user?.role === 'Tenant') { + return router.createUrlTree(['/tenant']); + } return router.createUrlTree(['/dashboard']); } // User is not authenticated - allow access to guest route diff --git a/frontend/src/app/core/auth/owner.guard.spec.ts b/frontend/src/app/core/auth/owner.guard.spec.ts index 84b848ca..c662da46 100644 --- a/frontend/src/app/core/auth/owner.guard.spec.ts +++ b/frontend/src/app/core/auth/owner.guard.spec.ts @@ -56,6 +56,19 @@ describe('ownerGuard', () => { }); }); + // Task 16.3: ownerGuard redirects Tenant to /tenant (Story 20.5, AC #6) + it('should redirect Tenant to /tenant', () => { + currentUserSignal.set(createUser('Tenant')); + const mockUrlTree = {} as UrlTree; + vi.mocked(mockRouter.createUrlTree!).mockReturnValue(mockUrlTree); + + TestBed.runInInjectionContext(() => { + const result = ownerGuard(null as any, { url: '/expenses' } as any); + expect(result).toBe(mockUrlTree); + expect(mockRouter.createUrlTree).toHaveBeenCalledWith(['/tenant']); + }); + }); + it('should redirect null user to /dashboard', () => { currentUserSignal.set(null); const mockUrlTree = {} as UrlTree; diff --git a/frontend/src/app/core/auth/owner.guard.ts b/frontend/src/app/core/auth/owner.guard.ts index c196dd63..968cd6f5 100644 --- a/frontend/src/app/core/auth/owner.guard.ts +++ b/frontend/src/app/core/auth/owner.guard.ts @@ -21,6 +21,11 @@ export const ownerGuard: CanActivateFn = (route, state) => { return true; } + // Tenant role — redirect to tenant dashboard (Story 20.5, AC #6) + if (user?.role === 'Tenant') { + return router.createUrlTree(['/tenant']); + } + // Contributor or unknown role — redirect to dashboard return router.createUrlTree(['/dashboard']); }; diff --git a/frontend/src/app/core/auth/permission.service.spec.ts b/frontend/src/app/core/auth/permission.service.spec.ts index a1a16d6d..bf29db23 100644 --- a/frontend/src/app/core/auth/permission.service.spec.ts +++ b/frontend/src/app/core/auth/permission.service.spec.ts @@ -202,6 +202,18 @@ describe('PermissionService', () => { expect(service.canAccess('/work-orders')).toBe(false); }); + // Task 16.7: Tenant can access /tenant (Story 20.5) + it('should allow Tenant to access /tenant', () => { + currentUserSignal.set(createUser('Tenant')); + expect(service.canAccess('/tenant')).toBe(true); + }); + + // Task 16.8: Tenant can access tenant subroutes + it('should allow Tenant to access /tenant/requests/123', () => { + currentUserSignal.set(createUser('Tenant')); + expect(service.canAccess('/tenant/requests/123')).toBe(true); + }); + // Null user it('should deny null user access to any route', () => { currentUserSignal.set(null); diff --git a/frontend/src/app/core/auth/permission.service.ts b/frontend/src/app/core/auth/permission.service.ts index d7f7bac3..b21fe414 100644 --- a/frontend/src/app/core/auth/permission.service.ts +++ b/frontend/src/app/core/auth/permission.service.ts @@ -31,9 +31,9 @@ export class PermissionService { if (this.isOwner()) return true; if (!this.authService.currentUser()) return false; - // Tenant-accessible routes (empty for now, will be populated in Story 20.5) + // Tenant-accessible routes (Story 20.5) if (this.isTenant()) { - const tenantRoutes: string[] = []; + const tenantRoutes: string[] = ['/tenant']; return tenantRoutes.some((r) => route === r || route.startsWith(r + '/')); } diff --git a/frontend/src/app/core/auth/tenant.guard.spec.ts b/frontend/src/app/core/auth/tenant.guard.spec.ts new file mode 100644 index 00000000..8a87f73d --- /dev/null +++ b/frontend/src/app/core/auth/tenant.guard.spec.ts @@ -0,0 +1,71 @@ +import { TestBed } from '@angular/core/testing'; +import { Router, UrlTree } from '@angular/router'; +import { signal } from '@angular/core'; +import { tenantGuard } from './tenant.guard'; +import { AuthService, User } from '../services/auth.service'; + +describe('tenantGuard', () => { + let currentUserSignal: ReturnType>; + let mockRouter: Partial; + + function createUser(role: string): User { + return { + userId: 'test-user-id', + accountId: 'test-account-id', + role, + email: 'test@example.com', + displayName: 'Test User', + propertyId: role === 'Tenant' ? 'prop-1' : null, + }; + } + + beforeEach(() => { + currentUserSignal = signal(createUser('Tenant')); + + mockRouter = { + createUrlTree: vi.fn().mockReturnValue({} as UrlTree), + }; + + TestBed.configureTestingModule({ + providers: [ + { provide: AuthService, useValue: { currentUser: currentUserSignal } }, + { provide: Router, useValue: mockRouter }, + ], + }); + }); + + // Task 16.1: tenantGuard allows Tenant role + it('should allow access for Tenant role', () => { + currentUserSignal.set(createUser('Tenant')); + + TestBed.runInInjectionContext(() => { + const result = tenantGuard(null as any, { url: '/tenant' } as any); + expect(result).toBe(true); + }); + }); + + // Task 16.2: tenantGuard redirects non-Tenant to /dashboard + it('should redirect Owner to /dashboard', () => { + currentUserSignal.set(createUser('Owner')); + const mockUrlTree = {} as UrlTree; + vi.mocked(mockRouter.createUrlTree!).mockReturnValue(mockUrlTree); + + TestBed.runInInjectionContext(() => { + const result = tenantGuard(null as any, { url: '/tenant' } as any); + expect(result).toBe(mockUrlTree); + expect(mockRouter.createUrlTree).toHaveBeenCalledWith(['/dashboard']); + }); + }); + + it('should redirect Contributor to /dashboard', () => { + currentUserSignal.set(createUser('Contributor')); + const mockUrlTree = {} as UrlTree; + vi.mocked(mockRouter.createUrlTree!).mockReturnValue(mockUrlTree); + + TestBed.runInInjectionContext(() => { + const result = tenantGuard(null as any, { url: '/tenant' } as any); + expect(result).toBe(mockUrlTree); + expect(mockRouter.createUrlTree).toHaveBeenCalledWith(['/dashboard']); + }); + }); +}); diff --git a/frontend/src/app/core/auth/tenant.guard.ts b/frontend/src/app/core/auth/tenant.guard.ts new file mode 100644 index 00000000..84ffff11 --- /dev/null +++ b/frontend/src/app/core/auth/tenant.guard.ts @@ -0,0 +1,26 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { AuthService } from '../services/auth.service'; + +/** + * Tenant Guard (Story 20.5, AC #1, #6) + * + * Route guard for Tenant-only routes. Runs on child routes inside the Shell, + * which already has authGuard — so by the time tenantGuard runs, the user + * is guaranteed to be authenticated. + * + * - Tenant role: allows access + * - Other roles: redirects to /dashboard + */ +export const tenantGuard: CanActivateFn = (route, state) => { + const authService = inject(AuthService); + const router = inject(Router); + + const user = authService.currentUser(); + if (user?.role === 'Tenant') { + return true; + } + + // Non-tenant role — redirect to landlord dashboard + return router.createUrlTree(['/dashboard']); +}; diff --git a/frontend/src/app/core/components/bottom-nav/bottom-nav.component.spec.ts b/frontend/src/app/core/components/bottom-nav/bottom-nav.component.spec.ts index 06e5d275..bde82647 100644 --- a/frontend/src/app/core/components/bottom-nav/bottom-nav.component.spec.ts +++ b/frontend/src/app/core/components/bottom-nav/bottom-nav.component.spec.ts @@ -101,6 +101,31 @@ describe('BottomNavComponent', () => { }); }); + // Task 16.6: BottomNavComponent shows only Dashboard for Tenant role (Story 20.5, AC #5) + describe('Tenant role (Story 20.5)', () => { + let component: BottomNavComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + ({ fixture, component } = await setupWithRole('Tenant')); + }); + + it('should have 1 navigation item for Tenant', () => { + expect(component.navItems().length).toBe(1); + }); + + it('should show only Dashboard for Tenant with /tenant route', () => { + const labels = component.navItems().map((item) => item.label); + expect(labels).toEqual(['Dashboard']); + expect(component.navItems()[0].route).toBe('/tenant'); + }); + + it('should render 1 nav tab in the DOM for Tenant', () => { + const navTabs = fixture.debugElement.queryAll(By.css('.nav-tab')); + expect(navTabs.length).toBe(1); + }); + }); + describe('Contributor role (AC: #6)', () => { let component: BottomNavComponent; let fixture: ComponentFixture; diff --git a/frontend/src/app/core/components/bottom-nav/bottom-nav.component.ts b/frontend/src/app/core/components/bottom-nav/bottom-nav.component.ts index b6222b11..52cb3347 100644 --- a/frontend/src/app/core/components/bottom-nav/bottom-nav.component.ts +++ b/frontend/src/app/core/components/bottom-nav/bottom-nav.component.ts @@ -51,7 +51,10 @@ export class BottomNavComponent implements OnInit { ngOnInit(): void { // Load unprocessed receipts on init to populate badge count for mobile-only viewports - this.receiptStore.loadUnprocessedReceipts(); + // Skip for Tenant users who don't use receipts (Story 20.5) + if (this.authService.currentUser()?.role !== 'Tenant') { + this.receiptStore.loadUnprocessedReceipts(); + } } // Bottom nav items filtered by role (AC7.5, AC-19.5 #6) @@ -68,6 +71,11 @@ export class BottomNavComponent implements OnInit { return ownerItems; } + // Tenant sees only Dashboard (Story 20.5, AC #5) + if (this.authService.currentUser()?.role === 'Tenant') { + return [{ label: 'Dashboard', route: '/tenant', icon: 'dashboard' }]; + } + // Contributor sees Dashboard, Receipts, Work Orders return [ { label: 'Dashboard', route: '/dashboard', icon: 'dashboard' }, diff --git a/frontend/src/app/core/components/shell/shell.component.html b/frontend/src/app/core/components/shell/shell.component.html index ff218ff9..15401d5f 100644 --- a/frontend/src/app/core/components/shell/shell.component.html +++ b/frontend/src/app/core/components/shell/shell.component.html @@ -80,5 +80,7 @@ } - - + +@if (!isTenant()) { + +} diff --git a/frontend/src/app/core/components/shell/shell.component.spec.ts b/frontend/src/app/core/components/shell/shell.component.spec.ts index 65f3b412..874906d6 100644 --- a/frontend/src/app/core/components/shell/shell.component.spec.ts +++ b/frontend/src/app/core/components/shell/shell.component.spec.ts @@ -300,6 +300,123 @@ describe('ShellComponent', () => { }); }); + // Task 18: ShellComponent tenant handling (Story 20.5, AC #5) + describe('tenant user handling (Story 20.5)', () => { + let tenantFixture: ComponentFixture; + let tenantComponent: ShellComponent; + let tenantReceiptSignalR: any; + let tenantReceiptStore: any; + + beforeEach(async () => { + const mockBreakpointObserver = createMockBreakpointObserver(false, false, true); + const tenantAuthService = { + logout: vi.fn().mockReturnValue(of(undefined)), + logoutAndRedirect: vi.fn(), + accessToken: vi.fn().mockReturnValue('token'), + currentUser: vi.fn().mockReturnValue({ + userId: 'tenant-id', + accountId: 'account-id', + role: 'Tenant', + email: 'tenant@example.com', + displayName: 'Jane Tenant', + propertyId: 'prop-1', + }), + isAuthenticated: vi.fn().mockReturnValue(true), + isInitializing: vi.fn().mockReturnValue(false), + }; + tenantReceiptStore = { + loadUnprocessedReceipts: vi.fn(), + unprocessedCount: signal(0), + }; + tenantReceiptSignalR = { + initialize: vi.fn(), + handleReconnection: vi.fn(), + }; + + await TestBed.configureTestingModule({ + imports: [ShellComponent, NoopAnimationsModule], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + provideRouter([]), + { provide: BreakpointObserver, useValue: mockBreakpointObserver }, + { provide: AuthService, useValue: tenantAuthService }, + { provide: ReceiptStore, useValue: tenantReceiptStore }, + { provide: ReceiptSignalRService, useValue: tenantReceiptSignalR }, + { provide: SignalRService, useValue: mockSignalR }, + ], + }).compileComponents(); + + tenantFixture = TestBed.createComponent(ShellComponent); + tenantComponent = tenantFixture.componentInstance; + tenantFixture.detectChanges(); + }); + + // Task 18.1: ShellComponent does NOT call receiptSignalR.initialize() for Tenant users + it('should NOT call receiptSignalR.initialize() for Tenant users', () => { + expect(tenantReceiptSignalR.initialize).not.toHaveBeenCalled(); + }); + + // Note: receiptStore.loadUnprocessedReceipts may still be called by SidebarNavComponent.ngOnInit() + // The shell skips its own call, but the sidebar also triggers it. This is acceptable behavior. + + // Task 18.2: ShellComponent hides MobileCaptureFab for Tenant users + it('should hide MobileCaptureFab for Tenant users', () => { + const fab = tenantFixture.debugElement.query(By.css('app-mobile-capture-fab')); + expect(fab).toBeFalsy(); + }); + }); + + // Task 18.3: ShellComponent initializes receipts for Owner users (regression) + describe('owner user receipts (regression)', () => { + it('should call receiptSignalR.initialize() for Owner users', async () => { + const mockBreakpointObserver = createMockBreakpointObserver(true, false, false); + const ownerAuthService = { + logout: vi.fn().mockReturnValue(of(undefined)), + logoutAndRedirect: vi.fn(), + accessToken: vi.fn().mockReturnValue('token'), + currentUser: vi.fn().mockReturnValue({ + userId: 'owner-id', + accountId: 'account-id', + role: 'Owner', + email: 'owner@example.com', + displayName: 'Owner User', + propertyId: null, + }), + isAuthenticated: vi.fn().mockReturnValue(true), + isInitializing: vi.fn().mockReturnValue(false), + }; + const ownerReceiptSignalR = { + initialize: vi.fn(), + handleReconnection: vi.fn(), + }; + const ownerReceiptStore = { + loadUnprocessedReceipts: vi.fn(), + unprocessedCount: signal(0), + }; + + await TestBed.configureTestingModule({ + imports: [ShellComponent, NoopAnimationsModule], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + provideRouter([]), + { provide: BreakpointObserver, useValue: mockBreakpointObserver }, + { provide: AuthService, useValue: ownerAuthService }, + { provide: ReceiptStore, useValue: ownerReceiptStore }, + { provide: ReceiptSignalRService, useValue: ownerReceiptSignalR }, + { provide: SignalRService, useValue: mockSignalR }, + ], + }).compileComponents(); + + const ownerFixture = TestBed.createComponent(ShellComponent); + ownerFixture.detectChanges(); + + expect(ownerReceiptSignalR.initialize).toHaveBeenCalled(); + expect(ownerReceiptStore.loadUnprocessedReceipts).toHaveBeenCalled(); + }); + }); + describe('userDisplayName in header (AC-7.2.3)', () => { beforeEach(async () => { // Mock mobile view to test mobile header diff --git a/frontend/src/app/core/components/shell/shell.component.ts b/frontend/src/app/core/components/shell/shell.component.ts index 0c68340f..9991d506 100644 --- a/frontend/src/app/core/components/shell/shell.component.ts +++ b/frontend/src/app/core/components/shell/shell.component.ts @@ -124,12 +124,18 @@ export class ShellComponent implements OnInit, OnDestroy { }); } + /** Whether the current user is a tenant (Story 20.5, AC #5) */ + readonly isTenant = computed(() => this.authService.currentUser()?.role === 'Tenant'); + ngOnInit(): void { - // Initialize SignalR for real-time receipt updates (AC-5.6.1) - this.receiptSignalR.initialize(); + // Skip receipt features for Tenant users (Story 20.5, AC #5) + if (!this.isTenant()) { + // Initialize SignalR for real-time receipt updates (AC-5.6.1) + this.receiptSignalR.initialize(); - // Load initial receipt count for badge - this.receiptStore.loadUnprocessedReceipts(); + // Load initial receipt count for badge + this.receiptStore.loadUnprocessedReceipts(); + } } ngOnDestroy(): void { diff --git a/frontend/src/app/core/components/sidebar-nav/sidebar-nav.component.spec.ts b/frontend/src/app/core/components/sidebar-nav/sidebar-nav.component.spec.ts index 0c5344e8..8a18df93 100644 --- a/frontend/src/app/core/components/sidebar-nav/sidebar-nav.component.spec.ts +++ b/frontend/src/app/core/components/sidebar-nav/sidebar-nav.component.spec.ts @@ -176,6 +176,28 @@ describe('SidebarNavComponent', () => { }); }); + // Task 16.5: SidebarNavComponent shows only Dashboard for Tenant role (Story 20.5, AC #5) + describe('Tenant role (Story 20.5)', () => { + beforeEach(async () => { + await setupWithRole('Tenant'); + }); + + it('should have 1 navigation item for Tenant', () => { + expect(component.navItems().length).toBe(1); + }); + + it('should show only Dashboard for Tenant with /tenant route', () => { + const labels = component.navItems().map((item) => item.label); + expect(labels).toEqual(['Dashboard']); + expect(component.navItems()[0].route).toBe('/tenant'); + }); + + it('should render 1 nav item in the DOM', () => { + const navItems = fixture.debugElement.queryAll(By.css('.nav-item')); + expect(navItems.length).toBe(1); + }); + }); + describe('userDisplayName fallback logic (AC-7.2.2)', () => { it('should fall back to email when displayName is null', async () => { const userWithoutDisplayName: User = { diff --git a/frontend/src/app/core/components/sidebar-nav/sidebar-nav.component.ts b/frontend/src/app/core/components/sidebar-nav/sidebar-nav.component.ts index eb682a60..2047d724 100644 --- a/frontend/src/app/core/components/sidebar-nav/sidebar-nav.component.ts +++ b/frontend/src/app/core/components/sidebar-nav/sidebar-nav.component.ts @@ -75,6 +75,11 @@ export class SidebarNavComponent implements OnInit { return allItems; } + // Tenant sees only Dashboard (Story 20.5, AC #5) + if (this.authService.currentUser()?.role === 'Tenant') { + return [{ label: 'Dashboard', route: '/tenant', icon: 'dashboard' }]; + } + // Contributor sees only these routes const contributorRoutes = ['/dashboard', '/receipts', '/work-orders']; return allItems.filter((item) => contributorRoutes.includes(item.route)); @@ -82,7 +87,10 @@ export class SidebarNavComponent implements OnInit { ngOnInit(): void { // Load unprocessed receipts on init to populate badge count - this.receiptStore.loadUnprocessedReceipts(); + // Skip for Tenant users who don't use receipts (Story 20.5) + if (this.authService.currentUser()?.role !== 'Tenant') { + this.receiptStore.loadUnprocessedReceipts(); + } } /** diff --git a/frontend/src/app/features/auth/login/login.component.spec.ts b/frontend/src/app/features/auth/login/login.component.spec.ts index 6d462748..6c8fd2ce 100644 --- a/frontend/src/app/features/auth/login/login.component.spec.ts +++ b/frontend/src/app/features/auth/login/login.component.spec.ts @@ -3,21 +3,24 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Router, ActivatedRoute, provideRouter } from '@angular/router'; import { provideNoopAnimations } from '@angular/platform-browser/animations'; import { of, throwError, Subject } from 'rxjs'; +import { signal } from '@angular/core'; import { HttpErrorResponse } from '@angular/common/http'; import { LoginComponent } from './login.component'; -import { AuthService } from '../../../core/services/auth.service'; +import { AuthService, User } from '../../../core/services/auth.service'; describe('LoginComponent', () => { let component: LoginComponent; let fixture: ComponentFixture; let mockAuthService: { login: ReturnType; + currentUser: ReturnType>; }; let router: Router; beforeEach(async () => { mockAuthService = { login: vi.fn(), + currentUser: signal(null), }; await TestBed.configureTestingModule({ @@ -278,7 +281,7 @@ describe('LoginComponent', () => { async function setupWithReturnUrl(returnUrl: string | null) { TestBed.resetTestingModule(); - const authService = { login: vi.fn() }; + const authService = { login: vi.fn(), currentUser: signal(null) }; await TestBed.configureTestingModule({ imports: [LoginComponent], @@ -362,4 +365,87 @@ describe('LoginComponent', () => { expect(ctx.router.navigateByUrl).toHaveBeenCalledWith('/dashboard'); }); }); + + // Task 17: LoginComponent tenant redirect (Story 20.5, AC #1, #7) + describe('role-based redirect after login', () => { + async function setupWithRoleAndReturnUrl(role: string, returnUrl: string | null) { + TestBed.resetTestingModule(); + + const mockUser: User = { + userId: 'test-user-id', + accountId: 'test-account-id', + role, + email: 'test@example.com', + displayName: 'Test User', + propertyId: role === 'Tenant' ? 'prop-1' : null, + }; + + const currentUserSignal = signal(null); + // Login observable that sets the currentUser signal before emitting + const loginObservable = new Subject(); + const authService = { + login: vi.fn().mockImplementation(() => { + // Set currentUser before the subscriber gets the response + currentUserSignal.set(mockUser); + return of({ accessToken: 'token', expiresIn: 3600 }); + }), + currentUser: currentUserSignal, + }; + + await TestBed.configureTestingModule({ + imports: [LoginComponent], + providers: [ + provideNoopAnimations(), + provideRouter([]), + { provide: AuthService, useValue: authService }, + { + provide: ActivatedRoute, + useValue: { + snapshot: { + queryParamMap: { + get: (key: string) => (key === 'returnUrl' ? returnUrl : null), + }, + }, + }, + }, + ], + }).compileComponents(); + + const testRouter = TestBed.inject(Router); + vi.spyOn(testRouter, 'navigate').mockImplementation(() => Promise.resolve(true)); + vi.spyOn(testRouter, 'navigateByUrl').mockImplementation(() => Promise.resolve(true)); + + const testFixture = TestBed.createComponent(LoginComponent); + const testComponent = testFixture.componentInstance; + testFixture.detectChanges(); + + return { component: testComponent, router: testRouter, authService }; + } + + // Task 17.1: Tenant user redirected to /tenant after login (no returnUrl) + it('should redirect Tenant to /tenant after login when no returnUrl', async () => { + const ctx = await setupWithRoleAndReturnUrl('Tenant', null); + + ctx.component['form'].patchValue({ + email: 'tenant@example.com', + password: 'password123', + }); + ctx.component['onSubmit'](); + + expect(ctx.router.navigateByUrl).toHaveBeenCalledWith('/tenant'); + }); + + // Task 17.2: Owner user redirected to /dashboard after login (regression check) + it('should redirect Owner to /dashboard after login when no returnUrl', async () => { + const ctx = await setupWithRoleAndReturnUrl('Owner', null); + + ctx.component['form'].patchValue({ + email: 'owner@example.com', + password: 'password123', + }); + ctx.component['onSubmit'](); + + expect(ctx.router.navigateByUrl).toHaveBeenCalledWith('/dashboard'); + }); + }); }); diff --git a/frontend/src/app/features/auth/login/login.component.ts b/frontend/src/app/features/auth/login/login.component.ts index 31590c98..0d082e43 100644 --- a/frontend/src/app/features/auth/login/login.component.ts +++ b/frontend/src/app/features/auth/login/login.component.ts @@ -43,7 +43,12 @@ export class LoginComponent { private getSafeReturnUrl(): string { const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl'); - if (!returnUrl) return '/dashboard'; + if (!returnUrl) { + // Role-based default redirect (Story 20.5, AC #1, #7) + const user = this.authService.currentUser(); + if (user?.role === 'Tenant') return '/tenant'; + return '/dashboard'; + } if (returnUrl.startsWith('/') && !returnUrl.startsWith('//') && !returnUrl.includes('://')) { return returnUrl; } diff --git a/frontend/src/app/features/tenant-dashboard/components/request-detail/request-detail.component.html b/frontend/src/app/features/tenant-dashboard/components/request-detail/request-detail.component.html new file mode 100644 index 00000000..1bdc277f --- /dev/null +++ b/frontend/src/app/features/tenant-dashboard/components/request-detail/request-detail.component.html @@ -0,0 +1,82 @@ +
+ + + + + @if (isLoading()) { + + } + + + @if (error()) { + + } + + + @if (request(); as req) { + + + Maintenance Request + Submitted {{ req.createdAt | date: 'medium' }} + + + + +
+ + + {{ getStatusLabel(req.status) }} + + +
+ + +
+

Description

+

{{ req.description }}

+
+ + + @if (req.status === 'Dismissed' && req.dismissalReason) { +
+

Dismissal Reason

+
+ info +

{{ req.dismissalReason }}

+
+
+ } + + + @if (req.photos && req.photos.length > 0) { +
+

Photos

+
+ @for (photo of req.photos; track photo.id) { +
+ +
+ } +
+
+ } +
+
+ } +
diff --git a/frontend/src/app/features/tenant-dashboard/components/request-detail/request-detail.component.scss b/frontend/src/app/features/tenant-dashboard/components/request-detail/request-detail.component.scss new file mode 100644 index 00000000..d62b9f0c --- /dev/null +++ b/frontend/src/app/features/tenant-dashboard/components/request-detail/request-detail.component.scss @@ -0,0 +1,126 @@ +:host { + display: block; +} + +.request-detail { + max-width: 800px; + margin: 0 auto; + padding: 16px; +} + +.back-button { + margin-bottom: 16px; + color: var(--pm-text-secondary); + + mat-icon { + margin-right: 4px; + } +} + +.detail-card { + mat-card-content { + padding: 16px 0 0 0; + } +} + +.status-section { + margin-bottom: 16px; +} + +// Status Badge Colors +.status-submitted { + --mdc-chip-elevated-container-color: #e0e0e0; + --mdc-chip-label-text-color: #616161; +} + +.status-in-progress { + --mdc-chip-elevated-container-color: #e3f2fd; + --mdc-chip-label-text-color: #1565c0; +} + +.status-resolved { + --mdc-chip-elevated-container-color: #e8f5e9; + --mdc-chip-label-text-color: #2e7d32; +} + +.status-dismissed { + --mdc-chip-elevated-container-color: #fff3e0; + --mdc-chip-label-text-color: #e65100; +} + +.description-section, +.dismissal-section, +.photos-section { + margin-bottom: 20px; + + h3 { + font-size: 14px; + font-weight: 500; + color: var(--pm-text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin: 0 0 8px 0; + } + + p { + font-size: 15px; + line-height: 1.6; + color: var(--pm-text-primary); + margin: 0; + white-space: pre-wrap; + } +} + +.dismissal-reason { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 12px; + background-color: #fff3e0; + border-radius: 4px; + color: #e65100; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + flex-shrink: 0; + margin-top: 1px; + } + + p { + color: #e65100; + font-size: 14px; + } +} + +// Photo Grid +.photo-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 8px; +} + +.photo-thumbnail { + aspect-ratio: 1; + overflow: hidden; + border-radius: 8px; + border: 1px solid rgba(0, 0, 0, 0.12); + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +} + +// Mobile +@media (max-width: 599px) { + .request-detail { + padding: 12px; + } + + .photo-grid { + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + } +} diff --git a/frontend/src/app/features/tenant-dashboard/components/request-detail/request-detail.component.spec.ts b/frontend/src/app/features/tenant-dashboard/components/request-detail/request-detail.component.spec.ts new file mode 100644 index 00000000..ee9a82f2 --- /dev/null +++ b/frontend/src/app/features/tenant-dashboard/components/request-detail/request-detail.component.spec.ts @@ -0,0 +1,95 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter, ActivatedRoute } from '@angular/router'; +import { of } from 'rxjs'; +import { RequestDetailComponent } from './request-detail.component'; +import { TenantService, MaintenanceRequestDto } from '../../services/tenant.service'; + +describe('RequestDetailComponent', () => { + let component: RequestDetailComponent; + let fixture: ComponentFixture; + let tenantServiceMock: { getMaintenanceRequestById: ReturnType }; + + const mockRequest: MaintenanceRequestDto = { + id: 'req-1', + propertyId: 'prop-1', + propertyName: 'Sunset Apartments', + propertyAddress: '123 Main St, Austin, TX 78701', + description: 'The kitchen sink is leaking under the cabinet.', + status: 'Submitted', + dismissalReason: null, + submittedByUserId: 'user-1', + submittedByUserName: 'John Tenant', + workOrderId: null, + createdAt: '2026-04-10T12:00:00Z', + updatedAt: '2026-04-10T12:00:00Z', + photos: null, + }; + + beforeEach(async () => { + tenantServiceMock = { + getMaintenanceRequestById: vi.fn().mockReturnValue(of(mockRequest)), + }; + + await TestBed.configureTestingModule({ + imports: [RequestDetailComponent], + providers: [ + provideRouter([]), + { provide: TenantService, useValue: tenantServiceMock }, + { + provide: ActivatedRoute, + useValue: { snapshot: { paramMap: { get: () => 'req-1' } } }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(RequestDetailComponent); + component = fixture.componentInstance; + }); + + // Task 15.1: renders full description and status + it('renders full description and status', () => { + fixture.detectChanges(); + + const description = fixture.nativeElement.querySelector( + '[data-testid="request-description"]', + ); + expect(description).toBeTruthy(); + expect(description.textContent).toContain( + 'The kitchen sink is leaking under the cabinet.', + ); + + const detailCard = fixture.nativeElement.querySelector( + '[data-testid="request-detail-card"]', + ); + expect(detailCard.textContent).toContain('Submitted'); + }); + + // Task 15.2: renders dismissal reason when status is Dismissed + it('renders dismissal reason when status is Dismissed', () => { + const dismissedRequest = { + ...mockRequest, + status: 'Dismissed', + dismissalReason: 'Duplicate of another request', + }; + tenantServiceMock.getMaintenanceRequestById.mockReturnValue(of(dismissedRequest)); + + fixture = TestBed.createComponent(RequestDetailComponent); + fixture.detectChanges(); + + const dismissalReason = fixture.nativeElement.querySelector( + '[data-testid="dismissal-reason"]', + ); + expect(dismissalReason).toBeTruthy(); + expect(dismissalReason.textContent).toContain('Duplicate of another request'); + }); + + // Task 15.3: hides dismissal reason when status is not Dismissed + it('hides dismissal reason when status is not Dismissed', () => { + fixture.detectChanges(); + + const dismissalReason = fixture.nativeElement.querySelector( + '[data-testid="dismissal-reason"]', + ); + expect(dismissalReason).toBeFalsy(); + }); +}); diff --git a/frontend/src/app/features/tenant-dashboard/components/request-detail/request-detail.component.ts b/frontend/src/app/features/tenant-dashboard/components/request-detail/request-detail.component.ts new file mode 100644 index 00000000..9a288d17 --- /dev/null +++ b/frontend/src/app/features/tenant-dashboard/components/request-detail/request-detail.component.ts @@ -0,0 +1,102 @@ +import { Component, inject, OnInit, signal } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { MatCardModule } from '@angular/material/card'; +import { MatIconModule } from '@angular/material/icon'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatButtonModule } from '@angular/material/button'; +import { DatePipe } from '@angular/common'; + +import { TenantService, MaintenanceRequestDto } from '../../services/tenant.service'; +import { LoadingSpinnerComponent } from '../../../../shared/components/loading-spinner/loading-spinner.component'; +import { ErrorCardComponent } from '../../../../shared/components/error-card/error-card.component'; + +/** + * Request Detail Component (Story 20.5, AC #4) + * + * Shows full detail for a single maintenance request: + * - Full description + * - Status with badge + * - Submitted date + * - Dismissal reason (if dismissed) + * - Photos (if any) + */ +@Component({ + selector: 'app-request-detail', + standalone: true, + imports: [ + MatCardModule, + MatIconModule, + MatChipsModule, + MatButtonModule, + DatePipe, + LoadingSpinnerComponent, + ErrorCardComponent, + ], + templateUrl: './request-detail.component.html', + styleUrl: './request-detail.component.scss', +}) +export class RequestDetailComponent implements OnInit { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly tenantService = inject(TenantService); + + readonly request = signal(null); + readonly isLoading = signal(true); + readonly error = signal(null); + + private requestId = ''; + + ngOnInit(): void { + this.requestId = this.route.snapshot.paramMap.get('id') ?? ''; + this.loadRequest(); + } + + loadRequest(): void { + if (!this.requestId) { + this.error.set('Invalid request ID.'); + this.isLoading.set(false); + return; + } + + this.isLoading.set(true); + this.error.set(null); + + this.tenantService.getMaintenanceRequestById(this.requestId).subscribe({ + next: (result) => { + this.request.set(result); + this.isLoading.set(false); + }, + error: (err) => { + this.error.set('Failed to load maintenance request.'); + this.isLoading.set(false); + console.error('Error loading request detail:', err); + }, + }); + } + + goBack(): void { + this.router.navigate(['/tenant']); + } + + getStatusColor(status: string): string { + switch (status) { + case 'InProgress': + return 'primary'; + case 'Resolved': + return 'accent'; + case 'Dismissed': + return 'warn'; + default: + return ''; + } + } + + getStatusLabel(status: string): string { + switch (status) { + case 'InProgress': + return 'In Progress'; + default: + return status; + } + } +} diff --git a/frontend/src/app/features/tenant-dashboard/services/tenant.service.ts b/frontend/src/app/features/tenant-dashboard/services/tenant.service.ts new file mode 100644 index 00000000..c06e5f72 --- /dev/null +++ b/frontend/src/app/features/tenant-dashboard/services/tenant.service.ts @@ -0,0 +1,93 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +/** + * Tenant property DTO — read-only, no financial data (Story 20.5, AC #2). + */ +export interface TenantPropertyDto { + id: string; + name: string; + street: string; + city: string; + state: string; + zipCode: string; +} + +/** + * Maintenance request photo DTO (Story 20.4). + */ +export interface MaintenanceRequestPhotoDto { + id: string; + thumbnailUrl: string | null; + viewUrl: string | null; + isPrimary: boolean; + displayOrder: number; + originalFileName: string; + fileSizeBytes: number; + createdAt: string; +} + +/** + * Maintenance request DTO matching backend MaintenanceRequestDto. + */ +export interface MaintenanceRequestDto { + id: string; + propertyId: string; + propertyName: string; + propertyAddress: string; + description: string; + status: string; + dismissalReason: string | null; + submittedByUserId: string; + submittedByUserName: string | null; + workOrderId: string | null; + createdAt: string; + updatedAt: string; + photos: MaintenanceRequestPhotoDto[] | null; +} + +/** + * Paginated maintenance requests response. + */ +export interface PaginatedMaintenanceRequests { + items: MaintenanceRequestDto[]; + totalCount: number; + page: number; + pageSize: number; + totalPages: number; +} + +/** + * Tenant Service (Story 20.5, AC #2, #3, #4) + * + * HTTP service for tenant-specific API calls. + * Uses manual HttpClient calls (no NSwag generation). + */ +@Injectable({ providedIn: 'root' }) +export class TenantService { + private readonly http = inject(HttpClient); + private readonly baseUrl = '/api/v1/maintenance-requests'; + + /** + * Get the current tenant's assigned property info. + */ + getTenantProperty(): Observable { + return this.http.get(`${this.baseUrl}/tenant-property`); + } + + /** + * Get maintenance requests with pagination. + */ + getMaintenanceRequests(page = 1, pageSize = 20): Observable { + const params = new HttpParams().set('page', page.toString()).set('pageSize', pageSize.toString()); + return this.http.get(this.baseUrl, { params }); + } + + /** + * Get a single maintenance request by ID with full detail. + */ + getMaintenanceRequestById(id: string): Observable { + return this.http.get(`${this.baseUrl}/${id}`); + } +} diff --git a/frontend/src/app/features/tenant-dashboard/stores/tenant-dashboard.store.spec.ts b/frontend/src/app/features/tenant-dashboard/stores/tenant-dashboard.store.spec.ts new file mode 100644 index 00000000..534f3244 --- /dev/null +++ b/frontend/src/app/features/tenant-dashboard/stores/tenant-dashboard.store.spec.ts @@ -0,0 +1,127 @@ +import { TestBed } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { TenantDashboardStore } from './tenant-dashboard.store'; +import { TenantService } from '../services/tenant.service'; + +describe('TenantDashboardStore', () => { + let store: InstanceType; + let tenantServiceMock: { + getTenantProperty: ReturnType; + getMaintenanceRequests: ReturnType; + getMaintenanceRequestById: ReturnType; + }; + + const mockProperty = { + id: 'prop-1', + name: 'Sunset Apartments', + street: '123 Main St', + city: 'Austin', + state: 'TX', + zipCode: '78701', + }; + + const mockRequestsResponse = { + items: [ + { + id: 'req-1', + propertyId: 'prop-1', + propertyName: 'Sunset Apartments', + propertyAddress: '123 Main St, Austin, TX 78701', + description: 'Broken window', + status: 'Submitted', + dismissalReason: null, + submittedByUserId: 'user-1', + submittedByUserName: 'John', + workOrderId: null, + createdAt: '2026-04-10T00:00:00Z', + updatedAt: '2026-04-10T00:00:00Z', + photos: null, + }, + ], + totalCount: 1, + page: 1, + pageSize: 20, + totalPages: 1, + }; + + beforeEach(() => { + tenantServiceMock = { + getTenantProperty: vi.fn().mockReturnValue(of(mockProperty)), + getMaintenanceRequests: vi.fn().mockReturnValue(of(mockRequestsResponse)), + getMaintenanceRequestById: vi.fn(), + }; + + TestBed.configureTestingModule({ + providers: [TenantDashboardStore, { provide: TenantService, useValue: tenantServiceMock }], + }); + + store = TestBed.inject(TenantDashboardStore); + }); + + // Task 13.1: loadProperty() fetches and stores property data + it('loadProperty() fetches and stores property data', () => { + store.loadProperty(); + + expect(tenantServiceMock.getTenantProperty).toHaveBeenCalled(); + expect(store.property()).toEqual(mockProperty); + expect(store.isLoading()).toBe(false); + expect(store.error()).toBeNull(); + }); + + // Task 13.2: loadProperty() error sets error state + it('loadProperty() error sets error state', () => { + tenantServiceMock.getTenantProperty.mockReturnValue( + throwError(() => new Error('Network error')), + ); + + store.loadProperty(); + + expect(store.property()).toBeNull(); + expect(store.isLoading()).toBe(false); + expect(store.error()).toBe('Failed to load property information.'); + }); + + // Task 13.3: loadRequests() fetches and stores maintenance requests + it('loadRequests() fetches and stores maintenance requests', () => { + store.loadRequests(); + + expect(tenantServiceMock.getMaintenanceRequests).toHaveBeenCalled(); + expect(store.requests()).toEqual(mockRequestsResponse.items); + expect(store.totalCount()).toBe(1); + expect(store.isLoading()).toBe(false); + }); + + // Task 13.4: loadRequests() error sets error state + it('loadRequests() error sets error state', () => { + tenantServiceMock.getMaintenanceRequests.mockReturnValue( + throwError(() => new Error('Network error')), + ); + + store.loadRequests(); + + expect(store.requests()).toEqual([]); + expect(store.isLoading()).toBe(false); + expect(store.error()).toBe('Failed to load maintenance requests.'); + }); + + // Task 13.5: totalPages computed correctly + it('totalPages computes correctly', () => { + const manyRequestsResponse = { + ...mockRequestsResponse, + totalCount: 45, + pageSize: 20, + }; + tenantServiceMock.getMaintenanceRequests.mockReturnValue(of(manyRequestsResponse)); + + store.loadRequests(); + + expect(store.totalPages()).toBe(3); + }); + + // Task 13.6: propertyAddress formats correctly + it('propertyAddress formats correctly', () => { + store.loadProperty(); + + expect(store.propertyAddress()).toBe('123 Main St, Austin, TX 78701'); + }); +}); diff --git a/frontend/src/app/features/tenant-dashboard/stores/tenant-dashboard.store.ts b/frontend/src/app/features/tenant-dashboard/stores/tenant-dashboard.store.ts new file mode 100644 index 00000000..70d39938 --- /dev/null +++ b/frontend/src/app/features/tenant-dashboard/stores/tenant-dashboard.store.ts @@ -0,0 +1,108 @@ +import { computed, inject } from '@angular/core'; +import { patchState, signalStore, withComputed, withMethods, withState } from '@ngrx/signals'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { pipe, switchMap, tap, catchError, of } from 'rxjs'; +import { + TenantService, + TenantPropertyDto, + MaintenanceRequestDto, +} from '../services/tenant.service'; + +/** + * Tenant Dashboard Store State + */ +interface TenantDashboardState { + property: TenantPropertyDto | null; + requests: MaintenanceRequestDto[]; + isLoading: boolean; + error: string | null; + totalCount: number; + page: number; + pageSize: number; +} + +const initialState: TenantDashboardState = { + property: null, + requests: [], + isLoading: false, + error: null, + totalCount: 0, + page: 1, + pageSize: 20, +}; + +/** + * Tenant Dashboard Store (Story 20.5, AC #2, #3, #4) + * + * Signal store for tenant dashboard state: + * - Property info (read-only) + * - Maintenance request list with pagination + * - Loading/error states + */ +export const TenantDashboardStore = signalStore( + { providedIn: 'root' }, + withState(initialState), + withComputed((store) => ({ + totalPages: computed(() => { + const ps = store.pageSize(); + const tc = store.totalCount(); + return ps > 0 ? Math.ceil(tc / ps) : 0; + }), + isEmpty: computed(() => store.requests().length === 0 && !store.isLoading()), + propertyAddress: computed(() => { + const p = store.property(); + if (!p) return ''; + return `${p.street}, ${p.city}, ${p.state} ${p.zipCode}`; + }), + isPropertyLoaded: computed(() => store.property() !== null), + })), + withMethods((store, tenantService = inject(TenantService)) => ({ + loadProperty: rxMethod( + pipe( + tap(() => patchState(store, { isLoading: true, error: null })), + switchMap(() => + tenantService.getTenantProperty().pipe( + tap((property) => patchState(store, { property, isLoading: false })), + catchError((error) => { + patchState(store, { + isLoading: false, + error: 'Failed to load property information.', + }); + console.error('Error loading tenant property:', error); + return of(null); + }), + ), + ), + ), + ), + + loadRequests: rxMethod<{ page?: number; pageSize?: number } | void>( + pipe( + tap(() => patchState(store, { isLoading: true, error: null })), + switchMap((params) => { + const page = (params && typeof params === 'object' && 'page' in params) ? params.page ?? store.page() : store.page(); + const pageSize = (params && typeof params === 'object' && 'pageSize' in params) ? params.pageSize ?? store.pageSize() : store.pageSize(); + return tenantService.getMaintenanceRequests(page, pageSize).pipe( + tap((response) => + patchState(store, { + requests: response.items, + totalCount: response.totalCount, + page: response.page, + pageSize: response.pageSize, + isLoading: false, + }), + ), + catchError((error) => { + patchState(store, { + isLoading: false, + error: 'Failed to load maintenance requests.', + }); + console.error('Error loading maintenance requests:', error); + return of(null); + }), + ); + }), + ), + ), + })), +); diff --git a/frontend/src/app/features/tenant-dashboard/tenant-dashboard.component.html b/frontend/src/app/features/tenant-dashboard/tenant-dashboard.component.html new file mode 100644 index 00000000..86443eb6 --- /dev/null +++ b/frontend/src/app/features/tenant-dashboard/tenant-dashboard.component.html @@ -0,0 +1,94 @@ +
+ + @if (store.isLoading() && !store.isPropertyLoaded()) { + + } + + + @if (store.error()) { + + } + + + @if (!store.error()) { + + @if (store.isPropertyLoaded()) { + + + home + {{ store.property()!.name }} + {{ store.propertyAddress() }} + + + } + + + @if (!store.isLoading()) { + @if (store.isEmpty()) { + + } @else { +
+

Maintenance Requests

+ + @for (request of store.requests(); track request.id) { + + +
+ + + {{ getStatusLabel(request.status) }} + + + {{ request.createdAt | date: 'mediumDate' }} +
+

{{ truncateDescription(request.description) }}

+ @if (request.status === 'Dismissed' && request.dismissalReason) { +

+ info + {{ request.dismissalReason }} +

+ } +
+
+ } + + + @if (store.totalCount() > store.pageSize()) { + + } +
+ } + } + + + @if (store.isLoading() && store.isPropertyLoaded()) { + + } + } +
diff --git a/frontend/src/app/features/tenant-dashboard/tenant-dashboard.component.scss b/frontend/src/app/features/tenant-dashboard/tenant-dashboard.component.scss new file mode 100644 index 00000000..b62d39a0 --- /dev/null +++ b/frontend/src/app/features/tenant-dashboard/tenant-dashboard.component.scss @@ -0,0 +1,164 @@ +:host { + display: block; +} + +.tenant-dashboard { + max-width: 800px; + margin: 0 auto; + padding: 16px; +} + +// Property Info Card (AC #2) +.property-card { + margin-bottom: 24px; + + .property-icon { + font-size: 40px; + width: 40px; + height: 40px; + color: var(--pm-primary); + display: flex; + align-items: center; + justify-content: center; + } + + mat-card-title { + font-size: 20px; + font-weight: 500; + color: var(--pm-text-primary); + } + + mat-card-subtitle { + font-size: 14px; + color: var(--pm-text-secondary); + } +} + +// Section Title +.section-title { + font-size: 18px; + font-weight: 500; + color: var(--pm-text-primary); + margin: 0 0 16px 0; +} + +// Request List (AC #3) +.request-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +// Request Card +.request-card { + cursor: pointer; + transition: box-shadow 0.2s ease; + + &:hover { + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.12); + } + + &:focus-visible { + outline: 2px solid var(--pm-primary); + outline-offset: 2px; + } + + mat-card-content { + padding: 16px; + } +} + +// Request Header (status + date) +.request-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + flex-wrap: wrap; + gap: 8px; +} + +.request-date { + font-size: 13px; + color: var(--pm-text-secondary); + white-space: nowrap; +} + +// Request Description +.request-description { + font-size: 15px; + line-height: 1.5; + color: var(--pm-text-primary); + margin: 0; +} + +// Status Badge Colors (AC #4) +.status-submitted { + --mdc-chip-elevated-container-color: #e0e0e0; + --mdc-chip-label-text-color: #616161; +} + +.status-in-progress { + --mdc-chip-elevated-container-color: #e3f2fd; + --mdc-chip-label-text-color: #1565c0; +} + +.status-resolved { + --mdc-chip-elevated-container-color: #e8f5e9; + --mdc-chip-label-text-color: #2e7d32; +} + +.status-dismissed { + --mdc-chip-elevated-container-color: #fff3e0; + --mdc-chip-label-text-color: #e65100; +} + +// Dismissal Reason +.dismissal-reason { + display: flex; + align-items: flex-start; + gap: 8px; + margin: 8px 0 0 0; + padding: 8px 12px; + background-color: #fff3e0; + border-radius: 4px; + font-size: 13px; + color: #e65100; + line-height: 1.4; + + .dismissal-icon { + font-size: 18px; + width: 18px; + height: 18px; + flex-shrink: 0; + margin-top: 1px; + } +} + +// Paginator +mat-paginator { + margin-top: 8px; +} + +// Mobile-first responsive (AC #8, NFR-TP7) +@media (max-width: 599px) { + .tenant-dashboard { + padding: 12px; + } + + .property-card { + margin-bottom: 16px; + } + + .request-card mat-card-content { + padding: 12px; + } + + .request-description { + font-size: 14px; + } + + .section-title { + font-size: 16px; + } +} diff --git a/frontend/src/app/features/tenant-dashboard/tenant-dashboard.component.spec.ts b/frontend/src/app/features/tenant-dashboard/tenant-dashboard.component.spec.ts new file mode 100644 index 00000000..6be194e6 --- /dev/null +++ b/frontend/src/app/features/tenant-dashboard/tenant-dashboard.component.spec.ts @@ -0,0 +1,111 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { signal } from '@angular/core'; +import { TenantDashboardComponent } from './tenant-dashboard.component'; +import { TenantDashboardStore } from './stores/tenant-dashboard.store'; + +describe('TenantDashboardComponent', () => { + let component: TenantDashboardComponent; + let fixture: ComponentFixture; + + const mockStore = { + property: signal({ + id: 'prop-1', + name: 'Sunset Apartments', + street: '123 Main St', + city: 'Austin', + state: 'TX', + zipCode: '78701', + }), + requests: signal([ + { + id: 'req-1', + propertyId: 'prop-1', + propertyName: 'Sunset Apartments', + propertyAddress: '123 Main St, Austin, TX 78701', + description: 'Broken window in bedroom', + status: 'Submitted', + dismissalReason: null, + submittedByUserId: 'user-1', + submittedByUserName: 'John', + workOrderId: null, + createdAt: '2026-04-10T00:00:00Z', + updatedAt: '2026-04-10T00:00:00Z', + photos: null, + }, + ]), + isLoading: signal(false), + error: signal(null), + totalCount: signal(1), + page: signal(1), + pageSize: signal(20), + totalPages: signal(1), + isEmpty: signal(false), + propertyAddress: signal('123 Main St, Austin, TX 78701'), + isPropertyLoaded: signal(true), + loadProperty: vi.fn(), + loadRequests: vi.fn(), + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TenantDashboardComponent], + providers: [ + provideRouter([]), + { provide: TenantDashboardStore, useValue: mockStore }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(TenantDashboardComponent); + component = fixture.componentInstance; + }); + + // Task 14.1: component renders property info when loaded + it('renders property info when loaded', () => { + fixture.detectChanges(); + + const propertyCard = fixture.nativeElement.querySelector('[data-testid="property-card"]'); + expect(propertyCard).toBeTruthy(); + expect(propertyCard.textContent).toContain('Sunset Apartments'); + expect(propertyCard.textContent).toContain('123 Main St, Austin, TX 78701'); + }); + + // Task 14.2: component renders request list when loaded + it('renders request list when loaded', () => { + fixture.detectChanges(); + + const requestList = fixture.nativeElement.querySelector('[data-testid="request-list"]'); + expect(requestList).toBeTruthy(); + expect(requestList.textContent).toContain('Broken window in bedroom'); + }); + + // Task 14.3: component shows loading spinner during load + it('shows loading spinner during load', () => { + mockStore.isLoading.set(true); + mockStore.isPropertyLoaded.set(false); + fixture.detectChanges(); + + const spinner = fixture.nativeElement.querySelector('app-loading-spinner'); + expect(spinner).toBeTruthy(); + }); + + // Task 14.4: component shows empty state when no requests + it('shows empty state when no requests', () => { + mockStore.requests.set([]); + mockStore.isEmpty.set(true); + mockStore.isLoading.set(false); + fixture.detectChanges(); + + const emptyState = fixture.nativeElement.querySelector('app-empty-state'); + expect(emptyState).toBeTruthy(); + }); + + // Task 14.5: component shows error card on error + it('shows error card on error', () => { + mockStore.error.set('Failed to load property information.' as any); + fixture.detectChanges(); + + const errorCard = fixture.nativeElement.querySelector('app-error-card'); + expect(errorCard).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/features/tenant-dashboard/tenant-dashboard.component.ts b/frontend/src/app/features/tenant-dashboard/tenant-dashboard.component.ts new file mode 100644 index 00000000..01631299 --- /dev/null +++ b/frontend/src/app/features/tenant-dashboard/tenant-dashboard.component.ts @@ -0,0 +1,88 @@ +import { Component, inject, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; +import { MatCardModule } from '@angular/material/card'; +import { MatIconModule } from '@angular/material/icon'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatListModule } from '@angular/material/list'; +import { MatButtonModule } from '@angular/material/button'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { DatePipe } from '@angular/common'; + +import { TenantDashboardStore } from './stores/tenant-dashboard.store'; +import { LoadingSpinnerComponent } from '../../shared/components/loading-spinner/loading-spinner.component'; +import { ErrorCardComponent } from '../../shared/components/error-card/error-card.component'; +import { EmptyStateComponent } from '../../shared/components/empty-state/empty-state.component'; + +/** + * Tenant Dashboard Component (Story 20.5, AC #2, #3, #4, #8) + * + * Displays tenant's property info and maintenance request list. + * Mobile-first layout with max-width 800px on desktop. + */ +@Component({ + selector: 'app-tenant-dashboard', + standalone: true, + imports: [ + MatCardModule, + MatIconModule, + MatChipsModule, + MatListModule, + MatButtonModule, + MatPaginatorModule, + DatePipe, + LoadingSpinnerComponent, + ErrorCardComponent, + EmptyStateComponent, + ], + templateUrl: './tenant-dashboard.component.html', + styleUrl: './tenant-dashboard.component.scss', +}) +export class TenantDashboardComponent implements OnInit { + readonly store = inject(TenantDashboardStore); + private readonly router = inject(Router); + + ngOnInit(): void { + this.store.loadProperty(); + this.store.loadRequests(); + } + + onPageChange(event: PageEvent): void { + this.store.loadRequests({ page: event.pageIndex + 1, pageSize: event.pageSize }); + } + + viewRequest(id: string): void { + this.router.navigate(['/tenant/requests', id]); + } + + retry(): void { + this.store.loadProperty(); + this.store.loadRequests(); + } + + getStatusColor(status: string): string { + switch (status) { + case 'InProgress': + return 'primary'; + case 'Resolved': + return 'accent'; + case 'Dismissed': + return 'warn'; + default: + return ''; + } + } + + getStatusLabel(status: string): string { + switch (status) { + case 'InProgress': + return 'In Progress'; + default: + return status; + } + } + + truncateDescription(description: string, maxLength = 100): string { + if (description.length <= maxLength) return description; + return description.substring(0, maxLength) + '...'; + } +}