Skip to content
Draft
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
9 changes: 8 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,11 @@ CodeCoverage/
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
nunit-*.xml
# Playwright test artifacts
tests/KulaHub.PlaywrightTests/node_modules/
tests/KulaHub.PlaywrightTests/test-results/
tests/KulaHub.PlaywrightTests/playwright-report/

# Playwright CLI session files
.playwright-cli/
15 changes: 15 additions & 0 deletions docs/data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,21 @@ Represents rules for mirroring a form from one client/form type combination to a
| TargetPlaceholderOrganisationId | int | No | FK → Organisations.OrganisationId | | Placeholder organisation to assign mirrored forms to |
| IsActive | bit | No | | 1 | Enables or disables the mirroring rule |

### Feedback
Stores feedback submitted via the Feedback page form.

| Column | Type | Nullable | Key | Default | Notes |
|------|------|------|------|------|------|
| FeedbackId | int | No | PK | IDENTITY(1,1) | Primary key |
| Name | nvarchar(100) | No | | | Submitter's name |
| Email | nvarchar(200) | No | | | Submitter's email address |
| Comments | nvarchar(max) | No | | | Free-text feedback comments |
| CreatedUtc | datetime2 | No | | | When the feedback was submitted |
| CreatedBy | nvarchar(100) | No | | | Origin of submission |
| ModifiedUtc | datetime2 | Yes | | | |
| ModifiedBy | nvarchar(100) | Yes | | | |
| DeletedUtc | datetime2 | Yes | | | Soft delete timestamp |

### IntegrationInbox
For storing details of changes to rows in certain tables that can be read by a background process to maybe call 3rd party APIs with the changes.

Expand Down
33 changes: 33 additions & 0 deletions src/KulaHub.Api/FeedbackEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using System.ComponentModel.DataAnnotations;
using KulaHub.Data;

namespace KulaHub.Api;

public static class FeedbackEndpoints
{
public static IEndpointRouteBuilder MapFeedbackEndpoints(this IEndpointRouteBuilder endpoints)
{
endpoints.MapPost("/api/feedback", async (SubmitFeedbackBody body, IKulaHubCrmService crmService, CancellationToken cancellationToken) =>
{
try
{
var result = await crmService.SubmitFeedbackAsync(
new SubmitFeedbackCommand(body.Name, body.Email, body.Comments),
cancellationToken);

return Results.Created($"/api/feedback/{result.FeedbackId}", result);
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { message = ex.Message });
}
});

return endpoints;
}

public sealed record SubmitFeedbackBody(
[property: Required] string Name,
[property: Required, EmailAddress] string Email,
[property: Required] string Comments);
}
1 change: 1 addition & 0 deletions src/KulaHub.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,6 @@
.WithName("GetHealth");

app.MapContactEndpoints();
app.MapFeedbackEndpoints();

app.Run();
5 changes: 5 additions & 0 deletions src/KulaHub.Data/CrmContracts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ public sealed record CreateNoteResult(int NoteId);

public sealed record CreateFormResult(int FormId);

public sealed record SubmitFeedbackCommand(string Name, string Email, string Comments);

public sealed record SubmitFeedbackResult(int FeedbackId);

public interface IKulaHubCrmService
{
Task<IReadOnlyList<ClientLookupDto>> GetClientsAsync(CancellationToken cancellationToken = default);
Expand All @@ -93,4 +97,5 @@ public interface IKulaHubCrmService
Task<CreateContactResult> CreateContactAsync(CreateContactCommand command, OriginType originType, CancellationToken cancellationToken = default);
Task<CreateNoteResult> AddNoteAsync(AddNoteCommand command, OriginType originType, CancellationToken cancellationToken = default);
Task<CreateFormResult> CreateContactFormAsync(CreateContactFormCommand command, OriginType originType, CancellationToken cancellationToken = default);
Task<SubmitFeedbackResult> SubmitFeedbackAsync(SubmitFeedbackCommand command, CancellationToken cancellationToken = default);
}
14 changes: 14 additions & 0 deletions src/KulaHub.Data/Entities/Feedback.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace KulaHub.Data.Entities;

