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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,22 +58,20 @@ jobs:
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
cache: true
cache-dependency-path: '**/packages.lock.json'

- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ${{ env.NUGET_PACKAGES }}
key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json', '**/Directory.Packages.props') }}
key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props', '**/*.csproj') }}
restore-keys: |
${{ runner.os }}-nuget-

- name: Restore dependencies
run: dotnet restore Luminous.sln

- name: Build solution
run: dotnet build Luminous.sln --configuration Release --no-restore
run: dotnet build Luminous.sln --configuration Release

- name: Run tests
run: |
Expand Down
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
<PackageVersion Include="Microsoft.Azure.SignalR" Version="1.28.0" />
<PackageVersion Include="Microsoft.Extensions.Azure" Version="1.8.0" />
<PackageVersion Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.3.2" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />

<!-- Identifiers -->
<PackageVersion Include="Nanoid" Version="3.1.0" />
Expand All @@ -41,6 +42,7 @@
<PackageVersion Include="AspNetCore.HealthChecks.AzureServiceBus" Version="9.0.0" />

<!-- Logging -->
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageVersion Include="Serilog.Sinks.ApplicationInsights" Version="4.0.0" />

Expand Down
33 changes: 25 additions & 8 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Luminous Development Roadmap

> **Document Version:** 2.7.0
> **Document Version:** 2.8.0
> **Last Updated:** 2025-12-23
> **Status:** Active
> **TOGAF Phase:** Phase E/F (Opportunities, Solutions & Migration Planning)
Expand Down Expand Up @@ -103,7 +103,7 @@ Phase 6: Intelligence & Ecosystem
| Phase | Name | Focus | Key Deliverables | Status |
|-------|------|-------|------------------|--------|
| **0** | Foundation | Infrastructure | Azure IaC, .NET solution, Angular shell, Passwordless Auth, Local Dev, CI/CD, Docs | ✅ Complete |
| **1** | Core Platform | Multi-tenancy | Family sign-up, device linking, CosmosDB, web MVP | 🔄 In Progress (1.1 Complete) |
| **1** | Core Platform | Multi-tenancy | Family sign-up, device linking, CosmosDB, web MVP | 🔄 In Progress (1.1, 1.2 Complete) |
| **2** | Display & Calendar | Calendar visibility | Display app, calendar integration, SignalR sync | ⬜ Not Started |
| **3** | Native Mobile | Mobile apps | iOS (Swift), Android (Kotlin), push notifications | ⬜ Not Started |
| **4** | Task Management | Chores and routines | Task creation, completion tracking, rewards | ⬜ Not Started |
Expand Down Expand Up @@ -286,13 +286,29 @@ Deliver the multi-tenant platform with user registration, family creation, devic
- *Implemented: ITenantContext and TenantContext services*
- *All queries use familyId partition key; tenant access validated in handlers*

#### 1.2 Device Linking
#### 1.2 Device Linking ✅ COMPLETED

- [x] **1.2.1** Implement link code generation endpoint
- *Implemented: GenerateLinkCodeCommand creates unlinked device with 6-digit code (15-min expiry)*
- *API: POST /api/devices/link-code*
- [x] **1.2.2** Implement link code validation and device registration
- *Implemented: LinkDeviceCommand validates code, links device to family*
- *API: POST /api/devices/link*
- [x] **1.2.3** Implement device token issuance
- *Implemented: LinkDeviceCommand returns LinkedDeviceDto with JWT device token (30-day expiry)*
- *Token includes: device_id, family_id, device_type, device_name claims*
- [x] **1.2.4** Create device management endpoints
- *Implemented: GetDeviceQuery, GetFamilyDevicesQuery, UpdateDeviceCommand, UnlinkDeviceCommand, DeleteDeviceCommand*
- *APIs: GET/PUT/DELETE /api/devices/family/{familyId}/{id}, POST /api/devices/family/{familyId}/{id}/unlink*
- [x] **1.2.5** Implement device heartbeat/status tracking
- *Implemented: RecordHeartbeatCommand updates LastSeenAt, AppVersion; returns DeviceHeartbeatDto*
- *API: POST /api/devices/family/{familyId}/{id}/heartbeat*

- [ ] **1.2.1** Implement link code generation endpoint
- [ ] **1.2.2** Implement link code validation and device registration
- [ ] **1.2.3** Implement device token issuance
- [ ] **1.2.4** Create device management endpoints
- [ ] **1.2.5** Implement device heartbeat/status tracking
**Additional deliverables:**
- [x] Device entity methods: Unlink, Rename, UpdateSettings, Activate, Deactivate
- [x] DeviceSettingsDto, DeviceHeartbeatDto, LinkedDeviceDto DTOs
- [x] Unit tests for Device entity and command handlers
- [x] Authorization: FamilyMember for read, FamilyAdmin for write operations

#### 1.3 Family Member Management

Expand Down Expand Up @@ -706,3 +722,4 @@ These can be developed in parallel after Phase 0:
| 2.5.0 | 2025-12-22 | Luminous Team | Phase 0.5 CI/CD Pipeline completed |
| 2.6.0 | 2025-12-23 | Luminous Team | Phase 0.6 Documentation completed; Phase 0 complete |
| 2.7.0 | 2025-12-23 | Luminous Team | Phase 1.1 Multi-Tenant API completed |
| 2.8.0 | 2025-12-23 | Luminous Team | Phase 1.2 Device Linking completed |
165 changes: 162 additions & 3 deletions src/Luminous.Api/Controllers/DevicesController.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Luminous.Application.DTOs;
using Luminous.Application.Features.Devices.Commands;
using Luminous.Application.Features.Devices.Queries;
using Luminous.Domain.Enums;
using Luminous.Shared.Contracts;
using Microsoft.AspNetCore.Authorization;
Expand Down Expand Up @@ -35,13 +36,13 @@ public async Task<ActionResult<ApiResponse<DeviceLinkCodeDto>>> GenerateLinkCode
/// Links a device to a family using a link code.
/// </summary>
/// <param name="request">The link request.</param>
/// <returns>The linked device.</returns>
/// <returns>The linked device with authentication token.</returns>
[HttpPost("link")]
[Authorize]
[ProducesResponseType(typeof(ApiResponse<DeviceDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<LinkedDeviceDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ActionResult<ApiResponse<DeviceDto>>> LinkDevice([FromBody] LinkDeviceRequest request)
public async Task<ActionResult<ApiResponse<LinkedDeviceDto>>> LinkDevice([FromBody] LinkDeviceRequest request)
{
var command = new LinkDeviceCommand
{
Expand All @@ -52,6 +53,147 @@ public async Task<ActionResult<ApiResponse<DeviceDto>>> LinkDevice([FromBody] Li
var result = await Mediator.Send(command);
return OkResponse(result);
}

/// <summary>
/// Gets a device by ID.
/// </summary>
/// <param name="familyId">The family ID.</param>
/// <param name="id">The device ID.</param>
/// <returns>The device.</returns>
[HttpGet("family/{familyId}/{id}")]
[Authorize(Policy = "FamilyMember")]
[ProducesResponseType(typeof(ApiResponse<DeviceDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ActionResult<ApiResponse<DeviceDto>>> GetDevice(string familyId, string id)
{
var query = new GetDeviceQuery
{
DeviceId = id,
FamilyId = familyId
};
var result = await Mediator.Send(query);
return OkResponse(result);
}

/// <summary>
/// Gets all devices for a family.
/// </summary>
/// <param name="familyId">The family ID.</param>
/// <param name="activeOnly">If true, only returns active devices.</param>
/// <returns>The list of devices.</returns>
[HttpGet("family/{familyId}")]
[Authorize(Policy = "FamilyMember")]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<DeviceDto>>), StatusCodes.Status200OK)]
public async Task<ActionResult<ApiResponse<IReadOnlyList<DeviceDto>>>> GetFamilyDevices(
string familyId,
[FromQuery] bool? activeOnly = null)
{
var query = new GetFamilyDevicesQuery
{
FamilyId = familyId,
ActiveOnly = activeOnly
};
var result = await Mediator.Send(query);
return OkResponse(result);
}

/// <summary>
/// Updates a device.
/// </summary>
/// <param name="familyId">The family ID.</param>
/// <param name="id">The device ID.</param>
/// <param name="request">The update request.</param>
/// <returns>The updated device.</returns>
[HttpPut("family/{familyId}/{id}")]
[Authorize(Policy = "FamilyAdmin")]
[ProducesResponseType(typeof(ApiResponse<DeviceDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ActionResult<ApiResponse<DeviceDto>>> UpdateDevice(
string familyId,
string id,
[FromBody] UpdateDeviceRequest request)
{
var command = new UpdateDeviceCommand
{
DeviceId = id,
FamilyId = familyId,
Name = request.Name,
Settings = request.Settings
};
var result = await Mediator.Send(command);
return OkResponse(result);
}

/// <summary>
/// Unlinks a device from a family.
/// </summary>
/// <param name="familyId">The family ID.</param>
/// <param name="id">The device ID.</param>
/// <returns>No content on success.</returns>
[HttpPost("family/{familyId}/{id}/unlink")]
[Authorize(Policy = "FamilyAdmin")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<IActionResult> UnlinkDevice(string familyId, string id)
{
var command = new UnlinkDeviceCommand
{
DeviceId = id,
FamilyId = familyId
};
await Mediator.Send(command);
return NoContent();
}

/// <summary>
/// Deletes a device.
/// </summary>
/// <param name="familyId">The family ID.</param>
/// <param name="id">The device ID.</param>
/// <returns>No content on success.</returns>
[HttpDelete("family/{familyId}/{id}")]
[Authorize(Policy = "FamilyAdmin")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteDevice(string familyId, string id)
{
var command = new DeleteDeviceCommand
{
DeviceId = id,
FamilyId = familyId
};
await Mediator.Send(command);
return NoContent();
}

/// <summary>
/// Records a device heartbeat (updates last seen timestamp).
/// </summary>
/// <param name="familyId">The family ID.</param>
/// <param name="id">The device ID.</param>
/// <param name="request">The heartbeat request.</param>
/// <returns>The heartbeat response.</returns>
[HttpPost("family/{familyId}/{id}/heartbeat")]
[Authorize(Policy = "FamilyMember")]
[ProducesResponseType(typeof(ApiResponse<DeviceHeartbeatDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ActionResult<ApiResponse<DeviceHeartbeatDto>>> RecordHeartbeat(
string familyId,
string id,
[FromBody] RecordHeartbeatRequest request)
{
var command = new RecordHeartbeatCommand
{
DeviceId = id,
FamilyId = familyId,
AppVersion = request.AppVersion
};
var result = await Mediator.Send(command);
return OkResponse(result);
}
}

/// <summary>
Expand All @@ -72,3 +214,20 @@ public record LinkDeviceRequest
public string FamilyId { get; init; } = string.Empty;
public string DeviceName { get; init; } = string.Empty;
}

/// <summary>
/// Request to update a device.
/// </summary>
public record UpdateDeviceRequest
{
public string? Name { get; init; }
public DeviceSettingsDto? Settings { get; init; }
}

/// <summary>
/// Request to record a device heartbeat.
/// </summary>
public record RecordHeartbeatRequest
{
public string? AppVersion { get; init; }
}
2 changes: 1 addition & 1 deletion src/Luminous.Api/Middleware/TenantValidationMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ public async Task InvokeAsync(HttpContext context, ITenantContext tenantContext)
context.Response.StatusCode = StatusCodes.Status403Forbidden;
context.Response.ContentType = "application/json";

var response = ApiResponse<object>.Failure(
var response = ApiResponse<object>.Fail(
"TENANT_ACCESS_DENIED",
"You do not have access to this family's data.");

Expand Down
23 changes: 23 additions & 0 deletions src/Luminous.Application/DTOs/DeviceDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,26 @@ public sealed record DeviceLinkCodeDto
public string LinkCode { get; init; } = string.Empty;
public DateTime ExpiresAt { get; init; }
}

/// <summary>
/// Data transfer object for device heartbeat response.
/// </summary>
public sealed record DeviceHeartbeatDto
{
public string DeviceId { get; init; } = string.Empty;
public DateTime LastSeenAt { get; init; }
public bool IsActive { get; init; }
public string? AppVersion { get; init; }
}

/// <summary>
/// Data transfer object for linked device with token response.
/// </summary>
public sealed record LinkedDeviceDto
{
public DeviceDto Device { get; init; } = new();
public string AccessToken { get; init; } = string.Empty;
public string? RefreshToken { get; init; }
public string TokenType { get; init; } = "Bearer";
public int ExpiresIn { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using FluentValidation;
using Luminous.Application.Common.Exceptions;
using Luminous.Domain.Interfaces;
using MediatR;

namespace Luminous.Application.Features.Devices.Commands;

/// <summary>
/// Command to delete a device.
/// </summary>
public sealed record DeleteDeviceCommand : IRequest<Unit>
{
public string DeviceId { get; init; } = string.Empty;
public string FamilyId { get; init; } = string.Empty;
}

/// <summary>
/// Validator for DeleteDeviceCommand.
/// </summary>
public sealed class DeleteDeviceCommandValidator : AbstractValidator<DeleteDeviceCommand>
{
public DeleteDeviceCommandValidator()
{
RuleFor(x => x.DeviceId)
.NotEmpty().WithMessage("Device ID is required.");

RuleFor(x => x.FamilyId)
.NotEmpty().WithMessage("Family ID is required.");
}
}

/// <summary>
/// Handler for DeleteDeviceCommand.
/// </summary>
public sealed class DeleteDeviceCommandHandler : IRequestHandler<DeleteDeviceCommand, Unit>
{
private readonly IUnitOfWork _unitOfWork;

public DeleteDeviceCommandHandler(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}

public async Task<Unit> Handle(DeleteDeviceCommand request, CancellationToken cancellationToken)
{
var device = await _unitOfWork.Devices.GetByIdAsync(request.DeviceId, request.FamilyId, cancellationToken)
?? throw new NotFoundException("Device", request.DeviceId);

await _unitOfWork.Devices.DeleteAsync(device, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);

return Unit.Value;
}
}
Loading