Initial import into Gitea

This commit is contained in:
Grae Jones
2026-03-14 13:50:09 -07:00
parent 8e7e03702e
commit 34c1f09e01
154 changed files with 17666 additions and 1548 deletions

View File

@@ -0,0 +1,64 @@
namespace Registration.Data;
/// <summary>
/// Abstraction over registration data.
/// MockDataService for development, SqlDataService when DB is connected.
/// </summary>
public interface IRegistrationDataService
{
Task<RegistrationListResult> GetPendingAsync(CancellationToken ct = default);
Task<Applicant?> GetByIdAsync(string registrationId, CancellationToken ct = default);
Task<RegistrationResult> RegisterAsync(RegisterRequest request, CancellationToken ct = default);
Task<RegistrationResult> RejectAsync(string registrationId, string? reason, string? rejectedBy, CancellationToken ct = default);
Task<RegistrationResult> CompleteAsync(string registrationId, string? platformClientId, CancellationToken ct = default);
}
// ── Models ──
public sealed class Applicant
{
public string RegistrationId { get; set; } = "";
public string BusinessName { get; set; } = "";
public string? WebsiteUrl { get; set; }
public string? BusinessCategory { get; set; }
public string? BusinessDescription { get; set; }
public string? ContactName { get; set; }
public string? ContactEmail { get; set; }
public string? ContactPhone { get; set; }
public string? EntraSubjectId { get; set; }
public string? ClientCategory { get; set; } // General | Franchisee | Franchisor
public string Status { get; set; } = "Pending"; // Pending, Approved, Rejected
public bool PaymentVerified { get; set; }
public DateTime RegisteredUtc { get; set; }
public DateTime? ReviewedUtc { get; set; }
public string? ReviewedBy { get; set; }
public string? RejectionReason { get; set; }
public string? PlatformClientId { get; set; } // Set after approval
}
public sealed class RegisterRequest
{
public string? BusinessName { get; set; }
public string? WebsiteUrl { get; set; }
public string? BusinessCategory { get; set; }
public string? BusinessDescription { get; set; }
public string? ContactName { get; set; }
public string? ContactEmail { get; set; }
public string? ContactPhone { get; set; }
public string? EntraSubjectId { get; set; }
public string? ClientCategory { get; set; } // General | Franchisee | Franchisor
}
public sealed class RegistrationListResult
{
public bool Ok { get; set; }
public List<Applicant> Applicants { get; set; } = new();
public int TotalCount { get; set; }
}
public sealed class RegistrationResult
{
public bool Ok { get; set; }
public string? Error { get; set; }
public string? RegistrationId { get; set; }
}

View File

@@ -0,0 +1,74 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace Registration.Data;
/// <summary>
/// Real data service backed by dbRegistration.
/// Calls dbo.spRegistration with the standard @action/@rqst/@resp pattern.
///
/// Activate by swapping DI registration in Program.cs:
/// services.AddSingleton&lt;IRegistrationDataService, SqlDataService&gt;();
/// </summary>
public class SqlDataService : IRegistrationDataService
{
private readonly SqlService _sql;
private readonly ILogger<SqlDataService> _log;
private const string Proc = "dbo.spRegistration";
public SqlDataService(SqlService sql, ILogger<SqlDataService> log)
{
_sql = sql;
_log = log;
}
public async Task<RegistrationListResult> GetPendingAsync(CancellationToken ct)
{
var resp = await _sql.ExecProcAsync(Proc, "pending", "{}", ct: ct);
return JsonSerializer.Deserialize<RegistrationListResult>(resp, JsonOpts)
?? new() { Ok = false };
}
public async Task<Applicant?> GetByIdAsync(string registrationId, CancellationToken ct)
{
var rqst = JsonSerializer.Serialize(new { registrationId });
var resp = await _sql.ExecProcAsync(Proc, "get", rqst, ct: ct);
using var doc = JsonDocument.Parse(resp);
if (doc.RootElement.TryGetProperty("applicant", out var app))
return JsonSerializer.Deserialize<Applicant>(app.GetRawText(), JsonOpts);
return null;
}
public async Task<RegistrationResult> RegisterAsync(RegisterRequest request, CancellationToken ct)
{
var rqst = JsonSerializer.Serialize(request, JsonOpts);
var resp = await _sql.ExecProcAsync(Proc, "register", rqst, ct: ct);
return JsonSerializer.Deserialize<RegistrationResult>(resp, JsonOpts)
?? new() { Ok = false, Error = "Deserialization failed" };
}
public async Task<RegistrationResult> RejectAsync(string registrationId, string? reason, string? rejectedBy, CancellationToken ct)
{
var rqst = JsonSerializer.Serialize(new { registrationId, reason, rejectedBy });
var resp = await _sql.ExecProcAsync(Proc, "reject", rqst, ct: ct);
return JsonSerializer.Deserialize<RegistrationResult>(resp, JsonOpts)
?? new() { Ok = false, Error = "Deserialization failed" };
}
public async Task<RegistrationResult> CompleteAsync(string registrationId, string? platformClientId, CancellationToken ct)
{
var rqst = JsonSerializer.Serialize(new { registrationId, platformClientId });
var resp = await _sql.ExecProcAsync(Proc, "complete", rqst, ct: ct);
return JsonSerializer.Deserialize<RegistrationResult>(resp, JsonOpts)
?? new() { Ok = false, Error = "Deserialization failed" };
}
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
}