public sealed class Feedback
{
public int FeedbackId { get; set; }
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string Comments { get; set; } = string.Empty;
public DateTime CreatedUtc { get; set; }
public string CreatedBy { get; set; } = string.Empty;
public DateTime? ModifiedUtc { get; set; }
public string? ModifiedBy { get; set; }
public DateTime? DeletedUtc { get; set; }
}
1 change: 1 addition & 0 deletions src/KulaHub.Data/KulaHub.Data.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.5" />
</ItemGroup>

Expand Down
37 changes: 37 additions & 0 deletions src/KulaHub.Data/KulaHubCrmService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,43 @@ public async Task<CreateFormResult> CreateContactFormAsync(CreateContactFormComm
return new CreateFormResult(form.FormId);
}

public async Task<SubmitFeedbackResult> SubmitFeedbackAsync(SubmitFeedbackCommand command, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(command.Name))
{
throw new InvalidOperationException("Feedback requires a name.");
}

if (string.IsNullOrWhiteSpace(command.Email))
{
throw new InvalidOperationException("Feedback requires an email address.");
}

if (string.IsNullOrWhiteSpace(command.Comments))
{
throw new InvalidOperationException("Feedback requires comments.");
}

var createdUtc = DateTime.UtcNow;

await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);

var feedback = new Entities.Feedback
{
Name = command.Name.Trim(),
Email = command.Email.Trim(),
Comments = command.Comments.Trim(),
CreatedUtc = createdUtc,
CreatedBy = "FeedbackForm"
};

dbContext.Feedback.Add(feedback);
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);

return new SubmitFeedbackResult(feedback.FeedbackId);
}

private void AddInboxEntry(int clientId, string entityType, string eventType, string changeType, object payload, OriginType originType, DateTime receivedUtc, string? sourceSystemKey = null)
{
var traceContext = GetTraceContext();
Expand Down
11 changes: 11 additions & 0 deletions src/KulaHub.Data/KulaHubDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public sealed class KulaHubDbContext(DbContextOptions<KulaHubDbContext> options)
public DbSet<Form> Forms => Set<Form>();
public DbSet<IntegrationInboxEntry> IntegrationInbox => Set<IntegrationInboxEntry>();
public DbSet<IntegrationDispatchEntry> IntegrationDispatch => Set<IntegrationDispatchEntry>();
public DbSet<Feedback> Feedback => Set<Feedback>();

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
Expand Down Expand Up @@ -153,5 +154,15 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
entity.Property(x => x.ExternalEntityId).HasMaxLength(100);
entity.Property(x => x.DispatchTarget).HasMaxLength(200);
});

modelBuilder.Entity<Feedback>(entity =>
{
entity.ToTable("Feedback", "dbo");
entity.HasKey(x => x.FeedbackId);
entity.Property(x => x.Name).HasMaxLength(100).IsRequired();
entity.Property(x => x.Email).HasMaxLength(200).IsRequired();
entity.Property(x => x.CreatedBy).HasMaxLength(100);
entity.Property(x => x.ModifiedBy).HasMaxLength(100);
});
}
}
13 changes: 12 additions & 1 deletion src/KulaHub.Data/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,18 @@ public static IServiceCollection AddKulaHubData(this IServiceCollection services
"A database connection string named 'KulaHubDatabase' or the KULAHUB_DATABASE_CONNECTION_STRING setting is required.");
}

services.AddDbContext<KulaHubDbContext>(options => options.UseSqlServer(connectionString));
services.AddDbContext<KulaHubDbContext>(options =>
{
if (connectionString.TrimStart().StartsWith("Data Source=", StringComparison.OrdinalIgnoreCase))
{
options.UseSqlite(connectionString);
}
else
{
options.UseSqlServer(connectionString);
}
});

services.AddScoped<IKulaHubCrmService, KulaHubCrmService>();

return services;
Expand Down
54 changes: 54 additions & 0 deletions src/KulaHub.Web/Pages/Feedback.cshtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
@page
@model KulaHub.Web.Pages.FeedbackModel
@{
ViewData["Title"] = "Feedback";
}

