diff --git a/Server/SchoolBusAPI/Authorization/MigrationAuthorizationAttribute.cs b/Server/SchoolBusAPI/Authorization/MigrationAuthorizationAttribute.cs
new file mode 100644
index 000000000..fe4fa74dd
--- /dev/null
+++ b/Server/SchoolBusAPI/Authorization/MigrationAuthorizationAttribute.cs
@@ -0,0 +1,15 @@
+using Microsoft.AspNetCore.Mvc;
+
+namespace SchoolBusAPI.Authorization
+{
+ ///
+ /// Protects migration endpoints: allow if X-Migration-Api-Key matches config, or JWT user has required permissions.
+ ///
+ public class MigrationAuthorizationAttribute : TypeFilterAttribute
+ {
+ public MigrationAuthorizationAttribute(params string[] permissions) : base(typeof(MigrationAuthorizationFilter))
+ {
+ Arguments = new object[] { new PermissionRequirement(permissions) };
+ }
+ }
+}
diff --git a/Server/SchoolBusAPI/Authorization/MigrationAuthorizationFilter.cs b/Server/SchoolBusAPI/Authorization/MigrationAuthorizationFilter.cs
new file mode 100644
index 000000000..a306d0d61
--- /dev/null
+++ b/Server/SchoolBusAPI/Authorization/MigrationAuthorizationFilter.cs
@@ -0,0 +1,65 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Filters;
+using Microsoft.Extensions.Configuration;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace SchoolBusAPI.Authorization
+{
+ ///
+ /// Allows migration endpoints via API key (no user in legacy DB) or JWT user with required permissions.
+ ///
+ public class MigrationAuthorizationFilter : IAsyncAuthorizationFilter
+ {
+ public const string MigrationApiKeyHeaderName = "X-Migration-Api-Key";
+ public const string ConfigKey = "Migration:ApiKey";
+
+ private readonly IConfiguration _configuration;
+ private readonly IAuthorizationService _authService;
+ private readonly PermissionRequirement _requiredPermissions;
+
+ public MigrationAuthorizationFilter(
+ IConfiguration configuration,
+ IAuthorizationService authService,
+ PermissionRequirement requiredPermissions)
+ {
+ _configuration = configuration;
+ _authService = authService;
+ _requiredPermissions = requiredPermissions;
+ }
+
+ public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
+ {
+ var apiKeyFromConfig = _configuration[ConfigKey]?.Trim();
+ var apiKeyFromRequest = context.HttpContext.Request.Headers[MigrationApiKeyHeaderName].FirstOrDefault()?.Trim();
+
+ if (!string.IsNullOrEmpty(apiKeyFromConfig) &&
+ !string.IsNullOrEmpty(apiKeyFromRequest) &&
+ apiKeyFromConfig.Equals(apiKeyFromRequest, System.StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ var result = await _authService.AuthorizeAsync(
+ context.HttpContext.User,
+ context.ActionDescriptor.DisplayName,
+ _requiredPermissions);
+
+ if (!result.Succeeded)
+ {
+ var problem = new ValidationProblemDetails()
+ {
+ Type = "https://sb.bc.gov.ca/exception",
+ Title = "Access denied",
+ Status = StatusCodes.Status401Unauthorized,
+ Detail = "Valid X-Migration-Api-Key header or JWT with permission required.",
+ Instance = context.HttpContext.Request.Path
+ };
+ problem.Extensions.Add("traceId", context.HttpContext.TraceIdentifier);
+ context.Result = new UnauthorizedObjectResult(problem);
+ }
+ }
+ }
+}
diff --git a/Server/SchoolBusAPI/Controllers/MigrationController.cs b/Server/SchoolBusAPI/Controllers/MigrationController.cs
new file mode 100644
index 000000000..d602c885d
--- /dev/null
+++ b/Server/SchoolBusAPI/Controllers/MigrationController.cs
@@ -0,0 +1,97 @@
+/*
+ * Migration export API for moving data to the new system.
+ * Consumed by Hangfire jobs in the new app via HTTP. Supports pagination for large sets.
+ * Access: valid X-Migration-Api-Key header (when Migration:ApiKey is set) OR JWT user with required permission.
+ */
+
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using SchoolBusAPI.Authorization;
+using SchoolBusAPI.Services;
+
+namespace SchoolBusAPI.Controllers
+{
+ ///
+ /// Endpoints that return full entity data for migration to the new system
+ ///
+ [ApiVersion("1.0")]
+ [ApiController]
+ [AllowAnonymous]
+ public class MigrationController : ControllerBase
+ {
+ private readonly IMigrationService _migrationService;
+
+ public MigrationController(IMigrationService migrationService)
+ {
+ _migrationService = migrationService;
+ }
+
+ ///
+ /// Full inspection records with SchoolBus and Inspector. Use skip/take for batching.
+ ///
+ /// Number of records to skip (optional)
+ /// Max records to return (optional)
+ [HttpGet]
+ [Route("/api/migration/inspections")]
+ [MigrationAuthorization(Permissions.SchoolBusRead)]
+ public IActionResult GetInspections([FromQuery] int? skip, [FromQuery] int? take)
+ {
+ return _migrationService.GetInspections(skip, take);
+ }
+
+ ///
+ /// Full school bus records with Notes, Attachments, History, CCWData, and related entities. Use skip/take for batching.
+ ///
+ [HttpGet]
+ [Route("/api/migration/schoolbuses")]
+ [MigrationAuthorization(Permissions.SchoolBusRead)]
+ public IActionResult GetSchoolBuses([FromQuery] int? skip, [FromQuery] int? take)
+ {
+ return _migrationService.GetSchoolBuses(skip, take);
+ }
+
+ ///
+ /// Full school bus owner records with Contacts, Notes, Attachments, History. Use skip/take for batching.
+ ///
+ [HttpGet]
+ [Route("/api/migration/schoolbusowners")]
+ [MigrationAuthorization(Permissions.OwnerRead)]
+ public IActionResult GetSchoolBusOwners([FromQuery] int? skip, [FromQuery] int? take)
+ {
+ return _migrationService.GetSchoolBusOwners(skip, take);
+ }
+
+ ///
+ /// All school districts (reference data, typically small set)
+ ///
+ [HttpGet]
+ [Route("/api/migration/schooldistricts")]
+ [MigrationAuthorization(Permissions.CodeRead)]
+ public IActionResult GetSchoolDistricts()
+ {
+ return _migrationService.GetSchoolDistricts();
+ }
+
+ ///
+ /// All service areas with District (reference data)
+ ///
+ [HttpGet]
+ [Route("/api/migration/serviceareas")]
+ [MigrationAuthorization(Permissions.CodeRead)]
+ public IActionResult GetServiceAreas()
+ {
+ return _migrationService.GetServiceAreas();
+ }
+
+ ///
+ /// Full contact records with SchoolBusOwner. Use skip/take for batching.
+ ///
+ [HttpGet]
+ [Route("/api/migration/contacts")]
+ [MigrationAuthorization(Permissions.OwnerRead)]
+ public IActionResult GetContacts([FromQuery] int? skip, [FromQuery] int? take)
+ {
+ return _migrationService.GetContacts(skip, take);
+ }
+ }
+}
diff --git a/Server/SchoolBusAPI/Extensions/ServiceCollectionExtensions.cs b/Server/SchoolBusAPI/Extensions/ServiceCollectionExtensions.cs
index e0ce3e38a..d97cd0fea 100644
--- a/Server/SchoolBusAPI/Extensions/ServiceCollectionExtensions.cs
+++ b/Server/SchoolBusAPI/Extensions/ServiceCollectionExtensions.cs
@@ -1,4 +1,4 @@
-/*
+/*
* REST API Documentation for Schoolbus
*
* This project is to replace the existing permitting and inspection scheduling functionality in AVIS such that the mainframe application can be retired.
@@ -49,6 +49,7 @@ public static IServiceCollection RegisterApplicationServices(this IServiceCollec
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
var mappingConfig = new MapperConfiguration(cfg =>
{
diff --git a/Server/SchoolBusAPI/Services/IMigrationService.cs b/Server/SchoolBusAPI/Services/IMigrationService.cs
new file mode 100644
index 000000000..6413ca570
--- /dev/null
+++ b/Server/SchoolBusAPI/Services/IMigrationService.cs
@@ -0,0 +1,33 @@
+/*
+ * Migration export endpoints for moving data to new system.
+ * Used by Hangfire in the new app to pull full entity sets.
+ */
+
+using Microsoft.AspNetCore.Mvc;
+
+namespace SchoolBusAPI.Services
+{
+ ///
+ /// Service that returns full entity data for migration export
+ ///
+ public interface IMigrationService
+ {
+ /// All inspections with SchoolBus and Inspector
+ IActionResult GetInspections(int? skip, int? take);
+
+ /// All school buses with Notes, Attachments, History, CCWData, related entities
+ IActionResult GetSchoolBuses(int? skip, int? take);
+
+ /// All school bus owners with Contacts, Notes, Attachments, History
+ IActionResult GetSchoolBusOwners(int? skip, int? take);
+
+ /// All school districts
+ IActionResult GetSchoolDistricts();
+
+ /// All service areas with District
+ IActionResult GetServiceAreas();
+
+ /// All contacts with SchoolBusOwner
+ IActionResult GetContacts(int? skip, int? take);
+ }
+}
diff --git a/Server/SchoolBusAPI/Services/MigrationService.cs b/Server/SchoolBusAPI/Services/MigrationService.cs
new file mode 100644
index 000000000..bb3051149
--- /dev/null
+++ b/Server/SchoolBusAPI/Services/MigrationService.cs
@@ -0,0 +1,117 @@
+/*
+ * Migration export implementation. Read-only, AsNoTracking for bulk export.
+ */
+
+using System.Linq;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using SchoolBusAPI.Models;
+
+namespace SchoolBusAPI.Services
+{
+ ///
+ /// Exports full entity sets for migration; read-only, no tracking
+ ///
+ public class MigrationService : IMigrationService
+ {
+ private readonly DbAppContext _context;
+
+ public MigrationService(DbAppContext context)
+ {
+ _context = context;
+ }
+
+ ///
+ public IActionResult GetInspections(int? skip, int? take)
+ {
+ var query = _context.Inspections
+ .AsNoTracking()
+ .Include(x => x.SchoolBus)
+ .Include(x => x.Inspector)
+ .OrderBy(x => x.Id);
+
+ var list = ApplyPagination(query, skip, take).ToList();
+ return new ObjectResult(list);
+ }
+
+ ///
+ public IActionResult GetSchoolBuses(int? skip, int? take)
+ {
+ var query = _context.SchoolBuss
+ .AsNoTracking()
+ .Include(x => x.HomeTerminalCity)
+ .Include(x => x.SchoolDistrict)
+ .Include(x => x.SchoolBusOwner).ThenInclude(o => o.PrimaryContact)
+ .Include(x => x.District).ThenInclude(d => d.Region)
+ .Include(x => x.Inspector)
+ .Include(x => x.CCWData)
+ .Include(x => x.Notes)
+ .Include(x => x.Attachments)
+ .Include(x => x.History)
+ .Include(x => x.CCWNotifications)
+ .OrderBy(x => x.Id);
+
+ var list = ApplyPagination(query, skip, take).ToList();
+ return new ObjectResult(list);
+ }
+
+ ///
+ public IActionResult GetSchoolBusOwners(int? skip, int? take)
+ {
+ var query = _context.SchoolBusOwners
+ .AsNoTracking()
+ .Include(x => x.PrimaryContact)
+ .Include(x => x.District)
+ .Include(x => x.Contacts)
+ .Include(x => x.Notes)
+ .Include(x => x.Attachments)
+ .Include(x => x.History)
+ .OrderBy(x => x.Id);
+
+ var list = ApplyPagination(query, skip, take).ToList();
+ return new ObjectResult(list);
+ }
+
+ ///
+ public IActionResult GetSchoolDistricts()
+ {
+ var list = _context.SchoolDistricts
+ .AsNoTracking()
+ .OrderBy(x => x.Id)
+ .ToList();
+ return new ObjectResult(list);
+ }
+
+ ///
+ public IActionResult GetServiceAreas()
+ {
+ var list = _context.ServiceAreas
+ .AsNoTracking()
+ .Include(x => x.District)
+ .OrderBy(x => x.Id)
+ .ToList();
+ return new ObjectResult(list);
+ }
+
+ ///
+ public IActionResult GetContacts(int? skip, int? take)
+ {
+ var query = _context.Contacts
+ .AsNoTracking()
+ .Include(x => x.SchoolBusOwner)
+ .OrderBy(x => x.Id);
+
+ var list = ApplyPagination(query, skip, take).ToList();
+ return new ObjectResult(list);
+ }
+
+ private static IQueryable ApplyPagination(IQueryable query, int? skip, int? take)
+ {
+ if (skip.HasValue && skip.Value > 0)
+ query = query.Skip(skip.Value);
+ if (take.HasValue && take.Value > 0)
+ query = query.Take(take.Value);
+ return query;
+ }
+ }
+}
diff --git a/Server/SchoolBusAPI/appsettings.json b/Server/SchoolBusAPI/appsettings.json
index 9354a3da8..f7384b598 100644
--- a/Server/SchoolBusAPI/appsettings.json
+++ b/Server/SchoolBusAPI/appsettings.json
@@ -51,6 +51,9 @@
"CCW_BATCH_APP_ID": "batchAppId",
"CCW_PASSWORD": "password",
"PDF_SERVICE_NAME": "http://pdf",
+ "Migration": {
+ "ApiKey": "CVCSVSBC_3_5_26"
+ },
"ENABLE_HANGFIRE_CREATE": "Y",
"ENABLE_HANGFIRE_UPDATE": "Y",
"SMTP_SERVER": "apps.smtp.gov.bc.ca",