View File

@@ -0,0 +1,82 @@
using System.Data;
using System.Diagnostics;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace Registration.Data;
/// <summary>
/// Stored procedure executor — same @action/@rqst/@resp OUTPUT pattern
/// used by Gateway and Management.
///
/// Uncomment registration in Program.cs when dbRegistration is ready.
/// Connection string: ConnectionStrings__Sql (env var) or ConnectionStrings:Sql (appsettings).
/// </summary>
public class SqlService
{
private readonly IConfiguration _config;
private readonly ILogger<SqlService> _logger;
public SqlService(IConfiguration config, ILogger<SqlService> logger)
{
_config = config;
_logger = logger;
}
private string GetConnectionString()
{
var cs = _config.GetConnectionString("Sql");
if (string.IsNullOrWhiteSpace(cs))
throw new InvalidOperationException("Missing ConnectionStrings:Sql");
return cs;
}
public async Task<string> ExecProcAsync(
string procName,
string action,
string rqstJson,
int commandTimeoutSeconds = 30,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(procName))
throw new ArgumentException("procName is required.", nameof(procName));
if (string.IsNullOrWhiteSpace(rqstJson))
rqstJson = "{}";
var sw = Stopwatch.StartNew();
try
{
await using var conn = new SqlConnection(GetConnectionString());
await conn.OpenAsync(ct);
await using var cmd = new SqlCommand(procName, conn)
{
CommandType = CommandType.StoredProcedure,
CommandTimeout = commandTimeoutSeconds
};
cmd.Parameters.Add(new SqlParameter("@action", SqlDbType.VarChar, 50) { Value = action });
cmd.Parameters.Add(new SqlParameter("@rqst", SqlDbType.NVarChar, -1) { Value = rqstJson });
var pResp = new SqlParameter("@resp", SqlDbType.NVarChar, -1) { Direction = ParameterDirection.Output };
cmd.Parameters.Add(pResp);
await cmd.ExecuteNonQueryAsync(ct);
var resp = pResp.Value as string ?? "";
sw.Stop();
_logger.LogInformation("SQL ok: {Proc}.{Action} ms={Ms}", procName, action, sw.ElapsedMilliseconds);
return resp;
}
catch (Exception ex)
{
sw.Stop();
_logger.LogError(ex, "SQL error: {Proc}.{Action} ms={Ms}", procName, action, sw.ElapsedMilliseconds);
throw;
}
}
}

View File