<div class="page-header">
<div>
<p class="eyebrow">KulaHub CRM</p>
<h1>Feedback</h1>
<p class="lede">We value your feedback. Please fill in the form below and submit your comments.</p>
</div>
</div>

@if (Model.Submitted)
{
<div class="alert alert-success" role="alert">
Thank you for your feedback!
</div>
}
else
{
<div class="row g-4">
<div class="col-lg-8">
<div class="panel">
<form method="post">
<div asp-validation-summary="ModelOnly" class="text-danger mb-3"></div>
<div class="mb-3">
<label asp-for="Input.Name" class="form-label"></label>
<input asp-for="Input.Name" class="form-control" />
<span asp-validation-for="Input.Name" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="Input.Email" class="form-label"></label>
<input asp-for="Input.Email" class="form-control" type="email" />
<span asp-validation-for="Input.Email" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="Input.Comments" class="form-label"></label>
<textarea asp-for="Input.Comments" class="form-control" rows="5"></textarea>
<span asp-validation-for="Input.Comments" class="text-danger"></span>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary">Submit feedback</button>
</div>
</form>
</div>
</div>
</div>
}

@section Scripts {
<partial name="_ValidationScriptsPartial" />
}
59 changes: 59 additions & 0 deletions src/KulaHub.Web/Pages/Feedback.cshtml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using System.ComponentModel.DataAnnotations;
using KulaHub.Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace KulaHub.Web.Pages;

public sealed class FeedbackModel(IKulaHubCrmService crmService) : PageModel
{
[BindProperty]
public FeedbackInput Input { get; set; } = new();

public bool Submitted { get; private set; }

public void OnGet()
{
}

public async Task<IActionResult> OnPostAsync(CancellationToken cancellationToken)
{
if (!ModelState.IsValid)
{
return Page();
}

try
{
await crmService.SubmitFeedbackAsync(
new SubmitFeedbackCommand(Input.Name!, Input.Email!, Input.Comments!),
cancellationToken);

Submitted = true;
return Page();
}
catch (InvalidOperationException ex)
{
ModelState.AddModelError(string.Empty, ex.Message);
return Page();
}
}

public sealed class FeedbackInput
{
[Required]
[Display(Name = "Name")]
[MaxLength(100)]
public string? Name { get; set; }

[Required]
[EmailAddress]
[Display(Name = "Email address")]
[MaxLength(200)]
public string? Email { get; set; }

[Required]
[Display(Name = "Comments")]
public string? Comments { get; set; }
}
}
3 changes: 3 additions & 0 deletions src/KulaHub.Web/Pages/Shared/_Layout.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
<li class="nav-item">
<a class="nav-link" asp-area="" asp-page="/Index">Contacts</a>
</li>
<li class="nav-item">
<a class="nav-link" asp-area="" asp-page="/Feedback">Feedback</a>
</li>
</ul>
</div>
</div>
Expand Down
16 changes: 15 additions & 1 deletion src/KulaHub.Web/Program.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Azure.Monitor.OpenTelemetry.AspNetCore;
using KulaHub.Data;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);
var appInsightsConnectionString =
Expand All @@ -17,6 +18,16 @@

var app = builder.Build();

// Ensure SQLite database is created when running with a SQLite connection string
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<KulaHubDbContext>();
if (db.Database.IsSqlite())
{
db.Database.EnsureCreated();
}
}

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
Expand All @@ -25,7 +36,10 @@
app.UseHsts();
}

app.UseHttpsRedirection();
if (!app.Environment.IsEnvironment("UITest"))
{
app.UseHttpsRedirection();
}

app.UseRouting();

Expand Down
12 changes: 12 additions & 0 deletions src/KulaHub.Web/appsettings.UITest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"ConnectionStrings": {
"KulaHubDatabase": "Data Source=/tmp/kulahub-uitest.db"
},
"Logging": {
"LogLevel": {
"Default": "Warning",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
Loading