diff --git a/.gitignore b/.gitignore index 3180779..a5796c7 100644 --- a/.gitignore +++ b/.gitignore @@ -57,4 +57,11 @@ CodeCoverage/ # NUnit *.VisualState.xml TestResult.xml -nunit-*.xml \ No newline at end of file +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/ diff --git a/docs/data-model.md b/docs/data-model.md index 381ea11..650db69 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -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. diff --git a/src/KulaHub.Api/FeedbackEndpoints.cs b/src/KulaHub.Api/FeedbackEndpoints.cs new file mode 100644 index 0000000..96f9ec3 --- /dev/null +++ b/src/KulaHub.Api/FeedbackEndpoints.cs @@ -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); +} diff --git a/src/KulaHub.Api/Program.cs b/src/KulaHub.Api/Program.cs index f4dc8dc..2c92276 100644 --- a/src/KulaHub.Api/Program.cs +++ b/src/KulaHub.Api/Program.cs @@ -27,5 +27,6 @@ .WithName("GetHealth"); app.MapContactEndpoints(); +app.MapFeedbackEndpoints(); app.Run(); diff --git a/src/KulaHub.Data/CrmContracts.cs b/src/KulaHub.Data/CrmContracts.cs index d32b02a..afa9782 100644 --- a/src/KulaHub.Data/CrmContracts.cs +++ b/src/KulaHub.Data/CrmContracts.cs @@ -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> GetClientsAsync(CancellationToken cancellationToken = default); @@ -93,4 +97,5 @@ public interface IKulaHubCrmService Task CreateContactAsync(CreateContactCommand command, OriginType originType, CancellationToken cancellationToken = default); Task AddNoteAsync(AddNoteCommand command, OriginType originType, CancellationToken cancellationToken = default); Task CreateContactFormAsync(CreateContactFormCommand command, OriginType originType, CancellationToken cancellationToken = default); + Task SubmitFeedbackAsync(SubmitFeedbackCommand command, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/KulaHub.Data/Entities/Feedback.cs b/src/KulaHub.Data/Entities/Feedback.cs new file mode 100644 index 0000000..34f14f7 --- /dev/null +++ b/src/KulaHub.Data/Entities/Feedback.cs @@ -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; } +} diff --git a/src/KulaHub.Data/KulaHub.Data.csproj b/src/KulaHub.Data/KulaHub.Data.csproj index c901492..820d52d 100644 --- a/src/KulaHub.Data/KulaHub.Data.csproj +++ b/src/KulaHub.Data/KulaHub.Data.csproj @@ -8,6 +8,7 @@ + diff --git a/src/KulaHub.Data/KulaHubCrmService.cs b/src/KulaHub.Data/KulaHubCrmService.cs index 8793ecc..ac2a8da 100644 --- a/src/KulaHub.Data/KulaHubCrmService.cs +++ b/src/KulaHub.Data/KulaHubCrmService.cs @@ -306,6 +306,43 @@ public async Task CreateContactFormAsync(CreateContactFormComm return new CreateFormResult(form.FormId); } + public async Task 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(); diff --git a/src/KulaHub.Data/KulaHubDbContext.cs b/src/KulaHub.Data/KulaHubDbContext.cs index 433f035..fe412f8 100644 --- a/src/KulaHub.Data/KulaHubDbContext.cs +++ b/src/KulaHub.Data/KulaHubDbContext.cs @@ -13,6 +13,7 @@ public sealed class KulaHubDbContext(DbContextOptions options) public DbSet
Forms => Set(); public DbSet IntegrationInbox => Set(); public DbSet IntegrationDispatch => Set(); + public DbSet Feedback => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -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(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); + }); } } \ No newline at end of file diff --git a/src/KulaHub.Data/ServiceCollectionExtensions.cs b/src/KulaHub.Data/ServiceCollectionExtensions.cs index ef1b671..a6dd576 100644 --- a/src/KulaHub.Data/ServiceCollectionExtensions.cs +++ b/src/KulaHub.Data/ServiceCollectionExtensions.cs @@ -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(options => options.UseSqlServer(connectionString)); + services.AddDbContext(options => + { + if (connectionString.TrimStart().StartsWith("Data Source=", StringComparison.OrdinalIgnoreCase)) + { + options.UseSqlite(connectionString); + } + else + { + options.UseSqlServer(connectionString); + } + }); + services.AddScoped(); return services; diff --git a/src/KulaHub.Web/Pages/Feedback.cshtml b/src/KulaHub.Web/Pages/Feedback.cshtml new file mode 100644 index 0000000..0d48912 --- /dev/null +++ b/src/KulaHub.Web/Pages/Feedback.cshtml @@ -0,0 +1,54 @@ +@page +@model KulaHub.Web.Pages.FeedbackModel +@{ + ViewData["Title"] = "Feedback"; +} + + + +@if (Model.Submitted) +{ + +} +else +{ +
+
+
+ +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +
+ +
+
+
+} + +@section Scripts { + +} diff --git a/src/KulaHub.Web/Pages/Feedback.cshtml.cs b/src/KulaHub.Web/Pages/Feedback.cshtml.cs new file mode 100644 index 0000000..4d493c1 --- /dev/null +++ b/src/KulaHub.Web/Pages/Feedback.cshtml.cs @@ -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 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; } + } +} diff --git a/src/KulaHub.Web/Pages/Shared/_Layout.cshtml b/src/KulaHub.Web/Pages/Shared/_Layout.cshtml index b1aad9a..315ef99 100644 --- a/src/KulaHub.Web/Pages/Shared/_Layout.cshtml +++ b/src/KulaHub.Web/Pages/Shared/_Layout.cshtml @@ -23,6 +23,9 @@ + diff --git a/src/KulaHub.Web/Program.cs b/src/KulaHub.Web/Program.cs index 0c70170..1f37f9d 100644 --- a/src/KulaHub.Web/Program.cs +++ b/src/KulaHub.Web/Program.cs @@ -1,5 +1,6 @@ using Azure.Monitor.OpenTelemetry.AspNetCore; using KulaHub.Data; +using Microsoft.EntityFrameworkCore; var builder = WebApplication.CreateBuilder(args); var appInsightsConnectionString = @@ -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(); + if (db.Database.IsSqlite()) + { + db.Database.EnsureCreated(); + } +} + // Configure the HTTP request pipeline. if (!app.Environment.IsDevelopment()) { @@ -25,7 +36,10 @@ app.UseHsts(); } -app.UseHttpsRedirection(); +if (!app.Environment.IsEnvironment("UITest")) +{ + app.UseHttpsRedirection(); +} app.UseRouting(); diff --git a/src/KulaHub.Web/appsettings.UITest.json b/src/KulaHub.Web/appsettings.UITest.json new file mode 100644 index 0000000..8548e40 --- /dev/null +++ b/src/KulaHub.Web/appsettings.UITest.json @@ -0,0 +1,12 @@ +{ + "ConnectionStrings": { + "KulaHubDatabase": "Data Source=/tmp/kulahub-uitest.db" + }, + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/tests/KulaHub.IntegrationTests/KulaHubCrmServiceTests.cs b/tests/KulaHub.IntegrationTests/KulaHubCrmServiceTests.cs index 93e0092..6fbdfb6 100644 --- a/tests/KulaHub.IntegrationTests/KulaHubCrmServiceTests.cs +++ b/tests/KulaHub.IntegrationTests/KulaHubCrmServiceTests.cs @@ -99,4 +99,42 @@ public async Task AddNoteAsync_PersistsNoteAndInboxEntry() Assert.Equal("Note", inboxEntries[1].EntityType); Assert.Equal("Note.Created", inboxEntries[1].EventType); } + + [Fact] + public async Task SubmitFeedbackAsync_PersistsFeedbackRow() + { + var result = await crmService.SubmitFeedbackAsync( + new SubmitFeedbackCommand("Jane Smith", "jane.smith@example.com", "Great product!")); + + var feedback = await dbContext.Feedback.SingleAsync(item => item.FeedbackId == result.FeedbackId); + + Assert.Equal("Jane Smith", feedback.Name); + Assert.Equal("jane.smith@example.com", feedback.Email); + Assert.Equal("Great product!", feedback.Comments); + Assert.Equal("FeedbackForm", feedback.CreatedBy); + } + + [Fact] + public async Task SubmitFeedbackAsync_ThrowsWhenNameIsMissing() + { + await Assert.ThrowsAsync(() => + crmService.SubmitFeedbackAsync( + new SubmitFeedbackCommand("", "jane@example.com", "Comments"))); + } + + [Fact] + public async Task SubmitFeedbackAsync_ThrowsWhenEmailIsMissing() + { + await Assert.ThrowsAsync(() => + crmService.SubmitFeedbackAsync( + new SubmitFeedbackCommand("Jane", "", "Comments"))); + } + + [Fact] + public async Task SubmitFeedbackAsync_ThrowsWhenCommentsAreMissing() + { + await Assert.ThrowsAsync(() => + crmService.SubmitFeedbackAsync( + new SubmitFeedbackCommand("Jane", "jane@example.com", ""))); + } } \ No newline at end of file diff --git a/tests/KulaHub.PlaywrightTests/package-lock.json b/tests/KulaHub.PlaywrightTests/package-lock.json new file mode 100644 index 0000000..f780b1c --- /dev/null +++ b/tests/KulaHub.PlaywrightTests/package-lock.json @@ -0,0 +1,79 @@ +{ + "name": "kulahub.playwrighttests", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "kulahub.playwrighttests", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@playwright/test": "^1.59.1" + } + }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/tests/KulaHub.PlaywrightTests/package.json b/tests/KulaHub.PlaywrightTests/package.json new file mode 100644 index 0000000..41e4716 --- /dev/null +++ b/tests/KulaHub.PlaywrightTests/package.json @@ -0,0 +1,16 @@ +{ + "name": "kulahub.playwrighttests", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "test": "PLAYWRIGHT_HTML_OPEN=never npx playwright test", + "test:headed": "npx playwright test --headed" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "devDependencies": { + "@playwright/test": "^1.59.1" + } +} diff --git a/tests/KulaHub.PlaywrightTests/playwright.config.ts b/tests/KulaHub.PlaywrightTests/playwright.config.ts new file mode 100644 index 0000000..e630d38 --- /dev/null +++ b/tests/KulaHub.PlaywrightTests/playwright.config.ts @@ -0,0 +1,20 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + fullyParallel: false, + forbidOnly: !!process.env['CI'], + retries: process.env['CI'] ? 1 : 0, + workers: 1, + reporter: 'list', + use: { + baseURL: process.env['BASE_URL'] ?? 'http://localhost:5050', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'], headless: true }, + }, + ], +}); diff --git a/tests/KulaHub.PlaywrightTests/tests/feedback.spec.ts b/tests/KulaHub.PlaywrightTests/tests/feedback.spec.ts new file mode 100644 index 0000000..632e306 --- /dev/null +++ b/tests/KulaHub.PlaywrightTests/tests/feedback.spec.ts @@ -0,0 +1,53 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Feedback page', () => { + test('displays the feedback form', async ({ page }) => { + await page.goto('/Feedback'); + + await expect(page).toHaveTitle(/Feedback/); + await expect(page.getByRole('heading', { name: 'Feedback' })).toBeVisible(); + await expect(page.getByRole('textbox', { name: 'Name' })).toBeVisible(); + await expect(page.getByRole('textbox', { name: 'Email address' })).toBeVisible(); + await expect(page.getByRole('textbox', { name: 'Comments' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Submit feedback' })).toBeVisible(); + }); + + test('shows Feedback link in navigation', async ({ page }) => { + await page.goto('/'); + + await expect(page.getByRole('link', { name: 'Feedback' })).toBeVisible(); + }); + + test('submits the feedback form and shows success message', async ({ page }) => { + await page.goto('/Feedback'); + + await page.getByRole('textbox', { name: 'Name' }).fill('Jane Smith'); + await page.getByRole('textbox', { name: 'Email address' }).fill('jane.smith@example.com'); + await page.getByRole('textbox', { name: 'Comments' }).fill('This is a great CRM system. Very intuitive!'); + await page.getByRole('button', { name: 'Submit feedback' }).click(); + + await expect(page.getByRole('alert')).toContainText('Thank you for your feedback!'); + await expect(page.getByRole('textbox', { name: 'Name' })).not.toBeVisible(); + }); + + test('shows validation errors when form fields are empty', async ({ page }) => { + await page.goto('/Feedback'); + + await page.getByRole('button', { name: 'Submit feedback' }).click(); + + await expect(page.getByRole('textbox', { name: 'Name' })).toBeVisible(); + await expect(page.getByRole('textbox', { name: 'Email address' })).toBeVisible(); + await expect(page.getByRole('textbox', { name: 'Comments' })).toBeVisible(); + }); + + test('shows validation error for invalid email', async ({ page }) => { + await page.goto('/Feedback'); + + await page.getByRole('textbox', { name: 'Name' }).fill('John Doe'); + await page.getByRole('textbox', { name: 'Email address' }).fill('not-an-email'); + await page.getByRole('textbox', { name: 'Comments' }).fill('Some comments'); + await page.getByRole('button', { name: 'Submit feedback' }).click(); + + await expect(page.getByRole('textbox', { name: 'Email address' })).toBeVisible(); + }); +});