@@ -0,0 +1,218 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;
using Registration.Data;
using System.Security.Claims;
using System.Text.Json;
namespace Registration.Functions;
/// <summary>
/// Registration HTTP endpoints.
///
/// ENDPOINTS:
/// POST /api/registration/register — Public; requires Bearer token (CIAM JWT).
/// entraSubjectId is extracted from the validated
/// token — the client never supplies it.
///
/// GET /api/registration/pending — Admin; requires Function key.
/// GET /api/registration/item/{id} — Admin; requires Function key.
/// POST /api/registration/action/{id}/reject — Admin; requires Function key.
/// POST /api/registration/action/{id}/complete — Admin; requires Function key.
/// GET /api/health — Anonymous.
/// </summary>
public class RegistrationFunctions
{
private readonly IRegistrationDataService _data;
private readonly ILogger<RegistrationFunctions> _log;
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
public RegistrationFunctions(IRegistrationDataService data, ILogger<RegistrationFunctions> log)
{
_data = data;
_log = log;
}
// ── Helpers ──────────────────────────────────────────────────────────
/// <summary>
/// Extract the Entra Object ID from a validated CIAM JWT.
/// The OID claim is stable across sessions and providers (Google, Apple, Microsoft).
/// </summary>
private static string? GetEntraSubjectId(ClaimsPrincipal user) =>
user.FindFirst("oid")?.Value
?? user.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier")?.Value
?? user.FindFirst("sub")?.Value;
// ── Public: Register ─────────────────────────────────────────────────
/// <summary>
/// Register a new prospect.
///
/// AuthorizationLevel.Anonymous at the trigger allows any caller with a valid
/// Bearer token — no Function key required from the browser.
/// The [Authorize] attribute ensures the JWT is present and valid before
/// the function body runs. entraSubjectId is extracted from token claims only.
/// </summary>
[Function("Register")]
[Authorize]
public async Task<IActionResult> Register(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "registration/register")] HttpRequest req,
CancellationToken ct)
{
// Identity comes from the validated JWT — never from the request body
var entraSubjectId = GetEntraSubjectId(req.HttpContext.User);
if (string.IsNullOrEmpty(entraSubjectId))
{
_log.LogWarning("[Registration] Register called without a valid token OID claim");
return new UnauthorizedObjectResult(new { ok = false, error = "Valid authentication required" });
}
RegisterRequest? request;
try
{
request = await JsonSerializer.DeserializeAsync<RegisterRequest>(req.Body, JsonOpts, ct);
}
catch
{
return new BadRequestObjectResult(new { ok = false, error = "Invalid JSON body" });
}
if (request == null || string.IsNullOrWhiteSpace(request.BusinessName))
return new BadRequestObjectResult(new { ok = false, error = "businessName is required" });
// Stamp the server-validated identity — client-supplied value is ignored
request.EntraSubjectId = entraSubjectId;
_log.LogInformation("[Registration] POST register: {Name} by entra={EntraId}",
request.BusinessName, entraSubjectId);
var result = await _data.RegisterAsync(request, ct);
if (!result.Ok)
return new BadRequestObjectResult(result);
return new OkObjectResult(result);
}
// ── Admin: List pending ───────────────────────────────────────────────
/// <summary>List all pending registrations. Called by Management API with Function key.</summary>
[Function("GetPending")]
public async Task<IActionResult> GetPending(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "registration/pending")] HttpRequest req,
CancellationToken ct)
{
_log.LogInformation("[Registration] GET pending");
var result = await _data.GetPendingAsync(ct);
return new OkObjectResult(result);
}
// ── Admin: Get by ID ─────────────────────────────────────────────────
/// <summary>Get a single applicant by registration ID. Called by Management API.</summary>
[Function("GetById")]
public async Task<IActionResult> GetById(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "registration/item/{registrationId}")] HttpRequest req,
string registrationId,
CancellationToken ct)
{
_log.LogInformation("[Registration] GET {Id}", registrationId);
var applicant = await _data.GetByIdAsync(registrationId, ct);
if (applicant == null)
return new NotFoundObjectResult(new { ok = false, error = "Registration not found" });
return new OkObjectResult(new { ok = true, applicant });
}
// ── Admin: Reject ────────────────────────────────────────────────────
/// <summary>Reject a pending applicant. Called by Management after admin clicks Reject.</summary>
[Function("Reject")]
public async Task<IActionResult> Reject(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "registration/action/{registrationId}/reject")] HttpRequest req,
string registrationId,
CancellationToken ct)
{
RejectBody? body = null;
try { body = await JsonSerializer.DeserializeAsync<RejectBody>(req.Body, JsonOpts, ct); }
catch { /* optional body */ }
_log.LogInformation("[Registration] POST reject: {Id} reason={Reason}",
registrationId, body?.Reason);
var result = await _data.RejectAsync(registrationId, body?.Reason, body?.RejectedBy, ct);
if (!result.Ok)
return new BadRequestObjectResult(result);
return new OkObjectResult(result);
}
// ── Admin: Complete (approved) ────────────────────────────────────────
/// <summary>
/// Mark a registration as approved/completed.
/// Called by Management after spClientManagement.create succeeds.
/// Receives the platformClientId to link the registration to the platform record.
/// </summary>
[Function("Complete")]
public async Task<IActionResult> Complete(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "registration/action/{registrationId}/complete")] HttpRequest req,
string registrationId,
CancellationToken ct)
{
CompleteBody? body = null;
try { body = await JsonSerializer.DeserializeAsync<CompleteBody>(req.Body, JsonOpts, ct); }
catch { /* optional body */ }
_log.LogInformation("[Registration] POST complete: {Id} → platform client {PlatformId}",
registrationId, body?.PlatformClientId);
var result = await _data.CompleteAsync(registrationId, body?.PlatformClientId, ct);
if (!result.Ok)
return new BadRequestObjectResult(result);
return new OkObjectResult(result);
}
// ── Health ───────────────────────────────────────────────────────────
/// <summary>Health check — anonymous, no auth required.</summary>
[Function("Health")]
public IActionResult Health(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "health")] HttpRequest req)
{
return new OkObjectResult(new
{
ok = true,
service = "registration",
mode = _data is Registration.Mock.MockDataService ? "mock" : "database",
timestamp = DateTime.UtcNow
});
}
}
// ── Request / Response Bodies ─────────────────────────────────────────────
internal sealed class RejectBody
{
public string? Reason { get; set; }
public string? RejectedBy { get; set; }
}
internal sealed class CompleteBody
{
public string? PlatformClientId { get; set; }
}

View File

@@ -0,0 +1,186 @@
using Registration.Data;
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
namespace Registration.Mock;
/// <summary>
/// In-memory mock registration data for development.
/// Persists across requests within a single Function host lifecycle.
/// State resets on cold start (by design — it's mock data).
///
/// Swap to SqlDataService in Program.cs when dbRegistration is ready.
/// </summary>
public class MockDataService : IRegistrationDataService
{
private readonly ConcurrentDictionary<string, Applicant> _store;
private readonly ILogger<MockDataService> _log;
public MockDataService(ILogger<MockDataService> log)
{
_log = log;
_store = new ConcurrentDictionary<string, Applicant>(SeedData());
_log.LogInformation("[Mock] Initialized with {Count} applicants", _store.Count);
}
public Task<RegistrationListResult> GetPendingAsync(CancellationToken ct)
{
var pending = _store.Values
.Where(a => a.Status == "Pending")
.OrderBy(a => a.RegisteredUtc)
.ToList();
return Task.FromResult(new RegistrationListResult
{
Ok = true,
Applicants = pending,
TotalCount = pending.Count
});
}
public Task<Applicant?> GetByIdAsync(string registrationId, CancellationToken ct)
{
_store.TryGetValue(registrationId, out var applicant);
return Task.FromResult(applicant);
}
public Task<RegistrationResult> RegisterAsync(RegisterRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.BusinessName))
return Task.FromResult(new RegistrationResult { Ok = false, Error = "Business name is required" });
// Check for duplicate business name
if (_store.Values.Any(a => string.Equals(a.BusinessName, request.BusinessName, StringComparison.OrdinalIgnoreCase)
&& a.Status != "Rejected"))
return Task.FromResult(new RegistrationResult { Ok = false, Error = "A registration with this name already exists" });
var id = Guid.NewGuid().ToString("D");
var applicant = new Applicant
{
RegistrationId = id,
BusinessName = request.BusinessName!.Trim(),
WebsiteUrl = request.WebsiteUrl,
BusinessCategory = request.BusinessCategory,
BusinessDescription = request.BusinessDescription,
ContactName = request.ContactName,
ContactEmail = request.ContactEmail,
ContactPhone = request.ContactPhone,
EntraSubjectId = request.EntraSubjectId,
Status = "Pending",
PaymentVerified = false,
RegisteredUtc = DateTime.UtcNow
};
_store[id] = applicant;
_log.LogInformation("[Mock] New registration: {Name} ({Id})", applicant.BusinessName, id);
return Task.FromResult(new RegistrationResult { Ok = true, RegistrationId = id });
}
public Task<RegistrationResult> RejectAsync(string registrationId, string? reason, string? rejectedBy, CancellationToken ct)
{
if (!_store.TryGetValue(registrationId, out var applicant))
return Task.FromResult(new RegistrationResult { Ok = false, Error = "Registration not found" });
if (applicant.Status != "Pending")
return Task.FromResult(new RegistrationResult { Ok = false, Error = $"Cannot reject — status is {applicant.Status}" });
applicant.Status = "Rejected";
applicant.RejectionReason = reason;
applicant.ReviewedBy = rejectedBy;
applicant.ReviewedUtc = DateTime.UtcNow;
_log.LogInformation("[Mock] Rejected: {Name} ({Id}) reason={Reason}", applicant.BusinessName, registrationId, reason);
return Task.FromResult(new RegistrationResult { Ok = true, RegistrationId = registrationId });
}
public Task<RegistrationResult> CompleteAsync(string registrationId, string? platformClientId, CancellationToken ct)
{
if (!_store.TryGetValue(registrationId, out var applicant))
return Task.FromResult(new RegistrationResult { Ok = false, Error = "Registration not found" });
if (applicant.Status != "Pending")
return Task.FromResult(new RegistrationResult { Ok = false, Error = $"Cannot complete — status is {applicant.Status}" });
applicant.Status = "Approved";
applicant.PlatformClientId = platformClientId;
applicant.ReviewedUtc = DateTime.UtcNow;
_log.LogInformation("[Mock] Approved: {Name} ({Id}) → platform client {PlatformId}",
applicant.BusinessName, registrationId, platformClientId);
return Task.FromResult(new RegistrationResult { Ok = true, RegistrationId = registrationId });
}
// ── Seed Data ───────────────────────────────────
private static IEnumerable<KeyValuePair<string, Applicant>> SeedData()
{
var applicants = new[]
{
new Applicant
{
RegistrationId = "reg-001",
BusinessName = "Bella's Boutique",
WebsiteUrl = "https://bellasboutique.com",
BusinessCategory = "retail",
BusinessDescription = "Women's fashion and accessories boutique in Orange County. Looking to expand online presence through targeted social and search ads.",
ContactName = "Bella Rodriguez",
ContactEmail = "bella@bellasboutique.com",
ContactPhone = "(714) 555-0142",
EntraSubjectId = "entra-mock-bella-001",
Status = "Pending",
PaymentVerified = true,
RegisteredUtc = DateTime.UtcNow.AddDays(-3)
},
new Applicant
{
RegistrationId = "reg-002",
BusinessName = "Pacific Coast Plumbing",
WebsiteUrl = "https://pacificcoastplumbing.com",
BusinessCategory = "home_services",
BusinessDescription = "Full-service plumbing company serving LA and OC. Need help with Google Local Services ads and Maps visibility.",
ContactName = "Mike Chen",
ContactEmail = "mike@pcplumbing.com",
ContactPhone = "(562) 555-0198",
EntraSubjectId = "entra-mock-mike-002",
Status = "Pending",
PaymentVerified = true,
RegisteredUtc = DateTime.UtcNow.AddDays(-1)
},
new Applicant
{
RegistrationId = "reg-003",
BusinessName = "Sunrise Dental Group",
WebsiteUrl = "https://sunrisedental.care",
BusinessCategory = "healthcare",
BusinessDescription = "Multi-location dental practice. Interested in running awareness campaigns for new patient acquisition across Google and Meta.",
ContactName = "Dr. Sarah Kim",
ContactEmail = "sarah@sunrisedental.care",
ContactPhone = "(949) 555-0267",
EntraSubjectId = "entra-mock-sarah-003",
Status = "Pending",
PaymentVerified = false,
RegisteredUtc = DateTime.UtcNow.AddHours(-6)
},
new Applicant
{
RegistrationId = "reg-004",
BusinessName = "FreshBite Meal Prep",
WebsiteUrl = "https://freshbitemealprep.com",
BusinessCategory = "food_beverage",
BusinessDescription = "Healthy meal prep delivery service. $2k/month budget for Instagram and TikTok ads targeting health-conscious millennials.",
ContactName = "Jordan Williams",
ContactEmail = "jordan@freshbitemealprep.com",
ContactPhone = "(310) 555-0334",
EntraSubjectId = "entra-mock-jordan-004",
Status = "Pending",
PaymentVerified = true,
RegisteredUtc = DateTime.UtcNow.AddHours(-2)
},
};
return applicants.Select(a => new KeyValuePair<string, Applicant>(a.RegistrationId, a));
}
}

36
Registration/Program.cs Normal file
View File

@@ -0,0 +1,36 @@
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Identity.Web;
using Registration.Data;
using Registration.Mock;
var host = new HostBuilder()
.ConfigureFunctionsWebApplication()
.ConfigureServices((context, services) =>
{
services.AddApplicationInsightsTelemetryWorkerService();
services.ConfigureFunctionsApplicationInsights();
// =============================================================
// JWT Authentication — Entra External ID (CIAM)
// Validates Bearer tokens issued by usimclients.ciamlogin.com.
// AzureAd config is in local.settings.json (dev) or Function App
// Configuration (production) using AzureAd__ prefix.
// =============================================================
services.AddAuthentication()
.AddMicrosoftIdentityWebApi(context.Configuration.GetSection("AzureAd"));
services.AddAuthorization();
// =============================================================
// Data layer — SqlDataService backed by dbRegistration.
// Connection string: ConnectionStrings:Sql in local.settings.json
// or the "Sql" connection string in Function App Configuration.
// =============================================================
services.AddSingleton<SqlService>();
services.AddSingleton<IRegistrationDataService, SqlDataService>();
})
.Build();
host.Run();

View File

@@ -0,0 +1,67 @@
{
"$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"resourceGroupName": {
"type": "string",
"defaultValue": "RG-GraeJones",
"metadata": {
"_parameterType": "resourceGroup",
"description": "Name of the resource group for the resource. It is recommended to put resources under same resource group for better tracking."
}
},
"resourceGroupLocation": {
"type": "string",
"defaultValue": "westus",
"metadata": {
"_parameterType": "location",
"description": "Location of the resource group. Resource groups could have different location than resources."
}
},
"resourceLocation": {
"type": "string",
"defaultValue": "[parameters('resourceGroupLocation')]",
"metadata": {
"_parameterType": "location",
"description": "Location of the resource. By default use resource group's location, unless the resource provider is not supported there."
}
}
},
"resources": [
{
"type": "Microsoft.Resources/resourceGroups",
"name": "[parameters('resourceGroupName')]",
"location": "[parameters('resourceGroupLocation')]",
"apiVersion": "2019-10-01"
},
{
"type": "Microsoft.Resources/deployments",
"name": "[concat(parameters('resourceGroupName'), 'Deployment', uniqueString(concat('usim-adp-registration', subscription().subscriptionId)))]",
"resourceGroup": "[parameters('resourceGroupName')]",
"apiVersion": "2019-10-01",
"dependsOn": [
"[parameters('resourceGroupName')]"
],
"properties": {
"mode": "Incremental",
"template": {
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"resources": [
{
"kind": "web",
"name": "usim-adp-registration",
"type": "microsoft.insights/components",
"location": "[parameters('resourceLocation')]",
"properties": {},
"apiVersion": "2015-05-01"
}
]
}
}
}
],
"metadata": {
"_dependencyType": "appInsights.azure"
}
}

View File

@@ -0,0 +1,173 @@
{
"$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"metadata": {
"_dependencyType": "compute.function.linux.appService"
},
"parameters": {
"resourceGroupName": {
"type": "string",
"defaultValue": "RG-GraeJones",
"metadata": {
"description": "Name of the resource group for the resource. It is recommended to put resources under same resource group for better tracking."
}
},
"resourceGroupLocation": {
"type": "string",
"defaultValue": "",
"metadata": {
"description": "Location of the resource group. Resource groups could have different location than resources, however by default we use API versions from latest hybrid profile which support all locations for resource types we support."
}
},
"resourceName": {
"type": "string",
"defaultValue": "usim-adp-registration",
"metadata": {
"description": "Name of the main resource to be created by this template."
}
},
"resourceLocation": {
"type": "string",
"defaultValue": "[parameters('resourceGroupLocation')]",
"metadata": {
"description": "Location of the resource. By default use resource group's location, unless the resource provider is not supported there."
}
}
},
"resources": [
{
"type": "Microsoft.Resources/resourceGroups",
"name": "[parameters('resourceGroupName')]",
"location": "[parameters('resourceGroupLocation')]",
"apiVersion": "2019-10-01"
},
{
"type": "Microsoft.Resources/deployments",
"name": "[concat(parameters('resourceGroupName'), 'Deployment', uniqueString(concat(parameters('resourceName'), subscription().subscriptionId)))]",
"resourceGroup": "[parameters('resourceGroupName')]",
"apiVersion": "2019-10-01",
"dependsOn": [
"[parameters('resourceGroupName')]"
],
"properties": {
"mode": "Incremental",
"expressionEvaluationOptions": {
"scope": "inner"
},
"parameters": {
"resourceGroupName": {
"value": "[parameters('resourceGroupName')]"
},
"resourceGroupLocation": {
"value": "[parameters('resourceGroupLocation')]"
},
"resourceName": {
"value": "[parameters('resourceName')]"
},
"resourceLocation": {
"value": "[parameters('resourceLocation')]"
}
},
"template": {
"$schema": "http://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"resourceGroupName": {
"type": "string"
},
"resourceGroupLocation": {
"type": "string"
},
"resourceName": {
"type": "string"
},
"resourceLocation": {
"type": "string"
}
},
"variables": {
"storage_name": "[toLower(concat('storage', uniqueString(concat(parameters('resourceName'), subscription().subscriptionId))))]",
"appServicePlan_name": "[concat('Plan', uniqueString(concat(parameters('resourceName'), subscription().subscriptionId)))]",
"storage_ResourceId": "[concat('/subscriptions/', subscription().subscriptionId, '/resourceGroups/', parameters('resourceGroupName'), '/providers/Microsoft.Storage/storageAccounts/', variables('storage_name'))]",
"appServicePlan_ResourceId": "[concat('/subscriptions/', subscription().subscriptionId, '/resourceGroups/', parameters('resourceGroupName'), '/providers/Microsoft.Web/serverFarms/', variables('appServicePlan_name'))]",
"function_ResourceId": "[concat('/subscriptions/', subscription().subscriptionId, '/resourceGroups/', parameters('resourceGroupName'), '/providers/Microsoft.Web/sites/', parameters('resourceName'))]"
},
"resources": [
{
"location": "[parameters('resourceLocation')]",
"name": "[parameters('resourceName')]",
"type": "Microsoft.Web/sites",
"apiVersion": "2015-08-01",
"tags": {
"[concat('hidden-related:', variables('appServicePlan_ResourceId'))]": "empty"
},
"dependsOn": [
"[variables('appServicePlan_ResourceId')]",
"[variables('storage_ResourceId')]"
],
"kind": "functionapp",
"properties": {
"name": "[parameters('resourceName')]",
"kind": "functionapp",
"httpsOnly": true,
"reserved": false,
"serverFarmId": "[variables('appServicePlan_ResourceId')]",
"siteConfig": {
"alwaysOn": true,
"linuxFxVersion": "dotnet|3.1"
}
},
"identity": {
"type": "SystemAssigned"
},
"resources": [
{
"name": "appsettings",
"type": "config",
"apiVersion": "2015-08-01",
"dependsOn": [
"[variables('function_ResourceId')]"
],
"properties": {
"AzureWebJobsStorage": "[concat('DefaultEndpointsProtocol=https;AccountName=', variables('storage_name'), ';AccountKey=', listKeys(variables('storage_ResourceId'), '2017-10-01').keys[0].value, ';EndpointSuffix=', 'core.windows.net')]",
"FUNCTIONS_EXTENSION_VERSION": "~3",
"FUNCTIONS_WORKER_RUNTIME": "dotnet"
}
}
]
},
{
"location": "[parameters('resourceGroupLocation')]",
"name": "[variables('storage_name')]",
"type": "Microsoft.Storage/storageAccounts",
"apiVersion": "2017-10-01",
"tags": {
"[concat('hidden-related:', concat('/providers/Microsoft.Web/sites/', parameters('resourceName')))]": "empty"
},
"properties": {
"supportsHttpsTrafficOnly": true
},
"sku": {
"name": "Standard_LRS"
},
"kind": "Storage"
},
{
"location": "[parameters('resourceGroupLocation')]",
"name": "[variables('appServicePlan_name')]",
"type": "Microsoft.Web/serverFarms",
"apiVersion": "2015-02-01",
"kind": "linux",
"properties": {
"name": "[variables('appServicePlan_name')]",
"sku": "Standard",
"workerSizeId": "0",
"reserved": true
}
}
]
}
}
}
]
}

View File

@@ -0,0 +1,70 @@
{
"$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"resourceGroupName": {
"type": "string",
"defaultValue": "RG-GraeJones",
"metadata": {
"_parameterType": "resourceGroup",
"description": "Name of the resource group for the resource. It is recommended to put resources under same resource group for better tracking."
}
},
"resourceGroupLocation": {
"type": "string",
"defaultValue": "westus",
"metadata": {
"_parameterType": "location",
"description": "Location of the resource group. Resource groups could have different location than resources."
}
},
"resourceLocation": {
"type": "string",
"defaultValue": "[parameters('resourceGroupLocation')]",
"metadata": {
"_parameterType": "location",
"description": "Location of the resource. By default use resource group's location, unless the resource provider is not supported there."
}
}
},
"resources": [
{
"type": "Microsoft.Resources/resourceGroups",
"name": "[parameters('resourceGroupName')]",
"location": "[parameters('resourceGroupLocation')]",
"apiVersion": "2019-10-01"
},
{
"type": "Microsoft.Resources/deployments",
"name": "[concat(parameters('resourceGroupName'), 'Deployment', uniqueString(concat('rggraejonesa164', subscription().subscriptionId)))]",
"resourceGroup": "[parameters('resourceGroupName')]",
"apiVersion": "2019-10-01",
"dependsOn": [
"[parameters('resourceGroupName')]"
],
"properties": {
"mode": "Incremental",
"template": {
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"resources": [
{
"sku": {
"name": "Standard_LRS",
"tier": "Standard"
},
"kind": "StorageV2",
"name": "rggraejonesa164",
"type": "Microsoft.Storage/storageAccounts",
"location": "[parameters('resourceLocation')]",
"apiVersion": "2017-10-01"
}
]
}
}
}
],
"metadata": {
"_dependencyType": "storage.azure"
}
}

View File

@@ -0,0 +1,9 @@
{
"profiles": {
"Registration": {
"commandName": "Project",
"commandLineArgs": "--port 7270",
"launchBrowser": false
}
}
}

View File

@@ -0,0 +1,12 @@
{
"dependencies": {
"storage1": {
"type": "storage",
"connectionId": "AzureWebJobsStorage"
},
"appInsights1": {
"type": "appInsights",
"connectionId": "APPLICATIONINSIGHTS_CONNECTION_STRING"
}
}
}

View File

@@ -0,0 +1,11 @@
{
"dependencies": {
"appInsights1": {
"type": "appInsights.sdk"
},
"storage1": {
"type": "storage.emulator",
"connectionId": "AzureWebJobsStorage"
}
}
}

View File

@@ -0,0 +1,14 @@
{
"dependencies": {
"storage1": {
"resourceId": "/subscriptions/[parameters('subscriptionId')]/resourceGroups/[parameters('resourceGroupName')]/providers/Microsoft.Storage/storageAccounts/rggraejonesa164",
"type": "storage.azure",
"connectionId": "AzureWebJobsStorage"
},
"appInsights1": {
"resourceId": "/subscriptions/[parameters('subscriptionId')]/resourceGroups/[parameters('resourceGroupName')]/providers/microsoft.insights/components/usim-adp-registration",
"type": "appInsights.azure",
"connectionId": "APPLICATIONINSIGHTS_CONNECTION_STRING"
}
}
}

91
Registration/README.md Normal file
View File

@@ -0,0 +1,91 @@
# Registration Function
Azure Function (isolated worker, .NET 8) for managing prospect registration in AdPlatform.
## Architecture
```
Prospect → Registration Function → dbRegistration (future)
Admin Panel → Management API → Registration Function (proxy)
→ spClientManagement (approve → dbAdPlatform)
```
Management validates admin sessions, then proxies registration calls to this Function.
The Function never touches `dbAdPlatform`. Management never touches `dbRegistration`.
## Endpoints
| Method | Route | Auth | Description |
|--------|-------|------|-------------|
| GET | `/api/registration/pending` | Function Key | List pending applicants |
| GET | `/api/registration/{id}` | Function Key | Get single applicant |
| POST | `/api/registration/register` | Function Key | New prospect signup |
| POST | `/api/registration/{id}/reject` | Function Key | Reject applicant |
| POST | `/api/registration/{id}/complete` | Function Key | Mark approved (called after platform client created) |
| GET | `/api/registration/health` | Anonymous | Health check |
## Mock Mode (Current)
Starts with 4 realistic test applicants in memory. State persists within a Function host
lifecycle and resets on cold start. No database required.
To switch to mock mode, in `Program.cs`:
```csharp
services.AddSingleton<IRegistrationDataService, MockDataService>();
```
## Database Mode (Future)
When `dbRegistration` is ready:
1. Create the database and run the `spRegistration` stored proc migration
2. Set `ConnectionStrings:Sql` to the registration database connection string
3. In `Program.cs`, swap DI registration:
```csharp
services.AddSingleton<SqlService>();
services.AddSingleton<IRegistrationDataService, SqlDataService>();
```
The `SqlDataService` calls `dbo.spRegistration` with the standard `@action/@rqst/@resp OUTPUT`
pattern used across all AdPlatform services.
## Local Development
```bash
# Requires Azure Functions Core Tools
func start
```
Test with:
```bash
curl http://localhost:7071/api/registration/health
curl http://localhost:7071/api/registration/pending
```
## Deployment
Deploy as an Azure Function App (Consumption or Flex Consumption plan).
After deployment:
1. Copy the Function Key from Azure Portal → Function App → App Keys
2. Set in Management API config:
- `Registration:BaseUrl` = `https://your-function-app.azurewebsites.net/api`
- `Registration:FunctionKey` = `<key from portal>`
These can be set as Azure Container App environment variables:
```
Registration__BaseUrl=https://your-function-app.azurewebsites.net/api
Registration__FunctionKey=<key>
```
## Mock Applicants
The mock data includes 4 test applicants representing the target market
(small businesses with low ad spend thresholds):
| Business | Category | Payment Verified | Days Waiting |
|----------|----------|-----------------|-------------|
| Bella's Boutique | Retail | Yes | 3 |
| Pacific Coast Plumbing | Home Services | Yes | 1 |
| Sunrise Dental Group | Healthcare | No | ~0.25 |
| FreshBite Meal Prep | Food & Beverage | Yes | ~0.08 |

View File

@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
<OutputType>Exe</OutputType>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>Registration</RootNamespace>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.51.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http" Version="3.3.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.7" />
<PackageReference Include="Microsoft.ApplicationInsights.WorkerService" Version="2.22.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.50.0" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
<PackageReference Include="Microsoft.Identity.Web" Version="4.5.0" />
</ItemGroup>
<ItemGroup>
<None Update="host.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="local.settings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
</None>
</ItemGroup>
</Project>

29
Registration/host.json Normal file
View File

@@ -0,0 +1,29 @@
{
"version": "2.0",
"logging": {
"applicationInsights": {
"samplingSettings": {
"isEnabled": true,
"excludedTypes": "Request"
},
"enableLiveMetricsFilters": true
},
"logLevel": {
"default": "Information",
"Registration": "Information"
}
},
"extensions": {
"http": {
"routePrefix": "api",
"cors": {
"allowedOrigins": [
"https://adpregist.usimdev.com",
"http://localhost:3001"
],
"allowedHeaders": [ "*" ],
"allowedMethods": [ "GET", "POST", "OPTIONS" ]
}
}
}
}

View File

@@ -0,0 +1,16 @@
{
"IsEncrypted": false,
"Values": {
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated"
},
"ConnectionStrings": {
"Sql": "Server=usimdev.database.windows.net;Database=dbRegistration;User Id=appAdPlatformReg;Password=YOUR_PASSWORD_HERE;TrustServerCertificate=True;"
},
"AzureAd": {
"Instance": "https://usimclients.ciamlogin.com/",
"TenantId": "891f98f1-ed34-42a1-9b6c-28b0554d92c2",
"ClientId": "154c9111-14a0-4c0f-8132-7bc68254a74e",
"Audience": "154c9111-14a0-4c0f-8132-7bc68254a74e"
}
}