diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..510cf81 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,53 @@ +# Git +.git +.gitignore +.gitattributes + +# IDE +.vs +.vscode +*.swp +*.swo +*~ +.DS_Store + +# Build artifacts +bin/ +obj/ +out/ +dist/ + +# NuGet +*.nupkg +*.snupkg +packages/ +.nuget/ + +# Node modules (for UI) +node_modules/ +npm-debug.log +yarn-error.log + +# Test +test-results/ +coverage/ + +# Documentation +*.md +docs/ + +# Docker +Dockerfile +docker-compose.yml +.dockerignore +.env + +# CI/CD +.github/ +.gitlab-ci.yml +.travis.yml + +# Temporary files +*.tmp +*.log +*.cache diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..776a1d8 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,46 @@ +name: Build & Test + +permissions: read-all + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + types: [opened, synchronize] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: 🔎 Checkout files + uses: actions/checkout@v5 + + - name: 💎 Cache NuGet packages + uses: actions/cache@v4 + id: cache-nuget + with: + path: | + ~/.nuget/packages + ~/.local/share/NuGet/Cache-* + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} + restore-keys: | + ${{ runner.os }}-nuget- + + - name: 🎽 Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '8.0.x' + + - name: 💫 Restore dependencies + run: dotnet restore + + - name: 🔥 Build + run: dotnet build --no-restore -o "${{ vars.ARTIFACT_DIR }}/output" + + - name: 📈 Test + run: dotnet test --verbosity normal + + diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..2b47f08 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,103 @@ +name: .NET +permissions: read-all +on: + push: + tags: + - 'v*' + workflow_dispatch: +jobs: + deploy: + runs-on: ubuntu-latest + env: + APPSETTINGS_PATH: ui/Sufi.Demo.PeopleDirectory.UI/Server/appsettings.json + IMAGE_NAME: sufiaziz/demo-contact + steps: + - name: 🔧 Install dependencies + run: | + apt-get update + apt-get install -y curl sed ca-certificates gnupg + + # Install Docker CLI with Compose plugin + install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg + chmod a+r /etc/apt/keyrings/docker.gpg + + echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ + $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ + tee /etc/apt/sources.list.d/docker.list > /dev/null + + apt-get update + apt-get install -y docker-ce-cli docker-compose-plugin + + # Verify installation + docker compose version + + - name: 🔎 Checkout files + uses: actions/checkout@v5 + + - name: 💎 Cache NuGet packages + uses: actions/cache@v4 + id: cache-nuget + with: + path: | + ~/.nuget/packages + ~/.local/share/NuGet/Cache-* + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} + restore-keys: | + ${{ runner.os }}-nuget- + + - name: 🎽 Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '8.0.x' + + - name: 🔄 Replace placeholders in docker-compose.yml + run: | + echo "Replacing placeholders in docker-compose.yml..." + echo "Replace connection string..." + sed -i "s|#ConnectionString#|${{ secrets.CONNECTION_STRING }}|g" docker-compose.yml + echo "Replace license key..." + sed -i "s|#LuckyPennyLicenseKey#|${{ secrets.LUCKY_PENNY_LICENSE_KEY }}|g" docker-compose.yml + echo "Replace SEQ settings..." + sed -i "s|#SEQ_URL#|${{ vars.SEQ_URL }}|g" docker-compose.yml + sed -i "s|#SEQ_APIKEY#|${{ secrets.SEQ_APIKEY }}|g" docker-compose.yml + + - name: 🐳 Build Docker image + run: | + docker build -t $IMAGE_NAME:latest \ + --build-arg BUILD_PROJ="${{ vars.BUILD_PROJ }}" \ + -f Dockerfile . + echo "Docker image built successfully" + + - name: 🛑 Stop existing containers + run: | + echo "Stopping existing containers via docker compose..." + docker compose down || echo "No existing containers to stop" + + - name: 🚀 Start containers with docker compose + run: | + echo "Starting containers via docker compose..." + docker compose up -d + echo "Containers started successfully at $(date)" + + - name: ✅ Verify container status + run: | + echo "Waiting for containers to be healthy..." + sleep 5 + + echo "Listing running containers:" + docker compose ps + + echo "" + echo "Recent logs:" + docker compose logs --tail=20 + + # Check if main service is running + if docker compose ps | grep -q "Up"; then + echo "Containers are running successfully" + else + echo "Error: Containers failed to start" + docker compose logs + exit 1 + fi \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d703275 --- /dev/null +++ b/.gitignore @@ -0,0 +1,399 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e2b1359 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build + +WORKDIR /App + +COPY . ./ + +RUN dotnet restore + +RUN dotnet publish "ui/Sufi.Demo.PeopleDirectory.UI/Server/Sufi.Demo.PeopleDirectory.UI.Server.csproj" -c Release -o out + +FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS runtime + +WORKDIR /App + +COPY --from=build /App/out ./ + +EXPOSE 8080 + +ENTRYPOINT ["dotnet", "Sufi.Demo.PeopleDirectory.UI.Server.dll"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..91c7c30 --- /dev/null +++ b/LICENSE @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) 2025 yoimili + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 4fb559d..a5d2da5 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,3 @@ # demo-contact +A simple demonstration on my coding knowledge and capabilities. In case someone need to see it :) \ No newline at end of file diff --git a/Sufi.Demo.PeopleDirectory.Application/Contracts/Common/IService.cs b/Sufi.Demo.PeopleDirectory.Application/Contracts/Common/IService.cs new file mode 100644 index 0000000..e309460 --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Application/Contracts/Common/IService.cs @@ -0,0 +1,6 @@ +namespace Sufi.Demo.PeopleDirectory.Application.Contracts.Common +{ + public interface IService + { + } +} diff --git a/Sufi.Demo.PeopleDirectory.Application/Contracts/Repositories/IAsyncRepository.cs b/Sufi.Demo.PeopleDirectory.Application/Contracts/Repositories/IAsyncRepository.cs new file mode 100644 index 0000000..71d082d --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Application/Contracts/Repositories/IAsyncRepository.cs @@ -0,0 +1,24 @@ +using Sufi.Demo.PeopleDirectory.Domain.Common; + +namespace Sufi.Demo.PeopleDirectory.Application.Contracts.Repositories +{ + public interface IAsyncRepository where T : class, IEntity + { + IQueryable Entities { get; } + + Task GetByIdAsync(TId id); + + Task> GetAllAsync(); + + Task> GetPagedResponseAsync(int pageNumber, int pageSize); + + Task AddAsync(T entity); + + Task UpdateAsync(T entity); + + Task DeleteAsync(T entity); + Task DeleteByIdAsync(TId id); + + Task CountAsync(); + } +} diff --git a/Sufi.Demo.PeopleDirectory.Application/Contracts/Repositories/IUnitOfWork.cs b/Sufi.Demo.PeopleDirectory.Application/Contracts/Repositories/IUnitOfWork.cs new file mode 100644 index 0000000..ef97bd3 --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Application/Contracts/Repositories/IUnitOfWork.cs @@ -0,0 +1,15 @@ +using Sufi.Demo.PeopleDirectory.Domain.Common; + +namespace Sufi.Demo.PeopleDirectory.Application.Contracts.Repositories +{ + public interface IUnitOfWork : IDisposable + { + IAsyncRepository Repository() where T : AuditableEntity; + + Task Commit(CancellationToken cancellationToken); + + Task CommitAndRemoveCache(CancellationToken cancellationToken, params string[] cacheKeys); + + Task Rollback(); + } +} diff --git a/Sufi.Demo.PeopleDirectory.Application/Contracts/Services/IAppCache.cs b/Sufi.Demo.PeopleDirectory.Application/Contracts/Services/IAppCache.cs new file mode 100644 index 0000000..e1ac5d4 --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Application/Contracts/Services/IAppCache.cs @@ -0,0 +1,11 @@ +namespace Sufi.Demo.PeopleDirectory.Application.Contracts.Services +{ + public interface IAppCache + { + ValueTask GetOrAddAsync(string key, Func> factory, + IEnumerable? tags = null, TimeSpan? absoluteExpireTime = null); + ValueTask RemoveAsync(string key); + ValueTask RemoveByTagAsync(string tag); + ValueTask ResetAsync(); + } +} diff --git a/Sufi.Demo.PeopleDirectory.Application/Contracts/Services/ICurrentUserService.cs b/Sufi.Demo.PeopleDirectory.Application/Contracts/Services/ICurrentUserService.cs new file mode 100644 index 0000000..edf8faa --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Application/Contracts/Services/ICurrentUserService.cs @@ -0,0 +1,9 @@ +using Sufi.Demo.PeopleDirectory.Application.Contracts.Common; + +namespace Sufi.Demo.PeopleDirectory.Application.Contracts.Services +{ + public interface ICurrentUserService : IService + { + string? UserId { get; } + } +} diff --git a/Sufi.Demo.PeopleDirectory.Application/Enums/AuditType.cs b/Sufi.Demo.PeopleDirectory.Application/Enums/AuditType.cs new file mode 100644 index 0000000..d71c600 --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Application/Enums/AuditType.cs @@ -0,0 +1,10 @@ +namespace Sufi.Demo.PeopleDirectory.Application.Enums +{ + public enum AuditType + { + None = 0, + Create = 1, + Update = 2, + Delete = 3 + } +} diff --git a/Sufi.Demo.PeopleDirectory.Application/Extensions/ServiceCollectionExtensions.cs b/Sufi.Demo.PeopleDirectory.Application/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..b866b41 --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Application/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,28 @@ +using FluentValidation; +using Microsoft.Extensions.DependencyInjection; +using System.Reflection; + +namespace Sufi.Demo.PeopleDirectory.Application.Extensions +{ + public static class ServiceCollectionExtensions + { + public static IServiceCollection AddApplicationLayer(this IServiceCollection services, string licenseKey) + { + services.AddAutoMapper(config => + { + config.LicenseKey = licenseKey; + config.AddMaps(Assembly.GetExecutingAssembly()); + }); + + services.AddMediatR(config => + { + config.LicenseKey = licenseKey; + config.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()); + }); + + services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); + + return services; + } + } +} diff --git a/Sufi.Demo.PeopleDirectory.Application/Features/Contacts/Commands/AddEditContactCommand.cs b/Sufi.Demo.PeopleDirectory.Application/Features/Contacts/Commands/AddEditContactCommand.cs new file mode 100644 index 0000000..c1a9880 --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Application/Features/Contacts/Commands/AddEditContactCommand.cs @@ -0,0 +1,102 @@ +using AutoMapper; +using FluentValidation; +using MediatR; +using Microsoft.Extensions.Logging; +using Sufi.Demo.PeopleDirectory.Application.Contracts.Repositories; +using Sufi.Demo.PeopleDirectory.Application.Contracts.Services; +using Sufi.Demo.PeopleDirectory.Domain.Entities.Misc; +using Sufi.Demo.PeopleDirectory.Shared.Wrapper; + +namespace Sufi.Demo.PeopleDirectory.Application.Features.Contacts.Commands +{ + public class AddEditContactCommand : IRequest> + { + public int Id { get; set; } + public string UserName { get; set; } = null!; + public string Phone { get; set; } = Random.Shared.Next(1000000000, 1999999999).ToString(); + public string Email { get; set; } = "user@example.com"; + public string SkillSets { get; set; } = "skill1, skill2, skill3"; + public string Hobby { get; set; } = "Hobby"; + } + + public sealed class AddEditContactCommandValidator : AbstractValidator + { + public AddEditContactCommandValidator() + { + RuleFor(v => v.UserName) + .NotEmpty().WithMessage("UserName is required.") + .MaximumLength(50).WithMessage("UserName must not exceed 50 characters."); + RuleFor(v => v.Phone) + .NotEmpty().WithMessage("Phone is required.") + .MaximumLength(20).WithMessage("Phone must not exceed 20 characters."); + RuleFor(v => v.Email) + .NotEmpty().WithMessage("Email is required.") + .EmailAddress().WithMessage("A valid email is required.") + .MaximumLength(100) + .WithMessage("Email must not exceed 100 characters."); + RuleFor(v => v.SkillSets) + .NotEmpty().WithMessage("SkillSets is required.") + .MaximumLength(255).WithMessage("SkillSets must not exceed 255 characters."); + RuleFor(v => v.Hobby) + .NotEmpty().WithMessage("Hobby is required.") + .MaximumLength(255).WithMessage("Hobby must not exceed 255 characters."); + } + } + + public class AddEditContactCommandHandler( + IMapper mapper, + IUnitOfWork unitOfWork, + ILogger logger, + IAppCache appCache + ) : IRequestHandler> + { + public async Task> Handle(AddEditContactCommand command, CancellationToken cancellationToken) + { + if (command.Id == 0) + { + // Only add if max count is not more than 100. + var count = await unitOfWork.Repository().CountAsync(); + if (count > 100) + { + return await Result.FailAsync("Max item count reached. Please delete some first."); + } + + var contact = mapper.Map(command); + await unitOfWork.Repository().AddAsync(contact); + await unitOfWork.Commit(cancellationToken); + + // Invalidate cache. + await appCache.RemoveAsync("contact_all"); + + logger.LogInformation("New contact added with ID: {Id}", contact.Id); + + return await Result.SuccessAsync(contact.Id, "New contact saved."); + } + else + { + var contact = await unitOfWork.Repository().GetByIdAsync(command.Id); + if (contact != null) + { + mapper.Map(command, contact); + + await unitOfWork.Repository().UpdateAsync(contact); + await unitOfWork.Commit(cancellationToken); + + // Invalidate cache. + await appCache.RemoveAsync($"contact_{command.Id}"); + await appCache.RemoveAsync("contact_all"); + + logger.LogInformation("Contact updated with ID: {Id}", contact.Id); + + return await Result.SuccessAsync(contact.Id, "Contact updated."); + } + else + { + logger.LogWarning("Contact not found with ID: {Id}", command.Id); + + return await Result.FailAsync("Contact not found!"); + } + } + } + } +} diff --git a/Sufi.Demo.PeopleDirectory.Application/Features/Contacts/Commands/DeleteContactCommand.cs b/Sufi.Demo.PeopleDirectory.Application/Features/Contacts/Commands/DeleteContactCommand.cs new file mode 100644 index 0000000..d1131f0 --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Application/Features/Contacts/Commands/DeleteContactCommand.cs @@ -0,0 +1,53 @@ +using FluentValidation; +using MediatR; +using Microsoft.Extensions.Logging; +using Sufi.Demo.PeopleDirectory.Application.Contracts.Repositories; +using Sufi.Demo.PeopleDirectory.Application.Contracts.Services; +using Sufi.Demo.PeopleDirectory.Domain.Entities.Misc; +using Sufi.Demo.PeopleDirectory.Shared.Wrapper; + +namespace Sufi.Demo.PeopleDirectory.Application.Features.Contacts.Commands +{ + public class DeleteContactCommand : IRequest + { + public int Id { get; set; } + } + + public sealed class DeleteContactCommandValidator : AbstractValidator + { + public DeleteContactCommandValidator() + { + RuleFor(v => v.Id) + .GreaterThan(0) + .WithMessage("A valid Id is required."); + } + } + + public class DeleteContactCommandHandler( + IUnitOfWork unitOfWork, + ILogger logger, + IAppCache appCache + ) : IRequestHandler + { + public async Task Handle(DeleteContactCommand request, CancellationToken cancellationToken) + { + var itemToDelete = await unitOfWork.Repository().GetByIdAsync(request.Id); + if (itemToDelete != null) + { + await unitOfWork.Repository().DeleteByIdAsync(request.Id); + + // Clear cache entries related to contacts. + await appCache.RemoveAsync($"contact_{request.Id}"); + await appCache.RemoveAsync("contact_all"); + + logger.LogInformation("Contact with ID: {Id} deleted.", request.Id); + + return Result.Success(); + } + + logger.LogWarning("No contact found with ID: {Id}", request.Id); + + return Result.Fail("No data to delete."); + } + } +} \ No newline at end of file diff --git a/Sufi.Demo.PeopleDirectory.Application/Features/Contacts/Queries/GetAll/GetAllContactsQuery.cs b/Sufi.Demo.PeopleDirectory.Application/Features/Contacts/Queries/GetAll/GetAllContactsQuery.cs new file mode 100644 index 0000000..8828da9 --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Application/Features/Contacts/Queries/GetAll/GetAllContactsQuery.cs @@ -0,0 +1,35 @@ +using AutoMapper; +using MediatR; +using Sufi.Demo.PeopleDirectory.Application.Contracts.Repositories; +using Sufi.Demo.PeopleDirectory.Application.Contracts.Services; +using Sufi.Demo.PeopleDirectory.Domain.Entities.Misc; +using Sufi.Demo.PeopleDirectory.Shared.Wrapper; + +namespace Sufi.Demo.PeopleDirectory.Application.Features.Contacts.Queries.GetAll +{ + public class GetAllContactsQuery : IRequest>> + { + } + + public class GetAllContactsQueryHandler( + IUnitOfWork unitOfWork, + IMapper mapper, + IAppCache appCache + ) : IRequestHandler>> + { + public async Task>> Handle(GetAllContactsQuery request, CancellationToken cancellationToken) + { + Task> allContactsFunc() => unitOfWork.Repository().GetAllAsync(); + + var allContacts = await appCache.GetOrAddAsync( + "contact_all", + async token => await allContactsFunc(), + absoluteExpireTime: TimeSpan.FromMinutes(2), + tags: ["contacts"] + ); + + var mappedContacts = mapper.Map>(allContacts); + return await Result>.SuccessAsync(mappedContacts); + } + } +} diff --git a/Sufi.Demo.PeopleDirectory.Application/Features/Contacts/Queries/GetAll/GetAllContactsResponse.cs b/Sufi.Demo.PeopleDirectory.Application/Features/Contacts/Queries/GetAll/GetAllContactsResponse.cs new file mode 100644 index 0000000..6ed5e9c --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Application/Features/Contacts/Queries/GetAll/GetAllContactsResponse.cs @@ -0,0 +1,12 @@ +namespace Sufi.Demo.PeopleDirectory.Application.Features.Contacts.Queries.GetAll +{ + public record GetAllContactsResponse + { + public int Id { get; set; } + public string UserName { get; set; } = null!; + public string Phone { get; set; } = null!; + public string Email { get; set; } = null!; + public string SkillSets { get; set; } = null!; + public string Hobby { get; set; } = null!; + } +} diff --git a/Sufi.Demo.PeopleDirectory.Application/Features/Contacts/Queries/GetById/GetContactByIdQuery.cs b/Sufi.Demo.PeopleDirectory.Application/Features/Contacts/Queries/GetById/GetContactByIdQuery.cs new file mode 100644 index 0000000..a975d7b --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Application/Features/Contacts/Queries/GetById/GetContactByIdQuery.cs @@ -0,0 +1,47 @@ +using AutoMapper; +using FluentValidation; +using MediatR; +using Sufi.Demo.PeopleDirectory.Application.Contracts.Repositories; +using Sufi.Demo.PeopleDirectory.Application.Contracts.Services; +using Sufi.Demo.PeopleDirectory.Domain.Entities.Misc; +using Sufi.Demo.PeopleDirectory.Shared.Wrapper; + +namespace Sufi.Demo.PeopleDirectory.Application.Features.Contacts.Queries.GetById +{ + public class GetContactByIdQuery : IRequest> + { + public int Id { get; set; } + } + + public sealed class GetContactByIdQueryValidator : AbstractValidator + { + public GetContactByIdQueryValidator() + { + RuleFor(v => v.Id) + .GreaterThan(0) + .WithMessage("A valid Id is required."); + } + } + + public class GetContactByIdQueryHandler( + IUnitOfWork unitOfWork, + IMapper mapper, + IAppCache appCache + ) : IRequestHandler> + { + public async Task> Handle(GetContactByIdQuery request, CancellationToken cancellationToken) + { + Task getContactByIdFunc() => unitOfWork.Repository().GetByIdAsync(request.Id); + + var contact = await appCache.GetOrAddAsync( + $"contact_{request.Id}", + async token => await getContactByIdFunc(), + absoluteExpireTime: TimeSpan.FromMinutes(2), + tags: ["contacts"] + ); + + var mappedContact = mapper.Map(contact); + return await Result.SuccessAsync(mappedContact); + } + } +} diff --git a/Sufi.Demo.PeopleDirectory.Application/Features/Contacts/Queries/GetById/GetContactByIdResponse.cs b/Sufi.Demo.PeopleDirectory.Application/Features/Contacts/Queries/GetById/GetContactByIdResponse.cs new file mode 100644 index 0000000..0697903 --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Application/Features/Contacts/Queries/GetById/GetContactByIdResponse.cs @@ -0,0 +1,12 @@ +namespace Sufi.Demo.PeopleDirectory.Application.Features.Contacts.Queries.GetById +{ + public record GetContactByIdResponse + { + public int Id { get; set; } + public string UserName { get; set; } = null!; + public string Phone { get; set; } = null!; + public string Email { get; set; } = null!; + public string SkillSets { get; set; } = null!; + public string Hobby { get; set; } = null!; + } +} diff --git a/Sufi.Demo.PeopleDirectory.Application/Mappings/ContactProfile.cs b/Sufi.Demo.PeopleDirectory.Application/Mappings/ContactProfile.cs new file mode 100644 index 0000000..d2caf89 --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Application/Mappings/ContactProfile.cs @@ -0,0 +1,20 @@ +using AutoMapper; +using Sufi.Demo.PeopleDirectory.Application.Features.Contacts.Commands; +using Sufi.Demo.PeopleDirectory.Application.Features.Contacts.Queries.GetAll; +using Sufi.Demo.PeopleDirectory.Application.Features.Contacts.Queries.GetById; +using Sufi.Demo.PeopleDirectory.Application.Responses; +using Sufi.Demo.PeopleDirectory.Domain.Entities.Misc; + +namespace Sufi.Demo.PeopleDirectory.Application.Mappings +{ + public class ContactProfile : Profile + { + public ContactProfile() + { + CreateMap().ReverseMap(); + CreateMap().ReverseMap(); + CreateMap(); + CreateMap(); + } + } +} diff --git a/Sufi.Demo.PeopleDirectory.Application/Responses/ContactResponse.cs b/Sufi.Demo.PeopleDirectory.Application/Responses/ContactResponse.cs new file mode 100644 index 0000000..c2c0602 --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Application/Responses/ContactResponse.cs @@ -0,0 +1,12 @@ +namespace Sufi.Demo.PeopleDirectory.Application.Responses +{ + public class ContactResponse + { + public int Id { get; set; } + public string UserName { get; set; } = null!; + public string Phone { get; set; } = null!; + public string Email { get; set; } = null!; + public string SkillSets { get; set; } = null!; + public string Hobby { get; set; } = null!; + } +} diff --git a/Sufi.Demo.PeopleDirectory.Application/Sufi.Demo.PeopleDirectory.Application.csproj b/Sufi.Demo.PeopleDirectory.Application/Sufi.Demo.PeopleDirectory.Application.csproj new file mode 100644 index 0000000..977b9cf --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Application/Sufi.Demo.PeopleDirectory.Application.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + + + + diff --git a/Sufi.Demo.PeopleDirectory.Domain/Common/AuditableEntity.cs b/Sufi.Demo.PeopleDirectory.Domain/Common/AuditableEntity.cs new file mode 100644 index 0000000..717f92e --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Domain/Common/AuditableEntity.cs @@ -0,0 +1,13 @@ +using System; + +namespace Sufi.Demo.PeopleDirectory.Domain.Common +{ + public abstract class AuditableEntity : IAuditableEntity + { + public TId Id { get; set; } = default!; + public string? CreatedBy { get; set; } + public DateTime CreatedOn { get; set; } + public string? LastModifiedBy { get; set; } + public DateTime? LastModifiedOn { get; set; } + } +} diff --git a/Sufi.Demo.PeopleDirectory.Domain/Common/IAuditableEntity.cs b/Sufi.Demo.PeopleDirectory.Domain/Common/IAuditableEntity.cs new file mode 100644 index 0000000..a3e7df5 --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Domain/Common/IAuditableEntity.cs @@ -0,0 +1,17 @@ +using System; + +namespace Sufi.Demo.PeopleDirectory.Domain.Common +{ + public interface IAuditableEntity : IAuditableEntity, IEntity + { + + } + + public interface IAuditableEntity : IEntity + { + string? CreatedBy { get; set; } + DateTime CreatedOn { get; set; } + string? LastModifiedBy { get; set; } + DateTime? LastModifiedOn { get; set; } + } +} diff --git a/Sufi.Demo.PeopleDirectory.Domain/Common/IEntity.cs b/Sufi.Demo.PeopleDirectory.Domain/Common/IEntity.cs new file mode 100644 index 0000000..68de689 --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Domain/Common/IEntity.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace Sufi.Demo.PeopleDirectory.Domain.Common +{ + public interface IEntity + { + } + + public interface IEntity : IEntity + { + [Key] + TId Id { get; set; } + } +} diff --git a/Sufi.Demo.PeopleDirectory.Domain/Entities/Misc/Contact.cs b/Sufi.Demo.PeopleDirectory.Domain/Entities/Misc/Contact.cs new file mode 100644 index 0000000..d0b0f52 --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Domain/Entities/Misc/Contact.cs @@ -0,0 +1,27 @@ +using Sufi.Demo.PeopleDirectory.Domain.Common; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Sufi.Demo.PeopleDirectory.Domain.Entities.Misc +{ + public class Contact : AuditableEntity + { + [Required] + [Column(TypeName = "character varying(50)")] + public string UserName { get; set; } = null!; + [Required] + [Phone] + [Column(TypeName = "character varying(20)")] + public string Phone { get; set; } = null!; + [Required] + [EmailAddress] + [Column(TypeName = "character varying(100)")] + public string Email { get; set; } = null!; + [Required] + [Column(TypeName = "character varying(255)")] + public string SkillSets { get; set; } = null!; + [Required] + [Column(TypeName = "character varying(255)")] + public string Hobby { get; set; } = null!; + } +} diff --git a/Sufi.Demo.PeopleDirectory.Domain/Entities/Misc/ServerInfo.cs b/Sufi.Demo.PeopleDirectory.Domain/Entities/Misc/ServerInfo.cs new file mode 100644 index 0000000..57053b6 --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Domain/Entities/Misc/ServerInfo.cs @@ -0,0 +1,13 @@ +using Sufi.Demo.PeopleDirectory.Domain.Common; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Sufi.Demo.PeopleDirectory.Domain.Entities.Misc +{ + public class ServerInfo : AuditableEntity + { + [Required] + [Column(TypeName = "character varying(255)")] + public string Value { get; set; } = null!; + } +} diff --git a/Sufi.Demo.PeopleDirectory.Domain/Sufi.Demo.PeopleDirectory.Domain.csproj b/Sufi.Demo.PeopleDirectory.Domain/Sufi.Demo.PeopleDirectory.Domain.csproj new file mode 100644 index 0000000..6596196 --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Domain/Sufi.Demo.PeopleDirectory.Domain.csproj @@ -0,0 +1,8 @@ + + + + net8.0 + enable + + + diff --git a/Sufi.Demo.PeopleDirectory.Infrastructure/Identity/CurrentUserService.cs b/Sufi.Demo.PeopleDirectory.Infrastructure/Identity/CurrentUserService.cs new file mode 100644 index 0000000..e091e9f --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Infrastructure/Identity/CurrentUserService.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Http; +using Sufi.Demo.PeopleDirectory.Application.Contracts.Services; +using System.Security.Claims; + +namespace Sufi.Demo.PeopleDirectory.Infrastructure.Identity +{ + public class CurrentUserService : ICurrentUserService + { + public string? UserId { get; } + public List>? Claims { get; set; } + + public CurrentUserService(IHttpContextAccessor httpContextAccessor) + { + var userClaim = httpContextAccessor.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier); + if (userClaim != null) + { + UserId = userClaim.Value; + } + + Claims = httpContextAccessor.HttpContext?.User?.Claims.AsEnumerable().Select(item => new KeyValuePair(item.Type, item.Value)).ToList(); + } + } +} diff --git a/Sufi.Demo.PeopleDirectory.Infrastructure/InfrastructureServiceRegistration.cs b/Sufi.Demo.PeopleDirectory.Infrastructure/InfrastructureServiceRegistration.cs new file mode 100644 index 0000000..4f82b8b --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Infrastructure/InfrastructureServiceRegistration.cs @@ -0,0 +1,38 @@ +using Microsoft.Extensions.DependencyInjection; +using Quartz; +using Sufi.Demo.PeopleDirectory.Application.Contracts.Services; +using Sufi.Demo.PeopleDirectory.Infrastructure.Identity; +using Sufi.Demo.PeopleDirectory.Infrastructure.Jobs; +using Sufi.Demo.PeopleDirectory.Infrastructure.Services; + +namespace Sufi.Demo.PeopleDirectory.Infrastructure +{ + public static class InfrastructureServiceRegistration + { + public static IServiceCollection AddInfrastructureServices(this IServiceCollection services) + { + services.AddHybridCache(); + + services.AddTransient() + .AddTransient(); + + // Some background jobs here. + services.AddQuartz(options => + { + var jobKey = new JobKey("ClearPersistentDataJob"); + options.AddJob(opt => opt.WithIdentity(jobKey)); + options.AddTrigger(opt => + { + opt.ForJob(jobKey) + .WithIdentity("ClearPersistentDataJob-trigger") + .WithSimpleSchedule(x => x + .WithIntervalInMinutes(10) + .RepeatForever()); + }); + }); + services.AddQuartzHostedService(options => options.WaitForJobsToComplete = true); + + return services; + } + } +} diff --git a/Sufi.Demo.PeopleDirectory.Infrastructure/Jobs/ClearPersistentDataJob.cs b/Sufi.Demo.PeopleDirectory.Infrastructure/Jobs/ClearPersistentDataJob.cs new file mode 100644 index 0000000..deb7798 --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Infrastructure/Jobs/ClearPersistentDataJob.cs @@ -0,0 +1,61 @@ +using Quartz; +using Sufi.Demo.PeopleDirectory.Application.Contracts.Repositories; +using Sufi.Demo.PeopleDirectory.Application.Contracts.Services; +using Sufi.Demo.PeopleDirectory.Domain.Entities.Misc; + +namespace Sufi.Demo.PeopleDirectory.Infrastructure.Jobs +{ + /// + /// Represents the cleanup job. + /// + /// + /// Initialize an instance of class. + /// + /// + /// + public class ClearPersistentDataJob(IUnitOfWork unitOfWorkInt, IUnitOfWork unitOfWorkString, IAppCache appCache) : IJob + { + private const string LastDateDeletedKey = "LastDateDeleted"; + private const string DateTimeFormat = "yyyy-MM-dd HH:mm:ss.ffff"; + + /// + /// Job implementation to be executed. + /// + /// + /// + public async Task Execute(IJobExecutionContext context) + { + // Get all contacts to be deleted. + var contactsToDelete = await unitOfWorkInt.Repository().GetAllAsync(); + + // Skips if nothing to delete. + if (contactsToDelete.Count == 0) + return; + + // Delete all contacts. + foreach (var contact in contactsToDelete) + { + await unitOfWorkInt.Repository().DeleteAsync(contact); + } + + // Update the last date deleted in the ServerInfo table. + var infoToUpdate = await unitOfWorkString.Repository().GetByIdAsync(LastDateDeletedKey); + if (infoToUpdate != null) + { + infoToUpdate.Value = DateTime.UtcNow.ToString(DateTimeFormat); + await unitOfWorkString.Repository().UpdateAsync(infoToUpdate); + } + else + { + var infoToAdd = new ServerInfo { Id = LastDateDeletedKey, Value = DateTime.UtcNow.ToString(DateTimeFormat) }; + await unitOfWorkString.Repository().AddAsync(infoToAdd); + } + + // Commit the changes to the database. + await unitOfWorkString.Commit(context.CancellationToken); + + // Clear cache entries related to contacts. + await appCache.ResetAsync(); + } + } +} diff --git a/Sufi.Demo.PeopleDirectory.Infrastructure/Services/AppCache.cs b/Sufi.Demo.PeopleDirectory.Infrastructure/Services/AppCache.cs new file mode 100644 index 0000000..729bd61 --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Infrastructure/Services/AppCache.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.Caching.Hybrid; +using Sufi.Demo.PeopleDirectory.Application.Contracts.Services; + +namespace Sufi.Demo.PeopleDirectory.Infrastructure.Services +{ + public class AppCache( + HybridCache hybridCache + ) : IAppCache + { + public ValueTask GetOrAddAsync(string key, Func> factory, + IEnumerable? tags = null, TimeSpan? absoluteExpireTime = null) + { + var options = new HybridCacheEntryOptions + { + Expiration = absoluteExpireTime ?? TimeSpan.FromSeconds(30), + }; + + return hybridCache.GetOrCreateAsync(key, factory, options, tags); + } + + public ValueTask RemoveAsync(string key) + { + return hybridCache.RemoveAsync(key); + } + + public ValueTask RemoveByTagAsync(string tag) + { + return hybridCache.RemoveByTagAsync(tag); + } + + public ValueTask ResetAsync() + { + return hybridCache.RemoveByTagAsync("*"); + } + } +} diff --git a/Sufi.Demo.PeopleDirectory.Infrastructure/Sufi.Demo.PeopleDirectory.Infrastructure.csproj b/Sufi.Demo.PeopleDirectory.Infrastructure/Sufi.Demo.PeopleDirectory.Infrastructure.csproj new file mode 100644 index 0000000..4498280 --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Infrastructure/Sufi.Demo.PeopleDirectory.Infrastructure.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + diff --git a/Sufi.Demo.PeopleDirectory.Persistence/Contexts/ApplicationDbContext.cs b/Sufi.Demo.PeopleDirectory.Persistence/Contexts/ApplicationDbContext.cs new file mode 100644 index 0000000..ebc40d2 --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Persistence/Contexts/ApplicationDbContext.cs @@ -0,0 +1,88 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Sufi.Demo.PeopleDirectory.Application.Contracts.Services; +using Sufi.Demo.PeopleDirectory.Domain.Common; +using Sufi.Demo.PeopleDirectory.Domain.Entities.Misc; +using Sufi.Demo.PeopleDirectory.Persistence.Models.Identity; + +namespace Sufi.Demo.PeopleDirectory.Persistence.Contexts +{ + public class ApplicationDbContext( + DbContextOptions options, + ICurrentUserService currentUserService + ) : AuditableContext(options) + { + public virtual DbSet Contacts { get; set; } = null!; + public virtual DbSet ServerInfos { get; set; } = null!; + + public override Task SaveChangesAsync(string? userId = null, CancellationToken cancellationToken = default) + { + PopulateAuditRecords(); + + return base.SaveChangesAsync(userId, cancellationToken); + } + + public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + PopulateAuditRecords(); + + if (currentUserService.UserId == null) + { + return await base.SaveChangesAsync(cancellationToken); + } + + return await base.SaveChangesAsync(currentUserService.UserId, cancellationToken); + } + + protected override void OnModelCreating(ModelBuilder builder) + { + foreach (var property in builder.Model.GetEntityTypes() + .SelectMany(t => t.GetProperties()) + .Where(p => p.ClrType == typeof(decimal) || p.ClrType == typeof(decimal?))) + { + property.SetColumnType("decimal(18,2)"); + } + + foreach (var property in builder.Model.GetEntityTypes() + .SelectMany(t => t.GetProperties()) + .Where(p => p.Name is "LastModifiedBy" or "CreatedBy")) + { + property.SetColumnType("character varying(100)"); + } + + base.OnModelCreating(builder); + + var assembly = typeof(ApplicationDbContext).Assembly; + builder.ApplyConfigurationsFromAssembly(assembly); + + builder.Entity>(entity => entity.ToTable("UserRoles", "Identity")); + + builder.Entity>(entity => entity.ToTable("UserClaims", "Identity")); + + builder.Entity>(entity => entity.ToTable("UserLogins", "Identity")); + + builder.Entity(entity => entity.ToTable("RoleClaims", "Identity")); + + builder.Entity>(entity => entity.ToTable("UserTokens", "Identity")); + } + + private void PopulateAuditRecords() + { + foreach (var entry in ChangeTracker.Entries().ToList()) + { + switch (entry.State) + { + case EntityState.Added: + entry.Entity.CreatedOn = DateTime.UtcNow; + entry.Entity.CreatedBy = currentUserService.UserId; + break; + + case EntityState.Modified: + entry.Entity.LastModifiedOn = DateTime.UtcNow; + entry.Entity.LastModifiedBy = currentUserService.UserId; + break; + } + } + } + } +} diff --git a/Sufi.Demo.PeopleDirectory.Persistence/Contexts/AuditableContext.cs b/Sufi.Demo.PeopleDirectory.Persistence/Contexts/AuditableContext.cs new file mode 100644 index 0000000..7886d50 --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Persistence/Contexts/AuditableContext.cs @@ -0,0 +1,117 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Sufi.Demo.PeopleDirectory.Application.Enums; +using Sufi.Demo.PeopleDirectory.Persistence.Models.Audit; +using Sufi.Demo.PeopleDirectory.Persistence.Models.Identity; + +namespace Sufi.Demo.PeopleDirectory.Persistence.Contexts +{ + public abstract class AuditableContext( + DbContextOptions options + ) : IdentityDbContext, IdentityUserRole, + IdentityUserLogin, AppRoleClaim, IdentityUserToken>(options) + { + public DbSet AuditTrails { get; set; } = null!; + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + builder.Entity().ToTable("AuditTrails", "Audit"); + } + + public virtual async Task SaveChangesAsync(string? userId = null, CancellationToken cancellationToken = new()) + { + var auditEntries = OnBeforeSaveChanges(userId); + var result = await base.SaveChangesAsync(cancellationToken); + await OnAfterSaveChanges(auditEntries, cancellationToken); + return result; + } + + private List OnBeforeSaveChanges(string? userId) + { + ChangeTracker.DetectChanges(); + var auditEntries = new List(); + foreach (var entry in ChangeTracker.Entries()) + { + if (entry.Entity is Audit || entry.State == EntityState.Detached || entry.State == EntityState.Unchanged) + continue; + + var auditEntry = new AuditEntry(entry) + { + TableName = entry.Entity.GetType().Name, + UserId = userId + }; + auditEntries.Add(auditEntry); + foreach (var property in entry.Properties) + { + if (property.IsTemporary) + { + auditEntry.TemporaryProperties.Add(property); + continue; + } + + string propertyName = property.Metadata.Name; + if (property.Metadata.IsPrimaryKey()) + { + auditEntry.KeyValues[propertyName] = property.CurrentValue; + continue; + } + + switch (entry.State) + { + case EntityState.Added: + auditEntry.AuditType = AuditType.Create; + auditEntry.NewValues[propertyName] = property.CurrentValue; + break; + + case EntityState.Deleted: + auditEntry.AuditType = AuditType.Delete; + auditEntry.OldValues[propertyName] = property.OriginalValue; + break; + + case EntityState.Modified: + if (property.IsModified && property.OriginalValue?.Equals(property.CurrentValue) == false) + { + auditEntry.ChangedColumns.Add(propertyName); + auditEntry.AuditType = AuditType.Update; + auditEntry.OldValues[propertyName] = property.OriginalValue; + auditEntry.NewValues[propertyName] = property.CurrentValue; + } + break; + } + } + } + foreach (var auditEntry in auditEntries.Where(_ => !_.HasTemporaryProperties)) + { + AuditTrails.Add(auditEntry.ToAudit()); + } + return [.. auditEntries.Where(_ => _.HasTemporaryProperties)]; + } + + private Task OnAfterSaveChanges(List auditEntries, CancellationToken cancellationToken = new()) + { + if (auditEntries == null || auditEntries.Count == 0) + return Task.CompletedTask; + + foreach (var auditEntry in auditEntries) + { + foreach (var prop in auditEntry.TemporaryProperties) + { + if (prop.Metadata.IsPrimaryKey()) + { + auditEntry.KeyValues[prop.Metadata.Name] = prop.CurrentValue; + } + else + { + auditEntry.NewValues[prop.Metadata.Name] = prop.CurrentValue; + } + } + AuditTrails.Add(auditEntry.ToAudit()); + } + return SaveChangesAsync(cancellationToken); + } + } +} diff --git a/Sufi.Demo.PeopleDirectory.Persistence/Contexts/EntityMaps/AppRoleMap.cs b/Sufi.Demo.PeopleDirectory.Persistence/Contexts/EntityMaps/AppRoleMap.cs new file mode 100644 index 0000000..8276b55 --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Persistence/Contexts/EntityMaps/AppRoleMap.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Sufi.Demo.PeopleDirectory.Persistence.Models.Identity; + +namespace Sufi.Demo.PeopleDirectory.Persistence.Contexts.EntityMaps +{ + public class AppRoleMap : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Roles", "Identity"); + } + } +} diff --git a/Sufi.Demo.PeopleDirectory.Persistence/Contexts/EntityMaps/AppUserMap.cs b/Sufi.Demo.PeopleDirectory.Persistence/Contexts/EntityMaps/AppUserMap.cs new file mode 100644 index 0000000..740221a --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Persistence/Contexts/EntityMaps/AppUserMap.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Sufi.Demo.PeopleDirectory.Persistence.Models.Identity; + +namespace Sufi.Demo.PeopleDirectory.Persistence.Contexts.EntityMaps +{ + public class AppUserMap : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Users", "Identity"); + + builder.Property(e => e.Id).ValueGeneratedOnAdd(); + } + } +} diff --git a/Sufi.Demo.PeopleDirectory.Persistence/Extensions/ServiceCollectionExtensions.cs b/Sufi.Demo.PeopleDirectory.Persistence/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..2c867cd --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Persistence/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Sufi.Demo.PeopleDirectory.Application.Contracts.Repositories; +using Sufi.Demo.PeopleDirectory.Persistence.Contexts; +using Sufi.Demo.PeopleDirectory.Persistence.Models.Identity; +using Sufi.Demo.PeopleDirectory.Persistence.Repositories; + +namespace Sufi.Demo.PeopleDirectory.Persistence.Extensions +{ + public static class ServiceCollectionExtensions + { + public static IServiceCollection AddPersistenceServices(this IServiceCollection services, IConfiguration configuration) + { + services.AddDbContext(options => options.UseNpgsql(configuration.GetConnectionString("DefaultConnectionString")!)); + + services + .AddIdentityCore(options => + { + options.Password.RequiredLength = 6; + options.Password.RequireDigit = false; + options.Password.RequireLowercase = false; + options.Password.RequireNonAlphanumeric = false; + options.Password.RequireUppercase = false; + options.User.RequireUniqueEmail = true; + }) + .AddRoles() + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + + services + .AddTransient(typeof(IAsyncRepository<,>), typeof(AsyncRepository<,>)) + .AddTransient(typeof(IUnitOfWork<>), typeof(UnitOfWork<>)); + + return services; + } + } +} diff --git a/Sufi.Demo.PeopleDirectory.Persistence/Migrations/20250527161934_InitialSchema.Designer.cs b/Sufi.Demo.PeopleDirectory.Persistence/Migrations/20250527161934_InitialSchema.Designer.cs new file mode 100644 index 0000000..76df1c9 --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Persistence/Migrations/20250527161934_InitialSchema.Designer.cs @@ -0,0 +1,457 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Sufi.Demo.PeopleDirectory.Persistence.Contexts; + +#nullable disable + +namespace Sufi.Demo.PeopleDirectory.Persistence.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20250527161934_InitialSchema")] + partial class InitialSchema + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", "Identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", "Identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", "Identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", "Identity"); + }); + + modelBuilder.Entity("Sufi.Demo.PeopleDirectory.Domain.Entities.Misc.Contact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("character varying(100)"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("character varying(100)"); + + b.Property("Hobby") + .IsRequired() + .HasColumnType("character varying(255)"); + + b.Property("LastModifiedBy") + .HasColumnType("character varying(100)"); + + b.Property("LastModifiedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("Phone") + .IsRequired() + .HasColumnType("character varying(20)"); + + b.Property("SkillSets") + .IsRequired() + .HasColumnType("character varying(255)"); + + b.Property("UserName") + .IsRequired() + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Contacts"); + }); + + modelBuilder.Entity("Sufi.Demo.PeopleDirectory.Domain.Entities.Misc.ServerInfo", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CreatedBy") + .HasColumnType("character varying(100)"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("LastModifiedBy") + .HasColumnType("character varying(100)"); + + b.Property("LastModifiedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.ToTable("ServerInfos"); + }); + + modelBuilder.Entity("Sufi.Demo.PeopleDirectory.Infrastructure.Models.Audit.Audit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AffectedColumns") + .HasColumnType("character varying(100)"); + + b.Property("DateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("NewValues") + .HasColumnType("character varying(255)"); + + b.Property("OldValues") + .HasColumnType("character varying(255)"); + + b.Property("PrimaryKey") + .IsRequired() + .HasColumnType("character varying(100)"); + + b.Property("TableName") + .IsRequired() + .HasColumnType("character varying(50)"); + + b.Property("Type") + .IsRequired() + .HasColumnType("character varying(20)"); + + b.Property("UserId") + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.ToTable("AuditTrails", "Audit"); + }); + + modelBuilder.Entity("Sufi.Demo.PeopleDirectory.Infrastructure.Models.Identity.AppRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedBy") + .HasColumnType("character varying(100)"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("character varying(100)"); + + b.Property("LastModifiedBy") + .HasColumnType("character varying(100)"); + + b.Property("LastModifiedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", "Identity"); + }); + + modelBuilder.Entity("Sufi.Demo.PeopleDirectory.Infrastructure.Models.Identity.AppRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AppRoleId") + .HasColumnType("text"); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("CreatedBy") + .HasColumnType("character varying(100)"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("character varying(100)"); + + b.Property("Group") + .HasColumnType("character varying(100)"); + + b.Property("LastModifiedBy") + .HasColumnType("character varying(100)"); + + b.Property("LastModifiedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AppRoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", "Identity"); + }); + + modelBuilder.Entity("Sufi.Demo.PeopleDirectory.Infrastructure.Models.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedBy") + .HasColumnType("character varying(100)"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("LastModifiedBy") + .HasColumnType("character varying(100)"); + + b.Property("LastModifiedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", "Identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Sufi.Demo.PeopleDirectory.Infrastructure.Models.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Sufi.Demo.PeopleDirectory.Infrastructure.Models.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Sufi.Demo.PeopleDirectory.Infrastructure.Models.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Sufi.Demo.PeopleDirectory.Infrastructure.Models.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Sufi.Demo.PeopleDirectory.Infrastructure.Models.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Sufi.Demo.PeopleDirectory.Infrastructure.Models.Identity.AppRoleClaim", b => + { + b.HasOne("Sufi.Demo.PeopleDirectory.Infrastructure.Models.Identity.AppRole", null) + .WithMany("RoleClaims") + .HasForeignKey("AppRoleId"); + + b.HasOne("Sufi.Demo.PeopleDirectory.Infrastructure.Models.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Sufi.Demo.PeopleDirectory.Infrastructure.Models.Identity.AppRole", b => + { + b.Navigation("RoleClaims"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Sufi.Demo.PeopleDirectory.Persistence/Migrations/20250527161934_InitialSchema.cs b/Sufi.Demo.PeopleDirectory.Persistence/Migrations/20250527161934_InitialSchema.cs new file mode 100644 index 0000000..a66bd37 --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Persistence/Migrations/20250527161934_InitialSchema.cs @@ -0,0 +1,355 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Sufi.Demo.PeopleDirectory.Persistence.Migrations +{ + /// + public partial class InitialSchema : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "Audit"); + + migrationBuilder.EnsureSchema( + name: "Identity"); + + migrationBuilder.CreateTable( + name: "AuditTrails", + schema: "Audit", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "character varying(100)", nullable: true), + Type = table.Column(type: "character varying(20)", nullable: false), + TableName = table.Column(type: "character varying(50)", nullable: false), + DateTime = table.Column(type: "timestamp with time zone", nullable: false), + OldValues = table.Column(type: "character varying(255)", nullable: true), + NewValues = table.Column(type: "character varying(255)", nullable: true), + AffectedColumns = table.Column(type: "character varying(100)", nullable: true), + PrimaryKey = table.Column(type: "character varying(100)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AuditTrails", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Contacts", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserName = table.Column(type: "character varying(50)", nullable: false), + Phone = table.Column(type: "character varying(20)", nullable: false), + Email = table.Column(type: "character varying(100)", nullable: false), + SkillSets = table.Column(type: "character varying(255)", nullable: false), + Hobby = table.Column(type: "character varying(255)", nullable: false), + CreatedBy = table.Column(type: "character varying(100)", nullable: true), + CreatedOn = table.Column(type: "timestamp with time zone", nullable: false), + LastModifiedBy = table.Column(type: "character varying(100)", nullable: true), + LastModifiedOn = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Contacts", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Roles", + schema: "Identity", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + Description = table.Column(type: "character varying(100)", nullable: true), + CreatedBy = table.Column(type: "character varying(100)", nullable: true), + CreatedOn = table.Column(type: "timestamp with time zone", nullable: false), + LastModifiedBy = table.Column(type: "character varying(100)", nullable: true), + LastModifiedOn = table.Column(type: "timestamp with time zone", nullable: true), + Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Roles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ServerInfos", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + Value = table.Column(type: "character varying(255)", nullable: false), + CreatedBy = table.Column(type: "character varying(100)", nullable: true), + CreatedOn = table.Column(type: "timestamp with time zone", nullable: false), + LastModifiedBy = table.Column(type: "character varying(100)", nullable: true), + LastModifiedOn = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ServerInfos", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Users", + schema: "Identity", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + IsActive = table.Column(type: "boolean", nullable: false), + IsDeleted = table.Column(type: "boolean", nullable: false), + DeletedOn = table.Column(type: "timestamp with time zone", nullable: true), + CreatedBy = table.Column(type: "character varying(100)", nullable: true), + CreatedOn = table.Column(type: "timestamp with time zone", nullable: false), + LastModifiedBy = table.Column(type: "character varying(100)", nullable: true), + LastModifiedOn = table.Column(type: "timestamp with time zone", nullable: true), + UserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + Email = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "boolean", nullable: false), + PasswordHash = table.Column(type: "text", nullable: true), + SecurityStamp = table.Column(type: "text", nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true), + PhoneNumber = table.Column(type: "text", nullable: true), + PhoneNumberConfirmed = table.Column(type: "boolean", nullable: false), + TwoFactorEnabled = table.Column(type: "boolean", nullable: false), + LockoutEnd = table.Column(type: "timestamp with time zone", nullable: true), + LockoutEnabled = table.Column(type: "boolean", nullable: false), + AccessFailedCount = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "RoleClaims", + schema: "Identity", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Description = table.Column(type: "character varying(100)", nullable: true), + Group = table.Column(type: "character varying(100)", nullable: true), + CreatedBy = table.Column(type: "character varying(100)", nullable: true), + CreatedOn = table.Column(type: "timestamp with time zone", nullable: false), + LastModifiedBy = table.Column(type: "character varying(100)", nullable: true), + LastModifiedOn = table.Column(type: "timestamp with time zone", nullable: true), + AppRoleId = table.Column(type: "text", nullable: true), + RoleId = table.Column(type: "text", nullable: false), + ClaimType = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_RoleClaims_Roles_AppRoleId", + column: x => x.AppRoleId, + principalSchema: "Identity", + principalTable: "Roles", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_RoleClaims_Roles_RoleId", + column: x => x.RoleId, + principalSchema: "Identity", + principalTable: "Roles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserClaims", + schema: "Identity", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "text", nullable: false), + ClaimType = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_UserClaims", x => x.Id); + table.ForeignKey( + name: "FK_UserClaims_Users_UserId", + column: x => x.UserId, + principalSchema: "Identity", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserLogins", + schema: "Identity", + columns: table => new + { + LoginProvider = table.Column(type: "text", nullable: false), + ProviderKey = table.Column(type: "text", nullable: false), + ProviderDisplayName = table.Column(type: "text", nullable: true), + UserId = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_UserLogins_Users_UserId", + column: x => x.UserId, + principalSchema: "Identity", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserRoles", + schema: "Identity", + columns: table => new + { + UserId = table.Column(type: "text", nullable: false), + RoleId = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_UserRoles_Roles_RoleId", + column: x => x.RoleId, + principalSchema: "Identity", + principalTable: "Roles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_UserRoles_Users_UserId", + column: x => x.UserId, + principalSchema: "Identity", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserTokens", + schema: "Identity", + columns: table => new + { + UserId = table.Column(type: "text", nullable: false), + LoginProvider = table.Column(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + Value = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_UserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_UserTokens_Users_UserId", + column: x => x.UserId, + principalSchema: "Identity", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_RoleClaims_AppRoleId", + schema: "Identity", + table: "RoleClaims", + column: "AppRoleId"); + + migrationBuilder.CreateIndex( + name: "IX_RoleClaims_RoleId", + schema: "Identity", + table: "RoleClaims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + schema: "Identity", + table: "Roles", + column: "NormalizedName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UserClaims_UserId", + schema: "Identity", + table: "UserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_UserLogins_UserId", + schema: "Identity", + table: "UserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_UserRoles_RoleId", + schema: "Identity", + table: "UserRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + schema: "Identity", + table: "Users", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + schema: "Identity", + table: "Users", + column: "NormalizedUserName", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AuditTrails", + schema: "Audit"); + + migrationBuilder.DropTable( + name: "Contacts"); + + migrationBuilder.DropTable( + name: "RoleClaims", + schema: "Identity"); + + migrationBuilder.DropTable( + name: "ServerInfos"); + + migrationBuilder.DropTable( + name: "UserClaims", + schema: "Identity"); + + migrationBuilder.DropTable( + name: "UserLogins", + schema: "Identity"); + + migrationBuilder.DropTable( + name: "UserRoles", + schema: "Identity"); + + migrationBuilder.DropTable( + name: "UserTokens", + schema: "Identity"); + + migrationBuilder.DropTable( + name: "Roles", + schema: "Identity"); + + migrationBuilder.DropTable( + name: "Users", + schema: "Identity"); + } + } +} diff --git a/Sufi.Demo.PeopleDirectory.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs b/Sufi.Demo.PeopleDirectory.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs new file mode 100644 index 0000000..1f40243 --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs @@ -0,0 +1,454 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Sufi.Demo.PeopleDirectory.Persistence.Contexts; + +#nullable disable + +namespace Sufi.Demo.PeopleDirectory.Persistence.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + partial class ApplicationDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", "Identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", "Identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", "Identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", "Identity"); + }); + + modelBuilder.Entity("Sufi.Demo.PeopleDirectory.Domain.Entities.Misc.Contact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedBy") + .HasColumnType("character varying(100)"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("character varying(100)"); + + b.Property("Hobby") + .IsRequired() + .HasColumnType("character varying(255)"); + + b.Property("LastModifiedBy") + .HasColumnType("character varying(100)"); + + b.Property("LastModifiedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("Phone") + .IsRequired() + .HasColumnType("character varying(20)"); + + b.Property("SkillSets") + .IsRequired() + .HasColumnType("character varying(255)"); + + b.Property("UserName") + .IsRequired() + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Contacts"); + }); + + modelBuilder.Entity("Sufi.Demo.PeopleDirectory.Domain.Entities.Misc.ServerInfo", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CreatedBy") + .HasColumnType("character varying(100)"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("LastModifiedBy") + .HasColumnType("character varying(100)"); + + b.Property("LastModifiedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.ToTable("ServerInfos"); + }); + + modelBuilder.Entity("Sufi.Demo.PeopleDirectory.Infrastructure.Models.Audit.Audit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AffectedColumns") + .HasColumnType("character varying(100)"); + + b.Property("DateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("NewValues") + .HasColumnType("character varying(255)"); + + b.Property("OldValues") + .HasColumnType("character varying(255)"); + + b.Property("PrimaryKey") + .IsRequired() + .HasColumnType("character varying(100)"); + + b.Property("TableName") + .IsRequired() + .HasColumnType("character varying(50)"); + + b.Property("Type") + .IsRequired() + .HasColumnType("character varying(20)"); + + b.Property("UserId") + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.ToTable("AuditTrails", "Audit"); + }); + + modelBuilder.Entity("Sufi.Demo.PeopleDirectory.Infrastructure.Models.Identity.AppRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedBy") + .HasColumnType("character varying(100)"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("character varying(100)"); + + b.Property("LastModifiedBy") + .HasColumnType("character varying(100)"); + + b.Property("LastModifiedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", "Identity"); + }); + + modelBuilder.Entity("Sufi.Demo.PeopleDirectory.Infrastructure.Models.Identity.AppRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AppRoleId") + .HasColumnType("text"); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("CreatedBy") + .HasColumnType("character varying(100)"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("character varying(100)"); + + b.Property("Group") + .HasColumnType("character varying(100)"); + + b.Property("LastModifiedBy") + .HasColumnType("character varying(100)"); + + b.Property("LastModifiedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AppRoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", "Identity"); + }); + + modelBuilder.Entity("Sufi.Demo.PeopleDirectory.Infrastructure.Models.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedBy") + .HasColumnType("character varying(100)"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("LastModifiedBy") + .HasColumnType("character varying(100)"); + + b.Property("LastModifiedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", "Identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Sufi.Demo.PeopleDirectory.Infrastructure.Models.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Sufi.Demo.PeopleDirectory.Infrastructure.Models.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Sufi.Demo.PeopleDirectory.Infrastructure.Models.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Sufi.Demo.PeopleDirectory.Infrastructure.Models.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Sufi.Demo.PeopleDirectory.Infrastructure.Models.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Sufi.Demo.PeopleDirectory.Infrastructure.Models.Identity.AppRoleClaim", b => + { + b.HasOne("Sufi.Demo.PeopleDirectory.Infrastructure.Models.Identity.AppRole", null) + .WithMany("RoleClaims") + .HasForeignKey("AppRoleId"); + + b.HasOne("Sufi.Demo.PeopleDirectory.Infrastructure.Models.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Sufi.Demo.PeopleDirectory.Infrastructure.Models.Identity.AppRole", b => + { + b.Navigation("RoleClaims"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Sufi.Demo.PeopleDirectory.Persistence/Models/Audit/Audit.cs b/Sufi.Demo.PeopleDirectory.Persistence/Models/Audit/Audit.cs new file mode 100644 index 0000000..18c156e --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Persistence/Models/Audit/Audit.cs @@ -0,0 +1,25 @@ +using Sufi.Demo.PeopleDirectory.Domain.Common; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Sufi.Demo.PeopleDirectory.Persistence.Models.Audit +{ + public class Audit : IEntity + { + public int Id { get; set; } + [Column(TypeName = "character varying(100)")] + public string? UserId { get; set; } + [Column(TypeName = "character varying(20)")] + public string Type { get; set; } = null!; + [Column(TypeName = "character varying(50)")] + public string TableName { get; set; } = null!; + public DateTime DateTime { get; set; } + [Column(TypeName = "character varying(255)")] + public string? OldValues { get; set; } + [Column(TypeName = "character varying(255)")] + public string? NewValues { get; set; } + [Column(TypeName = "character varying(100)")] + public string? AffectedColumns { get; set; } + [Column(TypeName = "character varying(100)")] + public string PrimaryKey { get; set; } = null!; + } +} diff --git a/Sufi.Demo.PeopleDirectory.Persistence/Models/Audit/AuditEntry.cs b/Sufi.Demo.PeopleDirectory.Persistence/Models/Audit/AuditEntry.cs new file mode 100644 index 0000000..e066260 --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Persistence/Models/Audit/AuditEntry.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Sufi.Demo.PeopleDirectory.Application.Enums; +using System.Text.Json; + +namespace Sufi.Demo.PeopleDirectory.Persistence.Models.Audit +{ + public class AuditEntry(EntityEntry entry) + { + public EntityEntry Entry { get; } = entry; + public string? UserId { get; set; } + public string TableName { get; set; } = null!; + public Dictionary KeyValues { get; } = new(); + public Dictionary OldValues { get; } = new(); + public Dictionary NewValues { get; } = new(); + public List TemporaryProperties { get; } = new(); + public AuditType AuditType { get; set; } + public List ChangedColumns { get; } = new(); + public bool HasTemporaryProperties => TemporaryProperties.Any(); + + public Audit ToAudit() + { + var audit = new Audit + { + UserId = UserId, + Type = AuditType.ToString(), + TableName = TableName, + DateTime = DateTime.UtcNow, + PrimaryKey = JsonSerializer.Serialize(KeyValues), + OldValues = OldValues.Count == 0 ? null : JsonSerializer.Serialize(OldValues), + NewValues = NewValues.Count == 0 ? null : JsonSerializer.Serialize(NewValues), + AffectedColumns = ChangedColumns.Count == 0 ? null : JsonSerializer.Serialize(ChangedColumns) + }; + return audit; + } + } +} diff --git a/Sufi.Demo.PeopleDirectory.Persistence/Models/Identity/AppRole.cs b/Sufi.Demo.PeopleDirectory.Persistence/Models/Identity/AppRole.cs new file mode 100644 index 0000000..67c310d --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Persistence/Models/Identity/AppRole.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Identity; +using Sufi.Demo.PeopleDirectory.Domain.Common; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Sufi.Demo.PeopleDirectory.Persistence.Models.Identity +{ + public class AppRole : IdentityRole, IAuditableEntity + { + [Column(TypeName = "character varying(100)")] + public string? Description { get; set; } + public string? CreatedBy { get; set; } + public DateTime CreatedOn { get; set; } + public string? LastModifiedBy { get; set; } + public DateTime? LastModifiedOn { get; set; } + public virtual ICollection RoleClaims { get; set; } + + public AppRole() : base() + { + RoleClaims = new HashSet(); + } + + public AppRole(string roleName, string? description = null) : base(roleName) + { + RoleClaims = new HashSet(); + Description = description; + } + } +} diff --git a/Sufi.Demo.PeopleDirectory.Persistence/Models/Identity/AppRoleClaim.cs b/Sufi.Demo.PeopleDirectory.Persistence/Models/Identity/AppRoleClaim.cs new file mode 100644 index 0000000..f2ba7aa --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Persistence/Models/Identity/AppRoleClaim.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Identity; +using Sufi.Demo.PeopleDirectory.Domain.Common; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Sufi.Demo.PeopleDirectory.Persistence.Models.Identity +{ + public class AppRoleClaim : IdentityRoleClaim, IAuditableEntity + { + [Column(TypeName = "character varying(100)")] + public string? Description { get; set; } + [Column(TypeName = "character varying(100)")] + public string? Group { get; set; } + public string? CreatedBy { get; set; } + public DateTime CreatedOn { get; set; } + public string? LastModifiedBy { get; set; } + public DateTime? LastModifiedOn { get; set; } + + public AppRoleClaim() : base() { } + + public AppRoleClaim(string? description = null, string? group = null) : base() + { + Description = description; + Group = group; + } + } +} diff --git a/Sufi.Demo.PeopleDirectory.Persistence/Models/Identity/AppUser.cs b/Sufi.Demo.PeopleDirectory.Persistence/Models/Identity/AppUser.cs new file mode 100644 index 0000000..d012f05 --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Persistence/Models/Identity/AppUser.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Identity; +using Sufi.Demo.PeopleDirectory.Domain.Common; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Sufi.Demo.PeopleDirectory.Persistence.Models.Identity +{ + public class AppUser : IdentityUser, IAuditableEntity + { + public bool IsActive { get; set; } + public bool IsDeleted { get; set; } + public DateTime? DeletedOn { get; set; } + public string? CreatedBy { get; set; } + public DateTime CreatedOn { get; set; } + public string? LastModifiedBy { get; set; } + public DateTime? LastModifiedOn { get; set; } + } +} diff --git a/Sufi.Demo.PeopleDirectory.Persistence/Repositories/AsyncRepository.cs b/Sufi.Demo.PeopleDirectory.Persistence/Repositories/AsyncRepository.cs new file mode 100644 index 0000000..492211f --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Persistence/Repositories/AsyncRepository.cs @@ -0,0 +1,70 @@ +using Microsoft.EntityFrameworkCore; +using Sufi.Demo.PeopleDirectory.Application.Contracts.Repositories; +using Sufi.Demo.PeopleDirectory.Domain.Common; +using Sufi.Demo.PeopleDirectory.Persistence.Contexts; + +namespace Sufi.Demo.PeopleDirectory.Persistence.Repositories +{ + public class AsyncRepository( + ApplicationDbContext dbContext + ) : IAsyncRepository where T : AuditableEntity + { + public IQueryable Entities => dbContext.Set(); + + public async Task AddAsync(T entity) + { + await dbContext.Set().AddAsync(entity); + return entity; + } + + public async Task CountAsync() => await dbContext.Set().CountAsync(); + + public Task DeleteAsync(T entity) + { + dbContext.Set().Remove(entity); + return Task.CompletedTask; + } + + public async Task DeleteByIdAsync(TId id) + { + var rowsAffected = await dbContext.Set() + .Where(e => e.Id != null && e.Id.Equals(id)) + .ExecuteDeleteAsync(); + + return rowsAffected; + } + + public async Task> GetAllAsync() + { + return await dbContext + .Set() + .ToListAsync(); + } + + public async Task GetByIdAsync(TId id) + { + return await dbContext.Set().FindAsync(id); + } + + public async Task> GetPagedResponseAsync(int pageNumber, int pageSize) + { + return await dbContext + .Set() + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .AsNoTracking() + .ToListAsync(); + } + + public Task UpdateAsync(T entity) + { + T? exist = dbContext.Set().Find(entity.Id); + if (exist != null) + { + dbContext.Entry(exist).CurrentValues.SetValues(entity); + } + + return Task.CompletedTask; + } + } +} diff --git a/Sufi.Demo.PeopleDirectory.Persistence/Repositories/UnitOfWork.cs b/Sufi.Demo.PeopleDirectory.Persistence/Repositories/UnitOfWork.cs new file mode 100644 index 0000000..122512c --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Persistence/Repositories/UnitOfWork.cs @@ -0,0 +1,71 @@ +using Sufi.Demo.PeopleDirectory.Application.Contracts.Repositories; +using Sufi.Demo.PeopleDirectory.Domain.Common; +using Sufi.Demo.PeopleDirectory.Persistence.Contexts; +using System.Collections; + +namespace Sufi.Demo.PeopleDirectory.Persistence.Repositories +{ + public class UnitOfWork( + ApplicationDbContext dbContext + ) : IUnitOfWork + { + private bool disposed; + private Hashtable? _repositories; + + public IAsyncRepository Repository() where TEntity : AuditableEntity + { + _repositories ??= []; + + var type = typeof(TEntity).Name; + + if (!_repositories.ContainsKey(type)) + { + var repositoryType = typeof(AsyncRepository<,>); + + var repositoryInstance = Activator.CreateInstance(repositoryType.MakeGenericType(typeof(TEntity), typeof(TId)), dbContext); + + _repositories.Add(type, repositoryInstance); + } + + return (IAsyncRepository)_repositories[type]!; + } + + public async Task Commit(CancellationToken cancellationToken) + { + return await dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task CommitAndRemoveCache(CancellationToken cancellationToken, params string[] cacheKeys) + { + var result = await dbContext.SaveChangesAsync(cancellationToken); + + return result; + } + + public Task Rollback() + { + dbContext.ChangeTracker.Entries().ToList().ForEach(x => x.Reload()); + return Task.CompletedTask; + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (!disposed) + { + if (disposing) + { + //dispose managed resources + dbContext.Dispose(); + } + } + //dispose unmanaged resources + disposed = true; + } + } +} diff --git a/Sufi.Demo.PeopleDirectory.Persistence/Sufi.Demo.PeopleDirectory.Persistence.csproj b/Sufi.Demo.PeopleDirectory.Persistence/Sufi.Demo.PeopleDirectory.Persistence.csproj new file mode 100644 index 0000000..bd422af --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Persistence/Sufi.Demo.PeopleDirectory.Persistence.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + diff --git a/Sufi.Demo.PeopleDirectory.Shared/Extensions/ResultExtensions.cs b/Sufi.Demo.PeopleDirectory.Shared/Extensions/ResultExtensions.cs new file mode 100644 index 0000000..42330b0 --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Shared/Extensions/ResultExtensions.cs @@ -0,0 +1,43 @@ +using Sufi.Demo.PeopleDirectory.Shared.Wrapper; +using System.Net.Http; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace Sufi.Demo.PeopleDirectory.Shared.Extensions +{ + public static class ResultExtensions + { + public static async Task> ToResult(this HttpResponseMessage response) where T : notnull + { + var responseAsString = await response.Content.ReadAsStringAsync(); + var responseObject = JsonSerializer.Deserialize>(responseAsString, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + ReferenceHandler = ReferenceHandler.Preserve + }); + return responseObject!; + } + + public static async Task ToResult(this HttpResponseMessage response) + { + var responseAsString = await response.Content.ReadAsStringAsync(); + var responseObject = JsonSerializer.Deserialize(responseAsString, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + ReferenceHandler = ReferenceHandler.Preserve + }); + return responseObject!; + } + + public static async Task> ToPaginatedResult(this HttpResponseMessage response) where T : notnull + { + var responseAsString = await response.Content.ReadAsStringAsync(); + var responseObject = JsonSerializer.Deserialize>(responseAsString, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + return responseObject!; + } + } +} diff --git a/Sufi.Demo.PeopleDirectory.Shared/Sufi.Demo.PeopleDirectory.Shared.csproj b/Sufi.Demo.PeopleDirectory.Shared/Sufi.Demo.PeopleDirectory.Shared.csproj new file mode 100644 index 0000000..0672ddb --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Shared/Sufi.Demo.PeopleDirectory.Shared.csproj @@ -0,0 +1,8 @@ + + + + net8.0 + enable + + + diff --git a/Sufi.Demo.PeopleDirectory.Shared/Wrapper/IResult.cs b/Sufi.Demo.PeopleDirectory.Shared/Wrapper/IResult.cs new file mode 100644 index 0000000..e2a6648 --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Shared/Wrapper/IResult.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; + +namespace Sufi.Demo.PeopleDirectory.Shared.Wrapper +{ + public interface IResult + { + List Messages { get; set; } + + bool Succeeded { get; set; } + } + + public interface IResult : IResult + { + T Data { get; } + } +} diff --git a/Sufi.Demo.PeopleDirectory.Shared/Wrapper/PaginatedResult.cs b/Sufi.Demo.PeopleDirectory.Shared/Wrapper/PaginatedResult.cs new file mode 100644 index 0000000..4ef06f0 --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Shared/Wrapper/PaginatedResult.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; + +namespace Sufi.Demo.PeopleDirectory.Shared.Wrapper +{ + public class PaginatedResult : Result where T : notnull + { + public PaginatedResult(List data) + { + Data = data; + } + + public List Data { get; set; } + + internal PaginatedResult(bool succeeded, List data = default!, List? messages = null, int count = 0, int page = 1, int pageSize = 10) + { + Data = data; + CurrentPage = page; + Succeeded = succeeded; + PageSize = pageSize; + TotalPages = (int)Math.Ceiling(count / (double)pageSize); + TotalCount = count; + Messages = messages ?? []; + } + + public static PaginatedResult Failure(List messages) + { + return new PaginatedResult(false, default!, messages); + } + + public static PaginatedResult Success(List data, int count, int page, int pageSize) + { + return new PaginatedResult(true, data, null, count, page, pageSize); + } + + public int CurrentPage { get; set; } + + public int TotalPages { get; set; } + + public int TotalCount { get; set; } + public int PageSize { get; set; } + + public bool HasPreviousPage => CurrentPage > 1; + + public bool HasNextPage => CurrentPage < TotalPages; + } +} diff --git a/Sufi.Demo.PeopleDirectory.Shared/Wrapper/Result.cs b/Sufi.Demo.PeopleDirectory.Shared/Wrapper/Result.cs new file mode 100644 index 0000000..7e786db --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.Shared/Wrapper/Result.cs @@ -0,0 +1,150 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Sufi.Demo.PeopleDirectory.Shared.Wrapper +{ + public class Result : IResult + { + public Result() + { + } + + public List Messages { get; set; } = []; + + public bool Succeeded { get; set; } + + public static IResult Fail() + { + return new Result { Succeeded = false }; + } + + public static IResult Fail(string message) + { + return new Result { Succeeded = false, Messages = [message] }; + } + + public static IResult Fail(List messages) + { + return new Result { Succeeded = false, Messages = messages }; + } + + public static Task FailAsync() + { + return Task.FromResult(Fail()); + } + + public static Task FailAsync(string message) + { + return Task.FromResult(Fail(message)); + } + + public static Task FailAsync(List messages) + { + return Task.FromResult(Fail(messages)); + } + + public static IResult Success() + { + return new Result { Succeeded = true }; + } + + public static IResult Success(string message) + { + return new Result { Succeeded = true, Messages = [message] }; + } + + public static Task SuccessAsync() + { + return Task.FromResult(Success()); + } + + public static Task SuccessAsync(string message) + { + return Task.FromResult(Success(message)); + } + } + + public class Result : Result, IResult where T : notnull + { + public Result() + { + } + + public T Data { get; set; } = default!; + + public new static Result Fail() + { + return new Result { Succeeded = false }; + } + + public new static Result Fail(string message) + { + return new Result { Succeeded = false, Messages = [message] }; + } + + public new static Result Fail(List messages) + { + return new Result { Succeeded = false, Messages = messages }; + } + + public new static Task> FailAsync() + { + return Task.FromResult(Fail()); + } + + public new static Task> FailAsync(string message) + { + return Task.FromResult(Fail(message)); + } + + public new static Task> FailAsync(List messages) + { + return Task.FromResult(Fail(messages)); + } + + public new static Result Success() + { + return new Result { Succeeded = true }; + } + + public new static Result Success(string message) + { + return new Result { Succeeded = true, Messages = [message] }; + } + + public static Result Success(T data) + { + return new Result { Succeeded = true, Data = data }; + } + + public static Result Success(T data, string message) + { + return new Result { Succeeded = true, Data = data, Messages = [message] }; + } + + public static Result Success(T data, List messages) + { + return new Result { Succeeded = true, Data = data, Messages = messages }; + } + + public new static Task> SuccessAsync() + { + return Task.FromResult(Success()); + } + + public new static Task> SuccessAsync(string message) + { + return Task.FromResult(Success(message)); + } + + public static Task> SuccessAsync(T data) + { + return Task.FromResult(Success(data)); + } + + public static Task> SuccessAsync(T data, string message) + { + return Task.FromResult(Success(data, message)); + } + } +} diff --git a/Sufi.Demo.PeopleDirectory.UnitTests/Contacts/AddEditContactCommandHandlerTests.cs b/Sufi.Demo.PeopleDirectory.UnitTests/Contacts/AddEditContactCommandHandlerTests.cs new file mode 100644 index 0000000..c38c62d --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.UnitTests/Contacts/AddEditContactCommandHandlerTests.cs @@ -0,0 +1,109 @@ +using AutoMapper; +using Microsoft.Extensions.Logging; +using Moq; +using Sufi.Demo.PeopleDirectory.Application.Contracts.Repositories; +using Sufi.Demo.PeopleDirectory.Application.Contracts.Services; +using Sufi.Demo.PeopleDirectory.Application.Features.Contacts.Commands; +using Sufi.Demo.PeopleDirectory.Domain.Entities.Misc; + +namespace Sufi.Demo.PeopleDirectory.UnitTests.Contacts +{ + public class AddEditContactCommandHandlerTests + { + private readonly Mock _mapperMock; + private readonly Mock> _unitOfWorkMock; + private readonly Mock> _loggerMock = new(); + private readonly Mock _appCacheMock = new(); + private readonly AddEditContactCommandHandler _handler; + + public AddEditContactCommandHandlerTests() + { + _mapperMock = new Mock(); + _unitOfWorkMock = new Mock>(); + _handler = new AddEditContactCommandHandler(_mapperMock.Object, _unitOfWorkMock.Object, _loggerMock.Object, _appCacheMock.Object); + } + + [Fact] + public async Task Handle_ShouldAddNewContact_WhenMaxCountNotExceeded() + { + // Arrange + var command = new AddEditContactCommand + { + Id = 0, + UserName = "JohnDoe", + Email = "john@example.com", + Phone = "1234567890", + SkillSets = "C#, SQL", + Hobby = "Reading" + }; + _unitOfWorkMock.Setup(u => u.Repository().CountAsync()).ReturnsAsync(50); + _mapperMock.Setup(m => m.Map(command)).Returns(new Contact()); + _unitOfWorkMock.Setup(u => u.Repository().AddAsync(It.IsAny())).Returns(Task.FromResult(new Contact())); + _unitOfWorkMock.Setup(u => u.Commit(It.IsAny())).ReturnsAsync(1); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + Assert.True(result.Succeeded); + Assert.Equal("New contact saved.", result.Messages[0]); + } + + [Fact] + public async Task Handle_ShouldFailToAddNewContact_WhenMaxCountExceeded() + { + // Arrange + var command = new AddEditContactCommand { Id = 0 }; + _unitOfWorkMock.Setup(u => u.Repository().CountAsync()).ReturnsAsync(101); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + Assert.False(result.Succeeded); + Assert.Equal("Max item count reached. Please delete some first.", result.Messages[0]); + } + + [Fact] + public async Task Handle_ShouldUpdateContact_WhenContactExists() + { + // Arrange + var command = new AddEditContactCommand + { + Id = 1, + UserName = "JaneDoe", + Email = "jane@example.com", + Phone = "0987654321", + SkillSets = "Java, Python", + Hobby = "Traveling" + }; + var existingContact = new Contact { Id = 1 }; + _unitOfWorkMock.Setup(u => u.Repository().GetByIdAsync(1)).ReturnsAsync(existingContact); + _unitOfWorkMock.Setup(u => u.Repository().UpdateAsync(existingContact)).Returns(Task.CompletedTask); + _unitOfWorkMock.Setup(u => u.Commit(It.IsAny())).ReturnsAsync(1); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + Assert.True(result.Succeeded); + Assert.Equal("Contact updated.", result.Messages[0]); + } + + [Fact] + public async Task Handle_ShouldFailToUpdateContact_WhenContactDoesNotExist() + { + // Arrange + var command = new AddEditContactCommand { Id = 1 }; + Contact? existingContact = null; + _unitOfWorkMock.Setup(u => u.Repository().GetByIdAsync(1)).ReturnsAsync(existingContact); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + Assert.False(result.Succeeded); + Assert.Equal("Contact not found!", result.Messages[0]); + } + } +} \ No newline at end of file diff --git a/Sufi.Demo.PeopleDirectory.UnitTests/Contacts/DeleteContactCommandHandlerTests.cs b/Sufi.Demo.PeopleDirectory.UnitTests/Contacts/DeleteContactCommandHandlerTests.cs new file mode 100644 index 0000000..076e45a --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.UnitTests/Contacts/DeleteContactCommandHandlerTests.cs @@ -0,0 +1,57 @@ +using Microsoft.Extensions.Logging; +using Moq; +using Sufi.Demo.PeopleDirectory.Application.Contracts.Repositories; +using Sufi.Demo.PeopleDirectory.Application.Contracts.Services; +using Sufi.Demo.PeopleDirectory.Application.Features.Contacts.Commands; +using Sufi.Demo.PeopleDirectory.Domain.Entities.Misc; + +namespace Sufi.Demo.PeopleDirectory.UnitTests.Contacts +{ + public class DeleteContactCommandHandlerTests + { + private readonly Mock> _unitOfWorkMock; + private readonly Mock> _loggerMock = new(); + private readonly Mock _appCacheMock = new(); + private readonly DeleteContactCommandHandler _handler; + + public DeleteContactCommandHandlerTests() + { + _unitOfWorkMock = new Mock>(); + _handler = new DeleteContactCommandHandler(_unitOfWorkMock.Object, _loggerMock.Object, _appCacheMock.Object); + } + + [Fact] + public async Task Handle_ShouldDeleteContact_WhenContactExists() + { + // Arrange + var command = new DeleteContactCommand { Id = 1 }; + var existingContact = new Contact { Id = 1 }; + _unitOfWorkMock.Setup(u => u.Repository().GetByIdAsync(1)).ReturnsAsync(existingContact); + _unitOfWorkMock.Setup(u => u.Repository().DeleteAsync(existingContact)).Returns(Task.CompletedTask); + _unitOfWorkMock.Setup(u => u.Commit(It.IsAny())).ReturnsAsync(1); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + Assert.True(result.Succeeded); + Assert.Empty(result.Messages); // Success messages are empty in this implementation + } + + [Fact] + public async Task Handle_ShouldFailToDeleteContact_WhenContactDoesNotExist() + { + // Arrange + var command = new DeleteContactCommand { Id = 1 }; + Contact? existingContact = null; + _unitOfWorkMock.Setup(u => u.Repository().GetByIdAsync(1)).ReturnsAsync(existingContact); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + Assert.False(result.Succeeded); + Assert.Equal("No data to delete.", result.Messages[0]); + } + } +} diff --git a/Sufi.Demo.PeopleDirectory.UnitTests/Contacts/GetContactByIdQueryHandlerTests.cs b/Sufi.Demo.PeopleDirectory.UnitTests/Contacts/GetContactByIdQueryHandlerTests.cs new file mode 100644 index 0000000..a99cc61 --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.UnitTests/Contacts/GetContactByIdQueryHandlerTests.cs @@ -0,0 +1,84 @@ +using AutoMapper; +using Moq; +using Sufi.Demo.PeopleDirectory.Application.Contracts.Repositories; +using Sufi.Demo.PeopleDirectory.Application.Contracts.Services; +using Sufi.Demo.PeopleDirectory.Application.Features.Contacts.Queries.GetById; +using Sufi.Demo.PeopleDirectory.Domain.Entities.Misc; + +namespace Sufi.Demo.PeopleDirectory.UnitTests.Contacts +{ + public class GetContactByIdQueryHandlerTests + { + private readonly Mock> _unitOfWorkMock; + private readonly Mock _mapperMock; + private readonly Mock _appCacheMock = new(); + private readonly GetContactByIdQueryHandler _handler; + + public GetContactByIdQueryHandlerTests() + { + _unitOfWorkMock = new Mock>(); + _mapperMock = new Mock(); + _handler = new GetContactByIdQueryHandler(_unitOfWorkMock.Object, _mapperMock.Object, _appCacheMock.Object); + } + + [Fact] + public async Task Handle_ReturnsMappedContact_WhenContactExists() + { + // Arrange + var contact = new Contact + { + Id = 1, + UserName = "User1", + Phone = "123", + Email = "user1@example.com", + SkillSets = "C#", + Hobby = "Reading" + }; + var mappedContact = new GetContactByIdResponse + { + Id = 1, + UserName = "User1", + Phone = "123", + Email = "user1@example.com", + SkillSets = "C#", + Hobby = "Reading" + }; + + _unitOfWorkMock.Setup(u => u.Repository().GetByIdAsync(1)).ReturnsAsync(contact); + _mapperMock.Setup(m => m.Map(contact)).Returns(mappedContact); + _appCacheMock.Setup(c => c.GetOrAddAsync(It.IsAny(), It.IsAny>>(), It.IsAny>(), It.IsAny())) + .ReturnsAsync(contact); + + var query = new GetContactByIdQuery { Id = 1 }; + + // Act + var result = await _handler.Handle(query, CancellationToken.None); + + // Assert + Assert.True(result.Succeeded); + Assert.NotNull(result.Data); + Assert.Equal(1, result.Data.Id); + Assert.Equal("User1", result.Data.UserName); + } + + [Fact] + public async Task Handle_ReturnsNull_WhenContactDoesNotExist() + { + // Arrange + Contact? contact = null; + GetContactByIdResponse? mappedContact = null; + + _unitOfWorkMock.Setup(u => u.Repository().GetByIdAsync(99)).ReturnsAsync(contact); + _mapperMock.Setup(m => m.Map(contact)).Returns(mappedContact); + + var query = new GetContactByIdQuery { Id = 99 }; + + // Act + var result = await _handler.Handle(query, CancellationToken.None); + + // Assert + Assert.True(result.Succeeded); + Assert.Null(result.Data); + } + } +} \ No newline at end of file diff --git a/Sufi.Demo.PeopleDirectory.UnitTests/Contacts/GetContactsQueriesHandlerTests.cs b/Sufi.Demo.PeopleDirectory.UnitTests/Contacts/GetContactsQueriesHandlerTests.cs new file mode 100644 index 0000000..7295a9c --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.UnitTests/Contacts/GetContactsQueriesHandlerTests.cs @@ -0,0 +1,100 @@ +using AutoMapper; +using Moq; +using Sufi.Demo.PeopleDirectory.Application.Contracts.Repositories; +using Sufi.Demo.PeopleDirectory.Application.Contracts.Services; +using Sufi.Demo.PeopleDirectory.Application.Features.Contacts.Queries.GetAll; +using Sufi.Demo.PeopleDirectory.Application.Features.Contacts.Queries.GetById; +using Sufi.Demo.PeopleDirectory.Domain.Entities.Misc; + +namespace Sufi.Demo.PeopleDirectory.UnitTests.Contacts +{ + public class GetContactsQueriesHandlerTests + { + private readonly Mock> _unitOfWorkMock; + private readonly Mock _mapperMock; + private readonly Mock _appCacheMock = new(); + + public GetContactsQueriesHandlerTests() + { + _unitOfWorkMock = new Mock>(); + _mapperMock = new Mock(); + } + + [Fact] + public async Task GetAllContactsQueryHandler_ReturnsMappedContacts() + { + // Arrange + var contacts = new List + { + new() { Id = 1, UserName = "User1", Phone = "123", Email = "user1@example.com", SkillSets = "C#", Hobby = "Reading" }, + new() { Id = 2, UserName = "User2", Phone = "456", Email = "user2@example.com", SkillSets = "Java", Hobby = "Swimming" } + }; + var mappedContacts = new List + { + new() { Id = 1, UserName = "User1", Phone = "123", Email = "user1@example.com", SkillSets = "C#", Hobby = "Reading" }, + new() { Id = 2, UserName = "User2", Phone = "456", Email = "user2@example.com", SkillSets = "Java", Hobby = "Swimming" } + }; + + _unitOfWorkMock.Setup(u => u.Repository().GetAllAsync()).ReturnsAsync(contacts); + _mapperMock.Setup(m => m.Map>(contacts)).Returns(mappedContacts); + _appCacheMock.Setup(c => c.GetOrAddAsync(It.IsAny(), It.IsAny>>>(), It.IsAny>(), It.IsAny())) + .ReturnsAsync(contacts); + + var handler = new GetAllContactsQueryHandler(_unitOfWorkMock.Object, _mapperMock.Object, _appCacheMock.Object); + + // Act + var result = await handler.Handle(new GetAllContactsQuery(), CancellationToken.None); + + // Assert + Assert.True(result.Succeeded); + Assert.NotNull(result.Data); + Assert.Equal(2, result.Data.Count); + Assert.Equal("User1", result.Data[0].UserName); + Assert.Equal("User2", result.Data[1].UserName); + } + + [Fact] + public async Task GetContactByIdQueryHandler_ReturnsMappedContact_WhenContactExists() + { + // Arrange + var contact = new Contact { Id = 1, UserName = "User1", Phone = "123", Email = "user1@example.com", SkillSets = "C#", Hobby = "Reading" }; + var mappedContact = new GetContactByIdResponse { Id = 1, UserName = "User1", Phone = "123", Email = "user1@example.com", SkillSets = "C#", Hobby = "Reading" }; + + _unitOfWorkMock.Setup(u => u.Repository().GetByIdAsync(1)).ReturnsAsync(contact); + _mapperMock.Setup(m => m.Map(contact)).Returns(mappedContact); + _appCacheMock.Setup(c => c.GetOrAddAsync(It.IsAny(), It.IsAny>>(), It.IsAny>(), It.IsAny())) + .ReturnsAsync(contact); + + var handler = new GetContactByIdQueryHandler(_unitOfWorkMock.Object, _mapperMock.Object, _appCacheMock.Object); + + // Act + var result = await handler.Handle(new GetContactByIdQuery { Id = 1 }, CancellationToken.None); + + // Assert + Assert.True(result.Succeeded); + Assert.NotNull(result.Data); + Assert.Equal(1, result.Data.Id); + Assert.Equal("User1", result.Data.UserName); + } + + [Fact] + public async Task GetContactByIdQueryHandler_ReturnsNullData_WhenContactDoesNotExist() + { + // Arrange + Contact? contact = null; + GetContactByIdResponse? mappedContact = null; + + _unitOfWorkMock.Setup(u => u.Repository().GetByIdAsync(99)).ReturnsAsync(contact); + _mapperMock.Setup(m => m.Map(contact)).Returns(mappedContact); + + var handler = new GetContactByIdQueryHandler(_unitOfWorkMock.Object, _mapperMock.Object, _appCacheMock.Object); + + // Act + var result = await handler.Handle(new GetContactByIdQuery { Id = 99 }, CancellationToken.None); + + // Assert + Assert.True(result.Succeeded); + Assert.Null(result.Data); + } + } +} diff --git a/Sufi.Demo.PeopleDirectory.UnitTests/Sufi.Demo.PeopleDirectory.UnitTests.csproj b/Sufi.Demo.PeopleDirectory.UnitTests/Sufi.Demo.PeopleDirectory.UnitTests.csproj new file mode 100644 index 0000000..8a49cdc --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.UnitTests/Sufi.Demo.PeopleDirectory.UnitTests.csproj @@ -0,0 +1,35 @@ + + + + net8.0 + enable + enable + + false + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + diff --git a/Sufi.Demo.PeopleDirectory.sln b/Sufi.Demo.PeopleDirectory.sln new file mode 100644 index 0000000..5ff2401 --- /dev/null +++ b/Sufi.Demo.PeopleDirectory.sln @@ -0,0 +1,101 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.6.33829.357 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sufi.Demo.PeopleDirectory.UI.Server", "ui\Sufi.Demo.PeopleDirectory.UI\Server\Sufi.Demo.PeopleDirectory.UI.Server.csproj", "{86ADD71C-9EFE-4D80-98C6-AA9539E2CC68}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sufi.Demo.PeopleDirectory.UI.Client", "ui\Sufi.Demo.PeopleDirectory.UI\Client\Sufi.Demo.PeopleDirectory.UI.Client.csproj", "{39B057E5-B151-4C7F-B172-904227D6778E}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{35940606-8CE4-421C-A0AA-0A829A7D44F6}" + ProjectSection(SolutionItems) = preProject + .gitea\workflows\build.yml = .gitea\workflows\build.yml + .gitea\workflows\deploy.yml = .gitea\workflows\deploy.yml + docker-compose.yml = docker-compose.yml + Dockerfile = Dockerfile + .github\workflows\dotnet.yml = .github\workflows\dotnet.yml + README.md = README.md + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Source", "Source", "{01F8753B-392E-48C7-A16A-327BBE2658ED}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Core", "Core", "{C7579627-572E-4682-9FA0-8FBEA29B6C1F}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Infrastructure", "Infrastructure", "{662C8048-BE05-464E-8F6E-DFEC5BDA3038}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Web", "Web", "{F15D83A4-8080-4750-A025-A0151B8B3577}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sufi.Demo.PeopleDirectory.Domain", "Sufi.Demo.PeopleDirectory.Domain\Sufi.Demo.PeopleDirectory.Domain.csproj", "{3DD52A3E-9BA6-4D71-A0EF-A0E7DE5C98CE}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sufi.Demo.PeopleDirectory.Persistence", "Sufi.Demo.PeopleDirectory.Persistence\Sufi.Demo.PeopleDirectory.Persistence.csproj", "{78D653E8-7B22-4BB1-8AD7-EE16CB69A21D}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sufi.Demo.PeopleDirectory.Application", "Sufi.Demo.PeopleDirectory.Application\Sufi.Demo.PeopleDirectory.Application.csproj", "{3A107445-083F-441D-8C1E-800E27156084}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sufi.Demo.PeopleDirectory.Shared", "Sufi.Demo.PeopleDirectory.Shared\Sufi.Demo.PeopleDirectory.Shared.csproj", "{8ECB01B5-9C96-4C41-9D61-B286B470D1C5}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sufi.Demo.PeopleDirectory.UnitTests", "Sufi.Demo.PeopleDirectory.UnitTests\Sufi.Demo.PeopleDirectory.UnitTests.csproj", "{D278DEB8-EE96-453E-AD1C-BF0E08F1A70A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sufi.Demo.PeopleDirectory.Infrastructure", "Sufi.Demo.PeopleDirectory.Infrastructure\Sufi.Demo.PeopleDirectory.Infrastructure.csproj", "{F3D2240B-A4F7-2F52-151C-941C2F32F0BA}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {86ADD71C-9EFE-4D80-98C6-AA9539E2CC68}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {86ADD71C-9EFE-4D80-98C6-AA9539E2CC68}.Debug|Any CPU.Build.0 = Debug|Any CPU + {86ADD71C-9EFE-4D80-98C6-AA9539E2CC68}.Release|Any CPU.ActiveCfg = Release|Any CPU + {86ADD71C-9EFE-4D80-98C6-AA9539E2CC68}.Release|Any CPU.Build.0 = Release|Any CPU + {39B057E5-B151-4C7F-B172-904227D6778E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {39B057E5-B151-4C7F-B172-904227D6778E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {39B057E5-B151-4C7F-B172-904227D6778E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {39B057E5-B151-4C7F-B172-904227D6778E}.Release|Any CPU.Build.0 = Release|Any CPU + {3DD52A3E-9BA6-4D71-A0EF-A0E7DE5C98CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3DD52A3E-9BA6-4D71-A0EF-A0E7DE5C98CE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3DD52A3E-9BA6-4D71-A0EF-A0E7DE5C98CE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3DD52A3E-9BA6-4D71-A0EF-A0E7DE5C98CE}.Release|Any CPU.Build.0 = Release|Any CPU + {78D653E8-7B22-4BB1-8AD7-EE16CB69A21D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {78D653E8-7B22-4BB1-8AD7-EE16CB69A21D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {78D653E8-7B22-4BB1-8AD7-EE16CB69A21D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {78D653E8-7B22-4BB1-8AD7-EE16CB69A21D}.Release|Any CPU.Build.0 = Release|Any CPU + {3A107445-083F-441D-8C1E-800E27156084}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3A107445-083F-441D-8C1E-800E27156084}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3A107445-083F-441D-8C1E-800E27156084}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3A107445-083F-441D-8C1E-800E27156084}.Release|Any CPU.Build.0 = Release|Any CPU + {8ECB01B5-9C96-4C41-9D61-B286B470D1C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8ECB01B5-9C96-4C41-9D61-B286B470D1C5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8ECB01B5-9C96-4C41-9D61-B286B470D1C5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8ECB01B5-9C96-4C41-9D61-B286B470D1C5}.Release|Any CPU.Build.0 = Release|Any CPU + {D278DEB8-EE96-453E-AD1C-BF0E08F1A70A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D278DEB8-EE96-453E-AD1C-BF0E08F1A70A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D278DEB8-EE96-453E-AD1C-BF0E08F1A70A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D278DEB8-EE96-453E-AD1C-BF0E08F1A70A}.Release|Any CPU.Build.0 = Release|Any CPU + {F3D2240B-A4F7-2F52-151C-941C2F32F0BA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F3D2240B-A4F7-2F52-151C-941C2F32F0BA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F3D2240B-A4F7-2F52-151C-941C2F32F0BA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F3D2240B-A4F7-2F52-151C-941C2F32F0BA}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {86ADD71C-9EFE-4D80-98C6-AA9539E2CC68} = {F15D83A4-8080-4750-A025-A0151B8B3577} + {39B057E5-B151-4C7F-B172-904227D6778E} = {F15D83A4-8080-4750-A025-A0151B8B3577} + {C7579627-572E-4682-9FA0-8FBEA29B6C1F} = {01F8753B-392E-48C7-A16A-327BBE2658ED} + {662C8048-BE05-464E-8F6E-DFEC5BDA3038} = {01F8753B-392E-48C7-A16A-327BBE2658ED} + {F15D83A4-8080-4750-A025-A0151B8B3577} = {01F8753B-392E-48C7-A16A-327BBE2658ED} + {3DD52A3E-9BA6-4D71-A0EF-A0E7DE5C98CE} = {C7579627-572E-4682-9FA0-8FBEA29B6C1F} + {78D653E8-7B22-4BB1-8AD7-EE16CB69A21D} = {662C8048-BE05-464E-8F6E-DFEC5BDA3038} + {3A107445-083F-441D-8C1E-800E27156084} = {C7579627-572E-4682-9FA0-8FBEA29B6C1F} + {8ECB01B5-9C96-4C41-9D61-B286B470D1C5} = {C7579627-572E-4682-9FA0-8FBEA29B6C1F} + {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {01F8753B-392E-48C7-A16A-327BBE2658ED} + {D278DEB8-EE96-453E-AD1C-BF0E08F1A70A} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {F3D2240B-A4F7-2F52-151C-941C2F32F0BA} = {662C8048-BE05-464E-8F6E-DFEC5BDA3038} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {04BF9A3C-F187-44A3-9F34-8BE6D3286782} + EndGlobalSection +EndGlobal diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a9f86c7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +services: + demo-api: + container_name: demo-contact-api + image: sufiaziz/demo-contact:latest + ports: + - 5001:8080 + environment: + ASPNETCORE_ENVIRONMENT: Production + ASPNETCORE_URLS: http://0.0.0.0:8080 + ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true" + ConnectionStrings__DefaultConnectionString: #ConnectionString# + LuckyPennyLicenseKey: #LuckyPennyLicenseKey# + Serilog__WriteTo__1__Args__serverUrl: #SEQ_URL# + Serilog__WriteTo__1__Args__apiKey: #SEQ_APIKEY# + restart: unless-stopped + networks: + - postgres-net + - seq-net + +networks: + postgres-net: + name: postgres-net + external: true + seq-net: + name: seq-net + external: true \ No newline at end of file diff --git a/ui/Sufi.Demo.PeopleDirectory.UI/Client/App.razor b/ui/Sufi.Demo.PeopleDirectory.UI/Client/App.razor new file mode 100644 index 0000000..6fd3ed1 --- /dev/null +++ b/ui/Sufi.Demo.PeopleDirectory.UI/Client/App.razor @@ -0,0 +1,12 @@ + + + + + + + Not found + +

Sorry, there's nothing at this address.

+
+
+
diff --git a/ui/Sufi.Demo.PeopleDirectory.UI/Client/Pages/Contacts.razor b/ui/Sufi.Demo.PeopleDirectory.UI/Client/Pages/Contacts.razor new file mode 100644 index 0000000..75a5c45 --- /dev/null +++ b/ui/Sufi.Demo.PeopleDirectory.UI/Client/Pages/Contacts.razor @@ -0,0 +1,210 @@ +@page "/contacts" + +@inject HttpClient Http +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject BoomerangService Boomerang + +Contact List + + + + Contact List + + + Behold! Below is the list of all registered users in this application. + (All data will be deleted for every 10 minutes) + + + + Create + + + Refresh + + + + @if (contacts != null && contacts.Length > 0) + { + + + Id + Username + Phone + Email + Skill Sets + Hobby + Action + + + @context.Id + @context.UserName + @context.Phone + @context.Email + @context.SkillSets + @context.Hobby + + + + + + + + + + + + + + } + else + { + No item to display. Click on the 'Create' button to add. + } + + + + + + + +@code { + private GetAllContactsResponse[]? contacts; + private bool loading; + + protected override async Task OnInitializedAsync() + { + await LoadTableAsync(); + + await Boomerang.AddVariableAsync("PageName", "Contacts"); + await Boomerang.SendBeaconAsync(); + } + + private async Task LoadTableAsync() + { + loading = true; + + var response = await Http.GetAsync("api/v1/contacts"); + var result = await response.ToResult>(); + + if (result.Succeeded) + { + contacts = result.Data.ToArray(); + } + else + { + contacts = null; + + foreach (var message in result.Messages) + { + Snackbar.Add(message, Severity.Error); + } + } + + loading = false; + } + + private async Task OnButtonRefreshClicked() + { + await LoadTableAsync(); + } + + private async Task OnButtonCreateClicked() + { + var dialogOptions = new DialogOptions { BackdropClick = false, CloseButton = true }; + var dialog = await DialogService.ShowAsync("Create New Contact", dialogOptions); + var dialogResult = await dialog.Result; + + if (dialogResult!.Canceled) + { + return; + } + + loading = true; + StateHasChanged(); + + var contactData = (AddEditContactCommand)dialogResult.Data!; + var response = await Http.PostAsJsonAsync("api/v1/contacts", contactData); + var result = await response.ToResult(); + + if (result.Succeeded) + { + Snackbar.Add(result.Messages[0], Severity.Success); + await LoadTableAsync(); + } + else + { + foreach (var message in result.Messages) + { + Snackbar.Add(message, Severity.Error); + } + + loading = false; + } + } + + async Task OnEditContactClicked(int id) + { + var dialogParams = new DialogParameters { ["Id"] = id }; + var dialogOptions = new DialogOptions { BackdropClick = false, CloseButton = true }; + var dialog = await DialogService.ShowAsync("Edit Contact", dialogParams, dialogOptions); + var dialogResult = await dialog.Result; + if (dialogResult!.Canceled) + { + return; + } + + loading = true; + StateHasChanged(); + + var contactData = (AddEditContactCommand)dialogResult.Data!; + var response = await Http.PostAsJsonAsync("api/v1/contacts", contactData); + var result = await response.ToResult(); + + if (result.Succeeded) + { + Snackbar.Add(result.Messages[0], Severity.Success); + await LoadTableAsync(); + } + else + { + foreach (var message in result.Messages) + { + Snackbar.Add(message, Severity.Error); + } + + loading = false; + } + } + async Task OnDeleteContactClicked(int id) + { + var dialogResult = await DialogService.ShowMessageBox("Delete Contact", "Confirm to delete the item? This action cannot be undone.", yesText: "Delete!", cancelText: "No"); + if (dialogResult == null) + { + return; + } + + loading = true; + StateHasChanged(); + + var deleteResponse = await Http.DeleteAsync($"api/v1/contacts/{id}"); + if (deleteResponse.IsSuccessStatusCode) + { + var result = await deleteResponse.ToResult(); + if (result!.Succeeded) + { + Snackbar.Add("Contact deleted successfully.", Severity.Success); + await LoadTableAsync(); + } + else + { + Snackbar.Add(result.Messages[0], Severity.Error); + } + } + else + { + Snackbar.Add("An error has occurred.", Severity.Error); + loading = false; + } + } +} diff --git a/ui/Sufi.Demo.PeopleDirectory.UI/Client/Pages/Dialogs/CreateContactDialog.razor b/ui/Sufi.Demo.PeopleDirectory.UI/Client/Pages/Dialogs/CreateContactDialog.razor new file mode 100644 index 0000000..8b72d2a --- /dev/null +++ b/ui/Sufi.Demo.PeopleDirectory.UI/Client/Pages/Dialogs/CreateContactDialog.razor @@ -0,0 +1,60 @@ + + + + + + + + + + + + Cancel + Ok + + + +@code { + private static readonly string[] SampleUserNames = new[] + { + "alex99", + "samwise", + "lunaStar", + "maverick", + "nova", + "pixelPro", + "kanu", + "tay", + "zorin", + "echo" + }; + private static readonly Random _rnd = new(); + + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = null!; + + private AddEditContactCommand request = new(); + private MudForm? form; + private bool success; + private string[] errors = { }; + + private void Cancel() => MudDialog.Cancel(); + + private async Task Submit() + { + await form!.Validate(); + if (!success) + { + return; + } + + MudDialog.Close(request); + } + + protected override Task OnInitializedAsync() + { + // Assign a random username from the in-memory list when the dialog opens + request.UserName = SampleUserNames[_rnd.Next(SampleUserNames.Length)]; + return base.OnInitializedAsync(); + } +} diff --git a/ui/Sufi.Demo.PeopleDirectory.UI/Client/Pages/Dialogs/EditContactDialog.razor b/ui/Sufi.Demo.PeopleDirectory.UI/Client/Pages/Dialogs/EditContactDialog.razor new file mode 100644 index 0000000..9c73b48 --- /dev/null +++ b/ui/Sufi.Demo.PeopleDirectory.UI/Client/Pages/Dialogs/EditContactDialog.razor @@ -0,0 +1,74 @@ +@using Sufi.Demo.PeopleDirectory.Application.Features.Contacts.Queries.GetById + +@inject HttpClient Http + + + + @if (showAlert) + { + @alertMessage + } + + + + + + + + + + Cancel + Ok + + + +@code { + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = null!; + [Parameter] + public int Id { get; set; } + + private AddEditContactCommand request = new(); + private MudForm? form; + private bool success; + private string[] errors = { }; + private bool showAlert; + private string? alertMessage; + + protected override async Task OnInitializedAsync() + { + var response = await Http.GetAsync($"api/v1/contacts/{Id}"); + var result = await response.ToResult(); + if (result.Succeeded) + { + var contact = result.Data; + request.Id = Id; + request.UserName = contact!.UserName; + request.Email = contact.Email; + request.Phone = contact.Phone; + request.SkillSets = contact.SkillSets; + request.Hobby = contact.Hobby; + + showAlert = false; + alertMessage = ""; + } + else + { + showAlert = true; + alertMessage = string.Join(',', result.Messages); + } + } + + private void Cancel() => MudDialog.Cancel(); + + private async Task Submit() + { + await form!.Validate(); + if (!success) + { + return; + } + + MudDialog!.Close(request); + } +} diff --git a/ui/Sufi.Demo.PeopleDirectory.UI/Client/Pages/Index.razor b/ui/Sufi.Demo.PeopleDirectory.UI/Client/Pages/Index.razor new file mode 100644 index 0000000..d60d8cc --- /dev/null +++ b/ui/Sufi.Demo.PeopleDirectory.UI/Client/Pages/Index.razor @@ -0,0 +1,22 @@ +@page "/" + +@inject BoomerangService Boomerang + +Index - Demo App + + + + Welcome to this demo app. + + + Please view the contact list page. + + + +@code { + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await Boomerang.AddVariableAsync("PageName", "Index"); + await Boomerang.SendBeaconAsync(); + } +} \ No newline at end of file diff --git a/ui/Sufi.Demo.PeopleDirectory.UI/Client/Program.cs b/ui/Sufi.Demo.PeopleDirectory.UI/Client/Program.cs new file mode 100644 index 0000000..79283dd --- /dev/null +++ b/ui/Sufi.Demo.PeopleDirectory.UI/Client/Program.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using MudBlazor; +using MudBlazor.Services; +using Sufi.Demo.PeopleDirectory.UI.Client; +using Sufi.Demo.PeopleDirectory.UI.Client.Services; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); +builder.RootComponents.Add("#app"); +builder.RootComponents.Add("head::after"); + +// MudBlazor +builder.Services.AddMudServices(config => +{ + config.SnackbarConfiguration.PositionClass = Defaults.Classes.Position.TopRight; + config.SnackbarConfiguration.ClearAfterNavigation = false; + config.SnackbarConfiguration.HideTransitionDuration = 200; + config.SnackbarConfiguration.ShowTransitionDuration = 200; + config.SnackbarConfiguration.NewestOnTop = true; +}); + +builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); +builder.Services.AddScoped(); + +await builder.Build().RunAsync(); diff --git a/ui/Sufi.Demo.PeopleDirectory.UI/Client/Properties/launchSettings.json b/ui/Sufi.Demo.PeopleDirectory.UI/Client/Properties/launchSettings.json new file mode 100644 index 0000000..5119cac --- /dev/null +++ b/ui/Sufi.Demo.PeopleDirectory.UI/Client/Properties/launchSettings.json @@ -0,0 +1,30 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:50212", + "sslPort": 44358 + } + }, + "profiles": { + "Sufi.Demo.PeopleDirectory.UI": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "https://localhost:7024;http://localhost:5243", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/ui/Sufi.Demo.PeopleDirectory.UI/Client/Services/BoomerangService.cs b/ui/Sufi.Demo.PeopleDirectory.UI/Client/Services/BoomerangService.cs new file mode 100644 index 0000000..2974667 --- /dev/null +++ b/ui/Sufi.Demo.PeopleDirectory.UI/Client/Services/BoomerangService.cs @@ -0,0 +1,24 @@ +using Microsoft.JSInterop; + +namespace Sufi.Demo.PeopleDirectory.UI.Client.Services +{ + public class BoomerangService( + IJSRuntime jsRuntime + ) + { + public async Task InitializeAsync(object config) + { + return await jsRuntime.InvokeAsync("boomerangHelper.init", config); + } + + public async Task AddVariableAsync(string key, string value) + { + await jsRuntime.InvokeVoidAsync("boomerangHelper.addVar", key, value); + } + + public async Task SendBeaconAsync() + { + await jsRuntime.InvokeVoidAsync("boomerangHelper.sendBeacon"); + } + } +} diff --git a/ui/Sufi.Demo.PeopleDirectory.UI/Client/Shared/MainLayout.razor b/ui/Sufi.Demo.PeopleDirectory.UI/Client/Shared/MainLayout.razor new file mode 100644 index 0000000..bc4063e --- /dev/null +++ b/ui/Sufi.Demo.PeopleDirectory.UI/Client/Shared/MainLayout.razor @@ -0,0 +1,30 @@ +@inherits LayoutComponentBase + + + + + + + + + + + + + + + + + @Body + + + + +@code { + bool _drawerOpen = true; + + void ToggleDrawer() + { + _drawerOpen = !_drawerOpen; + } +} \ No newline at end of file diff --git a/ui/Sufi.Demo.PeopleDirectory.UI/Client/Shared/MainLayout.razor.css b/ui/Sufi.Demo.PeopleDirectory.UI/Client/Shared/MainLayout.razor.css new file mode 100644 index 0000000..c865427 --- /dev/null +++ b/ui/Sufi.Demo.PeopleDirectory.UI/Client/Shared/MainLayout.razor.css @@ -0,0 +1,81 @@ +.page { + position: relative; + display: flex; + flex-direction: column; +} + +main { + flex: 1; +} + +.sidebar { + background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); +} + +.top-row { + background-color: #f7f7f7; + border-bottom: 1px solid #d6d5d5; + justify-content: flex-end; + height: 3.5rem; + display: flex; + align-items: center; +} + + .top-row ::deep a, .top-row ::deep .btn-link { + white-space: nowrap; + margin-left: 1.5rem; + text-decoration: none; + } + + .top-row ::deep a:hover, .top-row ::deep .btn-link:hover { + text-decoration: underline; + } + + .top-row ::deep a:first-child { + overflow: hidden; + text-overflow: ellipsis; + } + +@media (max-width: 640.98px) { + .top-row:not(.auth) { + display: none; + } + + .top-row.auth { + justify-content: space-between; + } + + .top-row ::deep a, .top-row ::deep .btn-link { + margin-left: 0; + } +} + +@media (min-width: 641px) { + .page { + flex-direction: row; + } + + .sidebar { + width: 250px; + height: 100vh; + position: sticky; + top: 0; + } + + .top-row { + position: sticky; + top: 0; + z-index: 1; + } + + .top-row.auth ::deep a:first-child { + flex: 1; + text-align: right; + width: 0; + } + + .top-row, article { + padding-left: 2rem !important; + padding-right: 1.5rem !important; + } +} diff --git a/ui/Sufi.Demo.PeopleDirectory.UI/Client/Shared/SideNavMenu.razor b/ui/Sufi.Demo.PeopleDirectory.UI/Client/Shared/SideNavMenu.razor new file mode 100644 index 0000000..4f75f0e --- /dev/null +++ b/ui/Sufi.Demo.PeopleDirectory.UI/Client/Shared/SideNavMenu.razor @@ -0,0 +1,11 @@ + + Sufi Demo App + For demo only + + + Home + + Contact List + + + \ No newline at end of file diff --git a/ui/Sufi.Demo.PeopleDirectory.UI/Client/Sufi.Demo.PeopleDirectory.UI.Client.csproj b/ui/Sufi.Demo.PeopleDirectory.UI/Client/Sufi.Demo.PeopleDirectory.UI.Client.csproj new file mode 100644 index 0000000..b1eff33 --- /dev/null +++ b/ui/Sufi.Demo.PeopleDirectory.UI/Client/Sufi.Demo.PeopleDirectory.UI.Client.csproj @@ -0,0 +1,68 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/Sufi.Demo.PeopleDirectory.UI/Client/_Imports.razor b/ui/Sufi.Demo.PeopleDirectory.UI/Client/_Imports.razor new file mode 100644 index 0000000..988dfc7 --- /dev/null +++ b/ui/Sufi.Demo.PeopleDirectory.UI/Client/_Imports.razor @@ -0,0 +1,17 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.AspNetCore.Components.WebAssembly.Http +@using Microsoft.JSInterop +@using Sufi.Demo.PeopleDirectory.UI.Client +@using Sufi.Demo.PeopleDirectory.UI.Client.Shared +@using MudBlazor; +@using Sufi.Demo.PeopleDirectory.Application.Features.Contacts.Commands +@using Sufi.Demo.PeopleDirectory.Application.Features.Contacts.Queries.GetAll +@using Sufi.Demo.PeopleDirectory.Shared.Wrapper +@using Sufi.Demo.PeopleDirectory.UI.Client.Pages.Dialogs +@using Sufi.Demo.PeopleDirectory.Shared.Extensions +@using Sufi.Demo.PeopleDirectory.UI.Client.Services \ No newline at end of file diff --git a/ui/Sufi.Demo.PeopleDirectory.UI/Client/wwwroot/css/app.css b/ui/Sufi.Demo.PeopleDirectory.UI/Client/wwwroot/css/app.css new file mode 100644 index 0000000..f54ba95 --- /dev/null +++ b/ui/Sufi.Demo.PeopleDirectory.UI/Client/wwwroot/css/app.css @@ -0,0 +1,62 @@ +html, body { + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; +} + +h1:focus { + outline: none; +} + +a, .btn-link { + color: #0071c1; +} + +.btn-primary { + color: #fff; + background-color: #1b6ec2; + border-color: #1861ac; +} + +.content { + padding-top: 1.1rem; +} + +.valid.modified:not([type=checkbox]) { + outline: 1px solid #26b050; +} + +.invalid { + outline: 1px solid red; +} + +.validation-message { + color: red; +} + +#blazor-error-ui { + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } diff --git a/ui/Sufi.Demo.PeopleDirectory.UI/Client/wwwroot/favicon.ico b/ui/Sufi.Demo.PeopleDirectory.UI/Client/wwwroot/favicon.ico new file mode 100644 index 0000000..63e859b Binary files /dev/null and b/ui/Sufi.Demo.PeopleDirectory.UI/Client/wwwroot/favicon.ico differ diff --git a/ui/Sufi.Demo.PeopleDirectory.UI/Client/wwwroot/icon-192.png b/ui/Sufi.Demo.PeopleDirectory.UI/Client/wwwroot/icon-192.png new file mode 100644 index 0000000..166f56d Binary files /dev/null and b/ui/Sufi.Demo.PeopleDirectory.UI/Client/wwwroot/icon-192.png differ diff --git a/ui/Sufi.Demo.PeopleDirectory.UI/Client/wwwroot/index.html b/ui/Sufi.Demo.PeopleDirectory.UI/Client/wwwroot/index.html new file mode 100644 index 0000000..e8bbbd7 --- /dev/null +++ b/ui/Sufi.Demo.PeopleDirectory.UI/Client/wwwroot/index.html @@ -0,0 +1,34 @@ + + + + + + + Sufi Demo App + + + + + + + + +
Loading...
+ +
+ An unhandled error has occurred. + Reload + 🗙 +
+ + + + + + + + \ No newline at end of file diff --git a/ui/Sufi.Demo.PeopleDirectory.UI/Client/wwwroot/index.template.html b/ui/Sufi.Demo.PeopleDirectory.UI/Client/wwwroot/index.template.html new file mode 100644 index 0000000..a93e15f --- /dev/null +++ b/ui/Sufi.Demo.PeopleDirectory.UI/Client/wwwroot/index.template.html @@ -0,0 +1,34 @@ + + + + + + + Sufi Demo App + + + + + + + + +
Loading...
+ +
+ An unhandled error has occurred. + Reload + 🗙 +
+ + + + + + + + \ No newline at end of file diff --git a/ui/Sufi.Demo.PeopleDirectory.UI/Client/wwwroot/js/boomerang-interop.js b/ui/Sufi.Demo.PeopleDirectory.UI/Client/wwwroot/js/boomerang-interop.js new file mode 100644 index 0000000..e2469a9 --- /dev/null +++ b/ui/Sufi.Demo.PeopleDirectory.UI/Client/wwwroot/js/boomerang-interop.js @@ -0,0 +1,21 @@ +window.boomerangHelper = { + init: function (config) { + if (window.BOOMR && window.BOOMR.init) { + BOOMR.init(config); + return true; + } + return false; + }, + + addVar: function (key, value) { + if (window.BOOMR) { + BOOMR.addVar(key, value); + } + }, + + sendBeacon: function () { + if (window.BOOMR) { + BOOMR.sendBeacon(); + } + } +}; \ No newline at end of file diff --git a/ui/Sufi.Demo.PeopleDirectory.UI/Client/wwwroot/js/boomerang.js b/ui/Sufi.Demo.PeopleDirectory.UI/Client/wwwroot/js/boomerang.js new file mode 100644 index 0000000..011ac81 --- /dev/null +++ b/ui/Sufi.Demo.PeopleDirectory.UI/Client/wwwroot/js/boomerang.js @@ -0,0 +1,5360 @@ +/** + * @copyright (c) 2011, Yahoo! Inc. All rights reserved. + * @copyright (c) 2012, Log-Normal, Inc. All rights reserved. + * @copyright (c) 2012-2017, SOASTA, Inc. All rights reserved. + * @copyright (c) 2017-2023, Akamai Technologies, Inc. All rights reserved. + * Copyrights licensed under the BSD License. See the accompanying LICENSE.txt file for terms. + */ + +/** + * @class BOOMR + * @desc + * boomerang measures various performance characteristics of your user's browsing + * experience and beacons it back to your server. + * + * To use this you'll need a web site, lots of users and the ability to do + * something with the data you collect. How you collect the data is up to + * you, but we have a few ideas. + * + * Everything in boomerang is accessed through the `BOOMR` object, which is + * available on `window.BOOMR`. It contains the public API, utility functions + * ({@link BOOMR.utils}) and all of the plugins ({@link BOOMR.plugins}). + * + * Each plugin has its own API, but is reachable through {@link BOOMR.plugins}. + * + * ## Beacon Parameters + * + * The core boomerang object will add the following parameters to the beacon. + * + * Note that each individual {@link BOOMR.plugins plugin} will add its own + * parameters as well. + * + * * `v`: Boomerang version + * * `sv`: Boomerang Loader Snippet version + * * `sm`: Boomerang Loader Snippet method + * * `u`: The page's URL (for most beacons), or the `XMLHttpRequest` URL + * * `n`: The beacon number + * * `pgu`: The page's URL (for `XMLHttpRequest` beacons) + * * `pid`: Page ID (8 characters) + * * `r`: Navigation referrer (from `document.location`) + * * `vis.pre`: `1` if the page transitioned from prerender to visible + * * `vis.st`: Document's visibility state when beacon was sent + * * `vis.lh`: Timestamp when page was last hidden + * * `vis.lv`: Timestamp when page was last visible + * * `xhr.pg`: The `XMLHttpRequest` page group + * * `errors`: Error messages of errors detected in Boomerang code, separated by a newline + * * `rt.si`: Session ID + * * `rt.ss`: Session start timestamp + * * `rt.sl`: Session length (number of pages), can be increased by XHR beacons as well + * * `ua.plt`: `navigator.platform` or if available `navigator.userAgentData.platform` + * * `ua.arch`: navigator userAgentData architecture, if client hints requested + * * `ua.model`: navigator userAgentData model, if client hints requested + * * `ua.pltv`: navigator userAgentData platform version, if client hints requested + * * `ua.vnd`: `navigator.vendor` + */ + +/** + * @typedef TimeStamp + * @type {number} + * + * @desc + * A [Unix Epoch](https://en.wikipedia.org/wiki/Unix_time) timestamp (milliseconds + * since 1970) created by [BOOMR.now()]{@link BOOMR.now}. + * + * If `DOMHighResTimeStamp` (`performance.now()`) is supported, it is + * a `DOMHighResTimeStamp` (with microsecond resolution in the fractional), + * otherwise, it is `Date.now()`. + */ + +/* BEGIN_DEBUG */ +// we don't yet have BOOMR.utils.mark() +if ("performance" in window && + window.performance && + typeof window.performance.mark === "function" && + !window.BOOMR_no_mark) { + window.performance.mark("boomr:startup"); +} +/* END_DEBUG */ + +/** + * @global + * @type {TimeStamp} + * @desc + * This variable is added to the global scope (`window`) until Boomerang loads, + * at which point it is removed. + * + * Timestamp the boomerang.js script started executing. + * + * This has to be global so that we don't wait for this entire + * script to download and execute before measuring the + * time. We also declare it without `var` so that we can later + * `delete` it. This is the only way that works on Internet Explorer. + */ +BOOMR_start = new Date().getTime(); + +/** + * @function + * @global + * @desc + * This function is added to the global scope (`window`). + * + * Check the value of `document.domain` and fix it if incorrect. + * + * This function is run at the top of boomerang, and then whenever + * {@link BOOMR.init} is called. If boomerang is running within an IFRAME, this + * function checks to see if it can access elements in the parent + * IFRAME. If not, it will fudge around with `document.domain` until + * it finds a value that works. + * + * This allows site owners to change the value of `document.domain` at + * any point within their page's load process, and we will adapt to + * it. + * + * @param {string} domain Domain name as retrieved from page URL + */ +function BOOMR_check_doc_domain(domain) { + /* eslint no-unused-vars:0 */ + var test; + + /* BEGIN_DEBUG */ + // we don't yet have BOOMR.utils.mark() + if ("performance" in window && + window.performance && + typeof window.performance.mark === "function" && + !window.BOOMR_no_mark) { + window.performance.mark("boomr:check_doc_domain"); + } + /* END_DEBUG */ + + if (!window) { + return; + } + + // If domain is not passed in, then this is a global call + // domain is only passed in if we call ourselves, so we + // skip the frame check at that point + if (!domain) { + // If we're running in the main window, then we don't need this + if (window.parent === window || !document.getElementById("boomr-if-as")) { + // nothing to do + return; + } + + if (window.BOOMR && BOOMR.boomerang_frame && BOOMR.window) { + try { + // If document.domain is changed during page load (from www.blah.com to blah.com, for example), + // BOOMR.window.location.href throws "Permission Denied" in IE. + // Resetting the inner domain to match the outer makes location accessible once again + if (BOOMR.boomerang_frame.document.domain !== BOOMR.window.document.domain) { + BOOMR.boomerang_frame.document.domain = BOOMR.window.document.domain; + } + } + catch (err) { + if (!BOOMR.isCrossOriginError(err)) { + BOOMR.addError(err, "BOOMR_check_doc_domain.domainFix"); + } + } + } + + domain = document.domain; + } + + if (!domain || domain.indexOf(".") === -1) { + // not okay, but we did our best + return; + } + + // window.parent might be null if we're running during unload from + // a detached iframe + if (!window.parent) { + return; + } + + // 1. Test without setting document.domain + try { + test = window.parent.document; + + // all okay + return; + } + // 2. Test with document.domain + catch (err) { + try { + document.domain = domain; + } + catch (err2) { + // An exception might be thrown if the document is unloaded + // or when the domain is incorrect. If so, we can't do anything + // more, so bail. + return; + } + } + + try { + test = window.parent.document; + + // all okay + return; + } + // 3. Strip off leading part and try again + catch (err) { + domain = domain.replace(/^[\w\-]+\./, ""); + } + + BOOMR_check_doc_domain(domain); +} + +BOOMR_check_doc_domain(); + +// Construct BOOMR +// w is window +(function(w) { + var impl, boomr, d, createCustomEvent, dispatchEvent, visibilityState, visibilityChange, + orig_w = w; + + // If the window that boomerang is running in is not top level (ie, we're running in an iframe) + // and if this iframe contains a script node with an id of "boomr-if-as", + // Then that indicates that we are using the iframe loader, so the page we're trying to measure + // is w.parent + // + // Note that we use `document` rather than `w.document` because we're specifically interested in + // the document of the currently executing context rather than a passed in proxy. + // + // The only other place we do this is in `BOOMR.utils.getMyURL` below, for the same reason, we + // need the full URL of the currently executing (boomerang) script. + if (w.parent !== w && + document.getElementById("boomr-if-as") && + document.getElementById("boomr-if-as").nodeName.toLowerCase() === "script") { + w = w.parent; + } + + d = w.document; + + // Short namespace because I don't want to keep typing BOOMERANG + if (!w.BOOMR) { + w.BOOMR = {}; + } + + BOOMR = w.BOOMR; + + // don't allow this code to be included twice + if (BOOMR.version) { + return; + } + + /** + * Boomerang version, formatted as major.minor.patchlevel. + * + * This variable is replaced during build (`grunt build`). + * + * @type {string} + * + * @memberof BOOMR + */ + BOOMR.version = "%boomerang_version%"; + + /** + * The main document window. + * * If Boomerang was loaded in an IFRAME, this is the parent window + * * If Boomerang was loaded inline, this is the current window + * + * @type {Window} + * + * @memberof BOOMR + */ + BOOMR.window = w; + + /** + * The Boomerang frame: + * * If Boomerang was loaded in an IFRAME, this is the IFRAME + * * If Boomerang was loaded inline, this is the current window + * + * @type {Window} + * + * @memberof BOOMR + */ + BOOMR.boomerang_frame = orig_w; + + /** + * @class BOOMR.plugins + * @desc + * Boomerang plugin namespace. + * + * All plugins should add their plugin object to `BOOMR.plugins`. + * + * A plugin should have, at minimum, the following exported functions: + * * `init(config)` + * * `is_complete()` + * + * See {@tutorial creating-plugins} for details. + */ + if (!BOOMR.plugins) { + BOOMR.plugins = {}; + } + + // CustomEvent proxy for IE9 & 10 from https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent + (function() { + try { + if (new w.CustomEvent("CustomEvent") !== undefined) { + createCustomEvent = function(e_name, params) { + return new w.CustomEvent(e_name, params); + }; + } + } + catch (ignore) { + // empty + } + + try { + if (!createCustomEvent && d.createEvent && d.createEvent("CustomEvent")) { + createCustomEvent = function(e_name, params) { + var evt = d.createEvent("CustomEvent"); + + params = params || { cancelable: false, bubbles: false }; + evt.initCustomEvent(e_name, params.bubbles, params.cancelable, params.detail); + + return evt; + }; + } + } + catch (ignore) { + // empty + } + + if (!createCustomEvent && d.createEventObject) { + createCustomEvent = function(e_name, params) { + var evt = d.createEventObject(); + + evt.type = evt.propertyName = e_name; + evt.detail = params.detail; + + return evt; + }; + } + + if (!createCustomEvent) { + createCustomEvent = function() { + return undefined; + }; + } + }()); + + /** + * Dispatch a custom event to the browser + * @param {string} e_name The custom event name that consumers can subscribe to + * @param {object} e_data Any data passed to subscribers of the custom event via the `event.detail` property + * @param {boolean} async By default, custom events are dispatched immediately. + * Set to true if the event should be dispatched once the browser has finished its current + * JavaScript execution. + */ + dispatchEvent = function(e_name, e_data, async) { + var ev = createCustomEvent(e_name, {"detail": e_data}); + + if (!ev) { + return; + } + + function dispatch() { + try { + if (d.dispatchEvent) { + d.dispatchEvent(ev); + } + else if (d.fireEvent) { + d.fireEvent("onpropertychange", ev); + } + } + catch (e) { + BOOMR.debug("Error when dispatching " + e_name); + } + } + + if (async) { + BOOMR.setImmediate(dispatch); + } + else { + dispatch(); + } + }; + + // visibilitychange is useful to detect if the page loaded through prerender + // or if the page never became visible + // https://www.w3.org/TR/2011/WD-page-visibility-20110602/ + // https://www.nczonline.net/blog/2011/08/09/introduction-to-the-page-visibility-api/ + // https://developer.mozilla.org/en-US/docs/Web/Guide/User_experience/Using_the_Page_Visibility_API + + // Set the name of the hidden property and the change event for visibility + if (typeof d.hidden !== "undefined") { + visibilityState = "visibilityState"; + visibilityChange = "visibilitychange"; + } + else if (typeof d.mozHidden !== "undefined") { + visibilityState = "mozVisibilityState"; + visibilityChange = "mozvisibilitychange"; + } + else if (typeof d.msHidden !== "undefined") { + visibilityState = "msVisibilityState"; + visibilityChange = "msvisibilitychange"; + } + else if (typeof d.webkitHidden !== "undefined") { + visibilityState = "webkitVisibilityState"; + visibilityChange = "webkitvisibilitychange"; + } + + // + // Internal implementation (impl) + // + // impl is a private object not reachable from outside the BOOMR object. + // Users can set properties by passing in to the init() method. + // + impl = { + // + // Private Members + // + + // Beacon URL + beacon_url: "", + + // Forces protocol-relative URLs to HTTPS + beacon_url_force_https: true, + + // List of string regular expressions that must match the beacon_url. If + // not set, or the list is empty, all beacon URLs are allowed. + beacon_urls_allowed: [], + + // Beacon request method, either GET, POST or AUTO. AUTO will check the + // request size then use GET if the request URL is less than MAX_GET_LENGTH + // chars. Otherwise, it will fall back to a POST request. + beacon_type: "AUTO", + + // Beacon authorization key value. Most systems will use the 'Authentication' + // keyword, but some some services use keys like 'X-Auth-Token' or other + // custom keys. + beacon_auth_key: "Authorization", + + // Beacon authorization token. This is only needed if your are using a POST + // and the beacon requires an Authorization token to accept your data. This + // disables use of the browser sendBeacon() API. + beacon_auth_token: undefined, + + // Sends beacons with Credentials (applies to XHR beacons, not IMG or `sendBeacon()`). + // If you need this, you may want to enable `beacon_disable_sendbeacon` as + // `sendBeacon()` does not support credentials. + beacon_with_credentials: false, + + // Disables navigator.sendBeacon() support + beacon_disable_sendbeacon: false, + + // Strip out everything except last two parts of hostname. + // This doesn't work well for domains that end with a country tld, + // but we allow the developer to override site_domain for that. + // You can disable all cookies by setting site_domain to a falsy value. + site_domain: w.location.hostname. + replace(/.*?([^.]+\.[^.]+)\.?$/, "$1"). + toLowerCase(), + + // User's IP address determined on the server. Used for the BW cookie. + user_ip: "", + + // Whether or not to send beacons on page load + autorun: true, + + // Whether or not we've sent a page load beacon + hasSentPageLoadBeacon: false, + + // document.referrer + r: undefined, + + // Whether or not to strip the Query String + strip_query_string: false, + + // Whether or not the page's 'onload' event has fired + onloadFired: false, + + // Whether or not we've attached all of the page event handlers we want on startup + handlers_attached: false, + + // Whether or not we're waiting for configuration to initialize + waiting_for_config: false, + + // All Boomerang cookies will be created with SameSite=Lax by default + same_site_cookie: "Lax", + + // All Boomerang cookies will be without Secure attribute by default + secure_cookie: false, + + // Sometimes we would like to be able to set the SameSite=None from a Boomerang plugin + forced_same_site_cookie_none: false, + + // Navigator User Agent data object holding Architecture, Model and Platform information from Client Hints API + userAgentData: undefined, + + // Client Hints use for Architecture, Model and Platform detail is disabled by default + request_client_hints: false, + + // Disables all Unload handlers and Unload beacons + no_unload: false, + + // Number of page_unload or before_unload callbacks registered + unloadEventsCount: 0, + + // Number of page_unload or before_unload callbacks called + unloadEventCalled: 0, + + // Event listener callbacks + listenerCallbacks: {}, + + // Beacon variables + vars: {}, + + // Beacon variables for only the next beacon + singleBeaconVars: {}, + + /** + * Variable priority lists: + * -1 = first + * 1 = last + */ + varPriority: { + "-1": {}, + "1": {} + }, + + // Internal boomerang.js errors + errors: {}, + + // Plugins that are disabled + disabled_plugins: {}, + + // Whether or not localStorage is supported + localStorageSupported: false, + + // Prefix for localStorage + LOCAL_STORAGE_PREFIX: "_boomr_", + + // Native functions that were overwritten and should be restored when + // the Boomerang IFRAME is unloaded + nativeOverwrites: [], + + // Prerendered offset (via activationStart). null if not yet checked, + // false if Prerender is supported but did not occur, an integer if + // there was a Prerender (activationStart time). + prerenderedOffset: null, + + // (End Private Members) + + // + // Events (internal and public) + // + + /** + * Internal Events + */ + events: { + /** + * Boomerang event, subscribe via {@link BOOMR.subscribe}. + * + * Fired when the page is usable by the user. + * + * By default this is fired when `window.onload` fires, but if you + * set `autorun` to false when calling {@link BOOMR.init}, then you + * must explicitly fire this event by calling {@link BOOMR#event:page_ready}. + * + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/GlobalEventHandlers/onload} + * @event BOOMR#page_ready + * @property {Event} [event] Event triggering the page_ready + */ + "page_ready": [], + + /** + * Boomerang event, subscribe via {@link BOOMR.subscribe}. + * + * Fired just before the browser unloads the page. + * + * The first event of `window.pagehide`, `window.beforeunload`, + * or `window.unload` will trigger this. + * + * @see {@link https://developer.mozilla.org/en-US/docs/Web/Events/pagehide} + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload} + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onunload} + * @event BOOMR#page_unload + */ + "page_unload": [], + + /** + * Boomerang event, subscribe via {@link BOOMR.subscribe}. + * + * Fired before the document is about to be unloaded. + * + * `window.beforeunload` will trigger this. + * + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload} + * @event BOOMR#before_unload + */ + "before_unload": [], + + /** + * Boomerang event, subscribe via {@link BOOMR.subscribe}. + * + * Fired on `document.DOMContentLoaded`. + * + * The `DOMContentLoaded` event is fired when the initial HTML document + * has been completely loaded and parsed, without waiting for stylesheets, + * images, and subframes to finish loading + * + * @see {@link https://developer.mozilla.org/en-US/docs/Web/Events/DOMContentLoaded} + * @event BOOMR#dom_loaded + */ + "dom_loaded": [], + + /** + * Boomerang event, subscribe via {@link BOOMR.subscribe}. + * + * Fired on `document.visibilitychange`. + * + * The `visibilitychange` event is fired when the content of a tab has + * become visible or has been hidden. + * + * @see {@link https://developer.mozilla.org/en-US/docs/Web/Events/visibilitychange} + * @event BOOMR#visibility_changed + */ + "visibility_changed": [], + + /** + * Boomerang event, subscribe via {@link BOOMR.subscribe}. + * + * Fired when the `visibilityState` of the document has changed from + * `prerender` to `visible` + * + * @see {@link https://developer.mozilla.org/en-US/docs/Web/Events/visibilitychange} + * @event BOOMR#prerender_to_visible + */ + "prerender_to_visible": [], + + /** + * Boomerang event, subscribe via {@link BOOMR.subscribe}. + * + * Fired when a beacon is about to be sent. + * + * The subscriber can still add variables to the beacon at this point, + * either by modifying the `vars` paramter or calling {@link BOOMR.addVar}. + * + * @event BOOMR#before_beacon + * @property {object} vars Beacon variables + */ + "before_beacon": [], + + /** + * Boomerang event, subscribe via {@link BOOMR.subscribe}. + * + * Fired when a beacon was sent. + * + * The beacon variables cannot be modified at this point. Any calls + * to {@link BOOMR.addVar} or {@link BOOMR.removeVar} will apply to the + * next beacon. + * + * Also known as `onbeacon`. + * + * @event BOOMR#beacon + * @property {object} vars Beacon variables + */ + "beacon": [], + + /** + * Boomerang event, subscribe via {@link BOOMR.subscribe}. + * + * Fired when the page load beacon has been sent. + * + * This event should only happen once on a page. It does not apply + * to SPA soft navigations. + * + * @event BOOMR#page_load_beacon + * @property {object} vars Beacon variables + */ + "page_load_beacon": [], + + /** + * Boomerang event, subscribe via {@link BOOMR.subscribe}. + * + * Fired when an XMLHttpRequest has finished, or, if something calls + * {@link BOOMR.responseEnd}. + * + * @event BOOMR#xhr_load + * @property {object} data Event data + */ + "xhr_load": [], + + /** + * Boomerang event, subscribe via {@link BOOMR.subscribe}. + * + * Fired when the `click` event has happened on the `document`. + * + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/GlobalEventHandlers/onclick} + * @event BOOMR#click + */ + "click": [], + + /** + * Boomerang event, subscribe via {@link BOOMR.subscribe}. + * + * Fired when any `FORM` element is submitted. + * + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/submit} + * @event BOOMR#form_submit + */ + "form_submit": [], + + /** + * Boomerang event, subscribe via {@link BOOMR.subscribe}. + * + * Fired whenever new configuration data is applied via {@link BOOMR.init}. + * + * Also known as `onconfig`. + * + * @event BOOMR#config + * @property {object} data Configuration data + */ + "config": [], + + /** + * Boomerang event, subscribe via {@link BOOMR.subscribe}. + * + * Fired whenever `XMLHttpRequest.open` is called. + * + * This event will only happen if {@link BOOMR.plugins.AutoXHR} is enabled. + * + * @event BOOMR#xhr_init + * @property {string} type XHR type ("xhr") + */ + "xhr_init": [], + + /** + * Boomerang event, subscribe via {@link BOOMR.subscribe}. + * + * Fired whenever a SPA plugin is about to track a new navigation. + * + * @event BOOMR#spa_init + * @property {object[]} parameters Navigation type (`spa` or `spa_hard`), URL and timings + */ + "spa_init": [], + + /** + * Boomerang event, subscribe via {@link BOOMR.subscribe}. + * + * Fired whenever a SPA navigation is complete. + * + * @event BOOMR#spa_navigation + * @property {object[]} parameters Timings + */ + "spa_navigation": [], + + /** + * Boomerang event, subscribe via {@link BOOMR.subscribe}. + * + * Fired whenever a SPA navigation is cancelled. + * + * @event BOOMR#spa_cancel + */ + "spa_cancel": [], + + /** + * Boomerang event, subscribe via {@link BOOMR.subscribe}. + * + * Fired whenever `XMLHttpRequest.send` is called. + * + * This event will only happen if {@link BOOMR.plugins.AutoXHR} is enabled. + * + * @event BOOMR#xhr_send + * @property {object} xhr `XMLHttpRequest` object + */ + "xhr_send": [], + + /** + * Boomerang event, subscribe via {@link BOOMR.subscribe}. + * + * Fired whenever and `XMLHttpRequest` has an error (if its `status` is + * set). + * + * This event will only happen if {@link BOOMR.plugins.AutoXHR} is enabled. + * + * Also known as `onxhrerror`. + * + * @event BOOMR#xhr_error + * @property {object} data XHR data + */ + "xhr_error": [], + + /** + * Boomerang event, subscribe via {@link BOOMR.subscribe}. + * + * Fired whenever a page error has happened. + * + * This event will only happen if {@link BOOMR.plugins.Errors} is enabled. + * + * Also known as `onerror`. + * + * @event BOOMR#error + * @property {object} err Error + */ + "error": [], + + /** + * Boomerang event, subscribe via {@link BOOMR.subscribe}. + * + * Fired whenever connection information changes via the + * Network Information API. + * + * This event will only happen if {@link BOOMR.plugins.Mobile} is enabled. + * + * @event BOOMR#netinfo + * @property {object} connection `navigator.connection` + */ + "netinfo": [], + + /** + * Boomerang event, subscribe via {@link BOOMR.subscribe}. + * + * Fired whenever a Rage Click is detected. + * + * This event will only happen if {@link BOOMR.plugins.Continuity} is enabled. + * + * @event BOOMR#rage_click + * @property {Event} e Event + */ + "rage_click": [], + + /** + * Boomerang event, subscribe via {@link BOOMR.subscribe}. + * + * Fired when an early beacon is about to be sent. + * + * The subscriber can still add variables to the early beacon at this point + * by calling {@link BOOMR.addVar}. + * + * This event will only happen if {@link BOOMR.plugins.Early} is enabled. + * + * @event BOOMR#before_early_beacon + * @property {object} data Event data + */ + "before_early_beacon": [], + + /** + * Boomerang event, subscribe via {@link BOOMR.subscribe}. + * + * Fired when an BFCache navigation occurs. + * + * The subscriber can still add variables to the BFCache beacon at this point + * by calling {@link BOOMR.addVar}. + * + * This event will only happen if {@link BOOMR.plugins.BFCache} is enabled. + * + * @event BOOMR#bfcache + * @property {object} data Event data + */ + "bfcache": [] + }, + + /** + * Public events + */ + public_events: { + /** + * Public event (fired on `document`), and can be subscribed via + * `document.addEventListener("onBeforeBoomerangBeacon", ...)` or + * `document.attachEvent("onpropertychange", ...)`. + * + * Maps to {@link BOOMR#event:before_beacon} + * + * @event document#onBeforeBoomerangBeacon + * @property {object} vars Beacon variables + */ + "before_beacon": "onBeforeBoomerangBeacon", + + /** + * Public event (fired on `document`), and can be subscribed via + * `document.addEventListener("onBoomerangBeacon", ...)` or + * `document.attachEvent("onpropertychange", ...)`. + * + * Maps to {@link BOOMR#event:before_beacon} + * + * @event document#onBoomerangBeacon + * @property {object} vars Beacon variables + */ + "beacon": "onBoomerangBeacon", + + /** + * Public event (fired on `document`), and can be subscribed via + * `document.addEventListener("onBoomerangLoaded", ...)` or + * `document.attachEvent("onpropertychange", ...)`. + * + * Fired when {@link BOOMR} has loaded and can be used. + * + * @event document#onBoomerangLoaded + */ + "onboomerangloaded": "onBoomerangLoaded" + }, + + /** + * Maps old event names to their updated name + */ + translate_events: { + "onbeacon": "beacon", + "onconfig": "config", + "onerror": "error", + "onxhrerror": "xhr_error" + }, + + // (End events) + + // + // Private Functions + // + + /** + * Creates a callback handler for the specified event type + * + * @param {string} type Event type + * @returns {function} Callback handler + */ + createCallbackHandler: function(type) { + return function(ev) { + var target; + + if (!ev) { + ev = w.event; + } + + if (ev.target) { + target = ev.target; + } + else if (ev.srcElement) { + target = ev.srcElement; + } + + if (target.nodeType === 3) { + // defeat Safari bug + target = target.parentNode; + } + + // don't capture events on flash objects + // because of context slowdowns in PepperFlash + if (target && + target.nodeName && + target.nodeName.toUpperCase() === "OBJECT" && + target.type === "application/x-shockwave-flash") { + return; + } + + impl.fireEvent(type, target); + }; + }, + + /** + * Clears all events + */ + clearEvents: function() { + var eventName; + + for (eventName in this.events) { + if (this.events.hasOwnProperty(eventName)) { + this.events[eventName] = []; + } + } + }, + + /** + * Clears all event listeners + */ + clearListeners: function() { + var type, i; + + for (type in impl.listenerCallbacks) { + if (impl.listenerCallbacks.hasOwnProperty(type)) { + // remove all callbacks -- removeListener is guaranteed + // to remove the element we're calling with + while (impl.listenerCallbacks[type].length) { + BOOMR.utils.removeListener( + impl.listenerCallbacks[type][0].el, + type, + impl.listenerCallbacks[type][0].fn); + } + } + } + + impl.listenerCallbacks = {}; + }, + + /** + * Fires the specified boomerang.js event. + * + * @param {string} e_name Event name + * @param {object} data Event data + */ + fireEvent: function(e_name, data) { + var i, handler, handlers, handlersLen; + + e_name = e_name.toLowerCase(); + + /* BEGIN_DEBUG */ + BOOMR.utils.mark("fire_event"); + BOOMR.utils.mark("fire_event:" + e_name + ":start"); + /* END_DEBUG */ + + // translate old names + if (this.translate_events[e_name]) { + e_name = this.translate_events[e_name]; + } + + if (!this.events.hasOwnProperty(e_name)) { + return; + } + + if (this.public_events.hasOwnProperty(e_name)) { + dispatchEvent(this.public_events[e_name], data); + } + + handlers = this.events[e_name]; + + // Before we fire any event listeners, let's call real_sendBeacon() to flush + // any beacon that is being held by the setImmediate. + if (e_name !== "before_beacon" && e_name !== "beacon" && e_name !== "before_early_beacon") { + BOOMR.real_sendBeacon(); + } + + // only call handlers at the time of fireEvent (and not handlers that are + // added during this callback to avoid an infinite loop) + handlersLen = handlers.length; + for (i = 0; i < handlersLen; i++) { + try { + handler = handlers[i]; + handler.fn.call(handler.scope, data, handler.cb_data); + } + catch (err) { + BOOMR.addError(err, "fireEvent." + e_name + "<" + i + ">"); + } + } + + // remove any 'once' handlers now that we've fired all of them + for (i = 0; i < handlersLen; i++) { + if (handlers[i].once) { + handlers.splice(i, 1); + handlersLen--; + i--; + } + } + + /* BEGIN_DEBUG */ + BOOMR.utils.mark("fire_event:" + e_name + ":end"); + BOOMR.utils.measure( + "fire_event:" + e_name, + "fire_event:" + e_name + ":start", + "fire_event:" + e_name + ":end"); + /* END_DEBUG */ + + return; + }, + + /** + * Notes when a SPA navigation has happened. + */ + spaNavigation: function() { + // a SPA navigation occured, force onloadfired to true + impl.onloadfired = true; + }, + + /** + * Determines whether a beacon URL is allowed based on + * `beacon_urls_allowed` config + * + * @param {string} url URL to test + * + */ + beaconUrlAllowed: function(url) { + if (!impl.beacon_urls_allowed || impl.beacon_urls_allowed.length === 0) { + return true; + } + + for (var i = 0; i < impl.beacon_urls_allowed.length; i++) { + var regEx = new RegExp(impl.beacon_urls_allowed[i]); + + if (regEx.exec(url)) { + return true; + } + } + + return false; + }, + + /** + * Checks browser for localStorage support + */ + checkLocalStorageSupport: function() { + var name = impl.LOCAL_STORAGE_PREFIX + "clss"; + + impl.localStorageSupported = false; + + // Browsers with cookies disabled or in private/incognito mode may throw an + // error when accessing the localStorage variable + try { + // we need JSON and localStorage support + if (!w.JSON || !w.localStorage) { + return; + } + + w.localStorage.setItem(name, name); + impl.localStorageSupported = (w.localStorage.getItem(name) === name); + } + catch (ignore) { + impl.localStorageSupported = false; + } + finally { + // If unsupported, then setItem threw and removeItem will also throw. + try { + if (w.localStorage) { + w.localStorage.removeItem(name); + } + } + catch (ignore) { + // empty + } + } + }, + + /** + * Fired when the Boomerang IFRAME is unloaded. + * + * If Boomerang was loaded into the root document, this code + * will not run. + */ + onFrameUnloaded: function() { + var i, prop; + + BOOMR.isUnloaded = true; + + // swap the original function back in for any overwrites + for (i = 0; i < impl.nativeOverwrites.length; i++) { + prop = impl.nativeOverwrites[i]; + + prop.obj[prop.functionName] = prop.origFn; + } + + impl.nativeOverwrites = []; + } + }; + + // + // Public BOOMR object + // + // We create a boomr object and then copy all its properties to BOOMR so that + // we don't overwrite anything additional that was added to BOOMR before this + // was called... for example, a plugin. + boomr = { + /** + * The timestamp when boomerang.js showed up on the page. + * + * This is the value of `BOOMR_start` we set earlier. + * @type {TimeStamp} + * + * @memberof BOOMR + */ + t_start: BOOMR_start, + + /** + * When the Boomerang plugins have all run. + * + * This value is generally set in zzz-last-plugin.js. + * @type {TimeStamp} + * + * @memberof BOOMR + */ + t_end: undefined, + + /** + * URL of boomerang.js. + * + * @type {string} + * + * @memberof BOOMR + */ + url: "", + + /** + * (Optional) URL of configuration file + * + * @type {string} + * + * @memberof BOOMR + */ + config_url: null, + + /** + * Whether or not Boomerang was loaded after the `onload` event. + * + * @type {boolean} + * + * @memberof BOOMR + */ + loadedLate: false, + + /** + * Current number of beacons sent. + * + * Will be incremented and added to outgoing beacon as `n`. + * + * @type {number} + * + */ + beaconsSent: 0, + + /** + * Whether or not Boomerang thinks it has been unloaded (if it was + * loaded in an IFRAME) + * + * @type {boolean} + */ + isUnloaded: false, + + /** + * Whether or not we're in the middle of building a beacon. + * + * If so, the code desiring to send a beacon should wait until the beacon + * event and try again. At that point, it should set this flag to true. + * + * @type {boolean} + */ + beaconInQueue: false, + + /* + * Cache of cookies set + */ + cookies: {}, + + /** + * Whether or not we've tested cookie setting + */ + testedCookies: false, + + /** + * Constants visible to the world + * @class BOOMR.constants + */ + constants: { + /** + * SPA beacon types + * + * @type {string[]} + * + * @memberof BOOMR.constants + */ + BEACON_TYPE_SPAS: ["spa", "spa_hard"], + + /** + * Maximum GET URL length. + * Using 2000 here as a de facto maximum URL length based on: + * https://stackoverflow.com/questions/417142/what-is-the-maximum-length-of-a-url-in-different-browsers + * + * @type {number} + * + * @memberof BOOMR.constants + */ + MAX_GET_LENGTH: 2000 + }, + + /** + * Session data + * @class BOOMR.session + */ + session: { + /** + * Session Domain. + * + * You can disable all cookies by setting site_domain to a falsy value. + * + * @type {string} + * + * @memberof BOOMR.session + */ + domain: impl.site_domain, + + /** + * Session ID. This will be randomly generated in the client but may + * be overwritten by the server if not set. + * + * @type {string} + * + * @memberof BOOMR.session + */ + ID: undefined, + + /** + * Session start time. + * + * @type {TimeStamp} + * + * @memberof BOOMR.session + */ + start: undefined, + + /** + * Session length (number of pages) + * + * @type {number} + * + * @memberof BOOMR.session + */ + length: 0, + + /** + * Session enabled (Are session cookies enabled?) + * + * @type {boolean} + * + * @memberof BOOMR.session + */ + enabled: true + }, + + /** + * @class BOOMR.utils + */ + utils: { + /** + * Determines whether or not the browser has `postMessage` support + * + * @returns {boolean} True if supported + */ + hasPostMessageSupport: function() { + if (!w.postMessage || typeof w.postMessage !== "function" && typeof w.postMessage !== "object") { + return false; + } + + return true; + }, + + /** + * Converts an object to a string. + * + * @param {object} o Object + * @param {string} separator Member separator + * @param {number} nest_level Number of levels to recurse + * + * @returns {string} String representation of the object + * + * @memberof BOOMR.utils + */ + objectToString: function(o, separator, nest_level) { + var value = [], + k; + + if (!o || typeof o !== "object") { + return o; + } + + if (separator === undefined) { + separator = "\n\t"; + } + + if (!nest_level) { + nest_level = 0; + } + + if (BOOMR.utils.isArray(o)) { + for (k = 0; k < o.length; k++) { + if (nest_level > 0 && o[k] !== null && typeof o[k] === "object") { + value.push( + this.objectToString( + o[k], + separator + (separator === "\n\t" ? "\t" : ""), + nest_level - 1 + ) + ); + } + else { + if (separator === "&") { + value.push(encodeURIComponent(o[k])); + } + else { + value.push(o[k]); + } + } + } + + separator = ","; + } + else { + for (k in o) { + if (Object.prototype.hasOwnProperty.call(o, k)) { + if (nest_level > 0 && o[k] !== null && typeof o[k] === "object") { + value.push(encodeURIComponent(k) + "=" + + this.objectToString( + o[k], + separator + (separator === "\n\t" ? "\t" : ""), + nest_level - 1 + ) + ); + } + else { + if (separator === "&") { + value.push(encodeURIComponent(k) + "=" + encodeURIComponent(o[k])); + } + else { + value.push(k + "=" + o[k]); + } + } + } + } + } + + return value.join(separator); + }, + + /** + * Gets the cached value of the cookie identified by `name`. + * + * @param {string} name Cookie name + * + * @returns {string|undefined} Cookie value, if set. + * + * @memberof BOOMR.utils + */ + getCookie: function(name) { + var cookieVal; + + if (!name) { + return null; + } + + /* BEGIN_DEBUG */ + BOOMR.utils.mark("get_cookie"); + /* END_DEBUG */ + + if (typeof BOOMR.cookies[name] !== "undefined") { + // a cached value of false indicates that the value doesn't exist, if so, + // return undefined per the API + return BOOMR.cookies[name] === false ? undefined : BOOMR.cookies[name]; + } + + // unknown value + cookieVal = this.getRawCookie(name); + + if (typeof cookieVal === "undefined") { + // set to false to indicate we've attempted to get this cookie + BOOMR.cookies[name] = false; + + // but return undefined per the API + return undefined; + } + + BOOMR.cookies[name] = cookieVal; + + return BOOMR.cookies[name]; + }, + + /** + * Gets the value of the cookie identified by `name`. + * + * @param {string} name Cookie name + * + * @returns {string|null} Cookie value, if set. + * + * @memberof BOOMR.utils + */ + getRawCookie: function(name) { + if (!name) { + return null; + } + + /* BEGIN_DEBUG */ + BOOMR.utils.mark("get_raw_cookie"); + /* END_DEBUG */ + + name = " " + name + "="; + + var i, cookies; + + cookies = " " + d.cookie + ";"; + + if ((i = cookies.indexOf(name)) >= 0) { + i += name.length; + + return cookies.substring(i, cookies.indexOf(";", i)).replace(/^"/, "").replace(/"$/, ""); + } + }, + + /** + * Sets the cookie named `name` to the serialized value of `subcookies`. + * + * @param {string} name The name of the cookie + * @param {object} subcookies Key/value pairs to write into the cookie. + * These will be serialized as an & separated list of URL encoded key=value pairs. + * @param {number} max_age Lifetime in seconds of the cookie. + * Set this to 0 to create a session cookie that expires when + * the browser is closed. If not set, defaults to 0. + * + * @returns {boolean} True if the cookie was set successfully + * + * @example + * BOOMR.utils.setCookie("RT", { s: t_start, r: url }); + * + * @memberof BOOMR.utils + */ + setCookie: function(name, subcookies, max_age) { + var value, nameval, savedval, c, exp; + + if (!name || !BOOMR.session.domain || typeof subcookies === "undefined") { + BOOMR.debug("Invalid parameters or site domain: " + name + "/" + subcookies + "/" + BOOMR.session.domain); + + BOOMR.addVar("nocookie", 1); + + return false; + } + + /* BEGIN_DEBUG */ + BOOMR.utils.mark("set_cookie"); + /* END_DEBUG */ + + value = this.objectToString(subcookies, "&"); + + if (value === BOOMR.cookies[name]) { + // no change + return true; + } + + nameval = name + "=\"" + value + "\""; + + if (nameval.length < 500) { + c = [nameval, "path=/", "domain=" + BOOMR.session.domain]; + + if (typeof max_age === "number") { + exp = new Date(); + exp.setTime(exp.getTime() + max_age * 1000); + exp = exp.toGMTString(); + c.push("expires=" + exp); + } + + var extraAttributes = this.getSameSiteAttributeParts(); + + /** + * 1. We check if the Secure attribute wasn't added already because SameSite=None will force adding it. + * 2. We check the current protocol because if we are on HTTP and we try to create a secure cookie with + * SameSite=Strict then a cookie will be created with SameSite=Lax. + */ + if (location.protocol === "https:" && + impl.secure_cookie === true && + extraAttributes.indexOf("Secure") === -1) { + extraAttributes.push("Secure"); + } + + // add extra attributes + c = c.concat(extraAttributes); + + /* BEGIN_DEBUG */ + BOOMR.utils.mark("set_cookie_real"); + /* END_DEBUG */ + + // set the cookie + d.cookie = c.join("; "); + + // we only need to test setting the cookie once + if (BOOMR.testedCookies) { + // only cache this cookie value if the expiry is in the future + if (typeof max_age !== "number" || max_age > 0) { + BOOMR.cookies[name] = value; + } + else { + // the cookie is going to expire right away, don't cache it + BOOMR.cookies[name] = undefined; + } + + return true; + } + + // unset the cached cookie value, in case the set doesn't work + BOOMR.cookies[name] = undefined; + + // confirm cookie was set (could be blocked by user's settings, etc.) + savedval = this.getRawCookie(name); + + // the saved cookie should be the same or undefined in the case of removeCookie + if (value === savedval || + (typeof savedval === "undefined" && typeof max_age === "number" && max_age <= 0)) { + // re-set the cached value + BOOMR.cookies[name] = value; + + // note we've saved successfully + BOOMR.testedCookies = true; + + BOOMR.removeVar("nocookie"); + + return true; + } + + BOOMR.warn("Saved cookie value doesn't match what we tried to set:\n" + value + "\n" + savedval); + } + else { + BOOMR.warn("Cookie too long: " + nameval.length + " " + nameval); + } + + BOOMR.addVar("nocookie", 1); + + return false; + }, + + /** + * Parse a cookie string returned by {@link BOOMR.utils.getCookie} and + * split it into its constituent subcookies. + * + * @param {string} cookie Cookie value + * + * @returns {object} On success, an object of key/value pairs of all + * sub cookies. Note that some subcookies may have empty values. + * `null` if `cookie` was not set or did not contain valid subcookies. + * + * @memberof BOOMR.utils + */ + getSubCookies: function(cookie) { + var cookies_a, + i, l, kv, + gotcookies = false, + cookies = {}; + + if (!cookie) { + return null; + } + + if (typeof cookie !== "string") { + BOOMR.debug("TypeError: cookie is not a string: " + typeof cookie); + + return null; + } + + cookies_a = cookie.split("&"); + + for (i = 0, l = cookies_a.length; i < l; i++) { + kv = cookies_a[i].split("="); + + if (kv[0]) { + // just in case there's no value + kv.push(""); + cookies[decodeURIComponent(kv[0])] = decodeURIComponent(kv[1]); + gotcookies = true; + } + } + + return gotcookies ? cookies : null; + }, + + /** + * Removes the cookie identified by `name` by nullifying its value, + * and making it a session cookie. + * + * @param {string} name Cookie name + * + * @memberof BOOMR.utils + */ + removeCookie: function(name) { + return this.setCookie(name, {}, -86400); + }, + + /** + * Depending on Boomerang configuration and checks of current protocol and + * compatible browsers the logic below will provide an array of cookie + * attributes that are needed for a successful creation of a cookie that + * contains the SameSite attribute. + * + * How it works: + * 1. We read the Boomerang configuration key `same_site_cookie` where + * one of the following values `None`, `Lax` or `Strict` is expected. + * 2. A configuration value of `same_site_cookie` will be read in case-insensitive + * manner. E.g. `Lax`, `lax` and `lAx` will produce same result - `SameSite=Lax`. + * 3. If a `same_site_cookie` configuration value is not specified a cookie + * will be created with `SameSite=Lax`. + * 4. If a `same_site_cookie` configuration value does't match any of + * `None`, `Lax` or `Strict` then a cookie will be created with `SameSite=Lax`. + * 5. The `Secure` cookie attribute will be added when a cookie is created + * with `SameSite=None`. + * 6. It's possible that a Boomerang plugin or external code may need cookies + * to be created with `SameSite=None`. In such cases we check a special + * flag `forced_same_site_cookie_none`. If the value of this flag is equal to `true` + * then the `same_site_cookie` value will be ignored and Boomerang cookies + * will be created with `SameSite=None`. + * + * SameSite=None - INCOMPATIBILITIES and EXCEPTIONS: + * + * There are known problems with older browsers where cookies created + * with `SameSite=None` are `dropped` or created with `SameSite=Strict`. + * Reference: https://www.chromium.org/updates/same-site/incompatible-clients + * + * 1. If we detect a browser that can't create safely a cookie with `SameSite=None` + * then Boomerang will create a cookie without the `SameSite` attribute. + * 2. A cookie with `SameSite=None` can be created only over `HTTPS` connection. + * If current connection is `HTTP` then a cookie will be created + * without the `SameSite` attribute. + * + * + * @returns {Array} of cookie attributes used for setting a cookie with SameSite attribute + * + * @memberof BOOMR.utils + */ + getSameSiteAttributeParts: function() { + var sameSiteMode = impl.same_site_cookie.toUpperCase(); + + if (impl.forced_same_site_cookie_none) { + sameSiteMode = "NONE"; + } + + if (sameSiteMode === "LAX") { + return ["SameSite=Lax"]; + } + + if (sameSiteMode === "NONE") { + if (location.protocol === "https:" && this.isCurrentUASameSiteNoneCompatible()) { + return ["SameSite=None", "Secure"]; + } + + // Fallback to browser's default + return []; + } + + if (sameSiteMode === "STRICT") { + return ["SameSite=Strict"]; + } + + return ["SameSite=Lax"]; + }, + + /** + * Retrieve items from localStorage + * + * @param {string} name Name of storage + * + * @returns {object|null} Returns object retrieved from localStorage. + * Returns undefined if not found or expired. + * Returns null if parameters are invalid or an error occured + * + * @memberof BOOMR.utils + */ + getLocalStorage: function(name) { + var value, data; + + if (!name || !impl.localStorageSupported) { + return null; + } + + /* BEGIN_DEBUG */ + BOOMR.utils.mark("get_local_storage"); + /* END_DEBUG */ + + try { + value = w.localStorage.getItem(impl.LOCAL_STORAGE_PREFIX + name); + + if (value === null) { + return undefined; + } + + data = w.JSON.parse(value); + } + catch (e) { + BOOMR.warn(e); + + return null; + } + + if (!data || typeof data.items !== "object") { + // Items are invalid + this.removeLocalStorage(name); + + return null; + } + + if (typeof data.expires === "number") { + if (BOOMR.now() >= data.expires) { + // Items are expired + this.removeLocalStorage(name); + + return undefined; + } + } + + return data.items; + }, + + /** + * Saves items in localStorage + * The value stored in localStorage will be a JSON string representation of {"items": items, "expiry": expiry} + * where items is the object we're saving and expiry is an optional epoch number of when the data is to be + * considered expired + * + * @param {string} name Name of storage + * @param {object} items Items to be saved + * @param {number} max_age Age in seconds before items are to be considered expired + * + * @returns {boolean} True if the localStorage was set successfully + * + * @memberof BOOMR.utils + */ + setLocalStorage: function(name, items, max_age) { + var data, value, savedval; + + if (!name || !impl.localStorageSupported || typeof items !== "object") { + return false; + } + + /* BEGIN_DEBUG */ + BOOMR.utils.mark("set_local_storage"); + /* END_DEBUG */ + + data = {"items": items}; + + if (typeof max_age === "number") { + data.expires = BOOMR.now() + (max_age * 1000); + } + + value = w.JSON.stringify(data); + + if (value.length < 50000) { + try { + w.localStorage.setItem(impl.LOCAL_STORAGE_PREFIX + name, value); + // confirm storage was set (could be blocked by user's settings, etc.) + savedval = w.localStorage.getItem(impl.LOCAL_STORAGE_PREFIX + name); + + if (value === savedval) { + return true; + } + } + catch (ignore) { + // Empty + } + + BOOMR.warn("Saved storage value doesn't match what we tried to set:\n" + value + "\n" + savedval); + } + else { + BOOMR.warn("Storage items too large: " + value.length + " " + value); + } + + return false; + }, + + /** + * Remove items from localStorage + * + * @param {string} name Name of storage + * + * @returns {boolean} True if item was removed from localStorage. + * + * @memberof BOOMR.utils + */ + removeLocalStorage: function(name) { + if (!name || !impl.localStorageSupported) { + return false; + } + try { + w.localStorage.removeItem(impl.LOCAL_STORAGE_PREFIX + name); + + return true; + } + catch (ignore) { + // Empty + } + + return false; + }, + + /** + * Cleans up a URL by removing the query string (if configured), and + * limits the URL to the specified size. + * + * @param {string} url URL to clean + * @param {number} urlLimit Maximum size, in characters, of the URL + * + * @returns {string} Cleaned up URL + * + * @memberof BOOMR.utils + */ + cleanupURL: function(url, urlLimit) { + if (!url || BOOMR.utils.isArray(url)) { + return ""; + } + + if (impl.strip_query_string) { + url = url.replace(/\?.*/, "?qs-redacted"); + } + + if (typeof urlLimit !== "undefined" && url && url.length > urlLimit) { + // We need to break this URL up. Try at the query string first. + var qsStart = url.indexOf("?"); + + if (qsStart !== -1 && qsStart < urlLimit) { + url = url.substr(0, qsStart) + "?..."; + } + else { + // No query string, just stop at the limit + url = url.substr(0, urlLimit - 3) + "..."; + } + } + + return url; + }, + + /** + * Gets the URL with the query string replaced with a hash of its contents. + * + * @param {string} url URL + * @param {boolean} stripHash Whether or not to strip the hash + * + * @returns {string} URL with query string hashed + * + * @memberof BOOMR.utils + */ + hashQueryString: function(url, stripHash) { + if (!url) { + return url; + } + + if (!url.match) { + BOOMR.addError("TypeError: Not a string", "hashQueryString", typeof url); + + return ""; + } + + if (url.match(/^\/\//)) { + url = location.protocol + url; + } + + if (!url.match(/^(https?|file):/)) { + BOOMR.error("Passed in URL is invalid: " + url); + + return ""; + } + + if (stripHash) { + url = url.replace(/#.*/, ""); + } + + return url.replace(/\?([^#]*)/, function(m0, m1) { + return "?" + (m1.length > 10 ? BOOMR.utils.hashString(m1) : m1); + }); + }, + + /** + * Sets the object's properties if anything in config matches + * one of the property names. + * + * @param {object} o The plugin's `impl` object within which it stores + * all its configuration and private properties + * @param {object} config The config object passed in to the plugin's + * `init()` method. + * @param {string} plugin_name The plugin's name in the {@link BOOMR.plugins} object. + * @param {string[]} properties An array containing a list of all configurable + * properties that this plugin has. + * + * @returns {boolean} True if a property was set + * + * @memberof BOOMR.utils + */ + pluginConfig: function(o, config, plugin_name, properties) { + var i, + props = 0; + + if (!config || !config[plugin_name]) { + return false; + } + + for (i = 0; i < properties.length; i++) { + if (config[plugin_name][properties[i]] !== undefined) { + o[properties[i]] = config[plugin_name][properties[i]]; + props++; + } + } + + return (props > 0); + }, + + /** + * `filter` for arrays + * + * @param {Array} array The array to iterate over. + * @param {Function} predicate The function invoked per iteration. + * + * @returns {Array} Returns the new filtered array. + * + * @memberof BOOMR.utils + */ + arrayFilter: function(array, predicate) { + var result = []; + + if (!(this.isArray(array) || (array && typeof array.length === "number")) || + typeof predicate !== "function") { + return result; + } + + if (typeof array.filter === "function") { + result = array.filter(predicate); + } + else { + var index = -1, + length = array.length, + value; + + while (++index < length) { + value = array[index]; + + if (predicate(value, index, array)) { + result[result.length] = value; + } + } + } + + return result; + }, + + /** + * `find` for Arrays + * + * @param {Array} array The array to iterate over + * @param {Function} predicate The function invoked per iteration + * + * @returns {Array} Returns the value of first element that satisfies + * the predicate + * + * @memberof BOOMR.utils + */ + arrayFind: function(array, predicate) { + if (!(this.isArray(array) || (array && typeof array.length === "number")) || + typeof predicate !== "function") { + return undefined; + } + + if (typeof array.find === "function") { + return array.find(predicate); + } + else { + var index = -1, + length = array.length, + value; + + while (++index < length) { + value = array[index]; + + if (predicate(value, index, array)) { + return value; + } + } + + return undefined; + } + }, + + /** + * MutationObserver feature detection + * + * Always returns false for IE 11 due several bugs in it's implementation that MS flagged as Won't Fix. + * In IE11, XHR responseXML might be malformed if MO is enabled (where extra newlines get added in nodes + * with UTF-8 content). + * + * Another IE 11 MO bug can cause the process to crash when certain mutations occur. + * + * For the process crash issue, see https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/8137215/ + * and + * https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/15167323/ + * + * @returns {boolean} Returns true if MutationObserver is supported. + * + * @memberof BOOMR.utils + */ + isMutationObserverSupported: function() { + // We can only detect IE 11 bugs by UA sniffing. + var ie11 = (w && + w.navigator && + !w.navigator.userAgentData && + w.navigator.userAgent && + w.navigator.userAgent.match(/Trident.*rv[ :]*11\./)); + + return (!ie11 && w && w.MutationObserver && typeof w.MutationObserver === "function"); + }, + + /** + * The callback function may return a falsy value to disconnect the + * observer after it returns, or a truthy value to keep watching for + * mutations. If the return value is numeric and greater than 0, then + * this will be the new timeout. If it is boolean instead, then the + * timeout will not fire any more so the caller MUST call disconnect() + * at some point. + * + * @callback BOOMR~addObserverCallback + * @param {object[]} mutations List of mutations detected by the observer or `undefined` if the observer timed out + * @param {object} callback_data Is the passed in `callback_data` parameter without modifications + */ + + /** + * Add a MutationObserver for a given element and terminate after `timeout`ms. + * + * @param {DOMElement} el DOM element to watch for mutations + * @param {MutationObserverInit} config MutationObserverInit object + * (https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver#MutationObserverInit) + * @param {number} timeout Number of milliseconds of no mutations after which the observer should be + * automatically disconnected. + * If set to a falsy value, the observer will wait indefinitely for Mutations. + * @param {BOOMR~addObserverCallback} callback Callback function to call either on timeout or if mutations + * are detected. + * @param {object} callback_data Any data to be passed to the callback function as its second parameter. + * @param {object} callback_ctx An object that represents the `this` object of the `callback` method. + * Leave unset the callback function is not a method of an object. + * + * @returns {object|null} + * - `null` if a MutationObserver could not be created OR + * - An object containing the observer and the timer object: + * `{ observer: , timer: }` + * - The caller can use this to disconnect the observer at any point + * by calling `retval.observer.disconnect()` + * - Note that the caller should first check to see if `retval.observer` + * is set before calling `disconnect()` as it may have been cleared automatically. + * + * @memberof BOOMR.utils + */ + addObserver: function(el, config, timeout, callback, callback_data, callback_ctx) { + var MO, zs, + o = {observer: null, timer: null}; + + /* BEGIN_DEBUG */ + BOOMR.utils.mark("add_observer"); + /* END_DEBUG */ + + if (!this.isMutationObserverSupported() || !callback || !el) { + return null; + } + + function done(mutations) { + var run_again = false; + + /* BEGIN_DEBUG */ + BOOMR.utils.mark("mutation_observer_callback"); + /* END_DEBUG */ + + if (o.timer) { + clearTimeout(o.timer); + o.timer = null; + } + + if (callback) { + run_again = callback.call(callback_ctx, mutations, callback_data); + + if (!run_again) { + callback = null; + } + } + + if (!run_again && o.observer) { + o.observer.disconnect(); + o.observer = null; + } + + if (typeof run_again === "number" && run_again > 0) { + o.timer = setTimeout(done, run_again); + } + } + + MO = w.MutationObserver; + + // if the site uses Zone.js then get the native MutationObserver + if (w.Zone && typeof w.Zone.__symbol__ === "function") { + zs = w.Zone.__symbol__("MutationObserver"); + + if (zs && typeof zs === "string" && w.hasOwnProperty(zs) && typeof w[zs] === "function") { + BOOMR.debug("Detected Zone.js, using native MutationObserver"); + MO = w[zs]; + } + } + + o.observer = new MO(done); + + if (timeout) { + o.timer = setTimeout(done, o.timeout); + } + + o.observer.observe(el, config); + + return o; + }, + + /** + * Adds an event listener. + * + * @param {DOMElement} el DOM element + * @param {string} type Event name + * @param {function} fn Callback function + * @param {boolean|object} passiveOrOpts Passive mode or Options object + * + * @memberof BOOMR.utils + */ + addListener: function(el, type, fn, passiveOrOpts) { + var opts = false; + + /* BEGIN_DEBUG */ + BOOMR.utils.mark("add_listener"); + /* END_DEBUG */ + + if (el.addEventListener) { + if (typeof passiveOrOpts === "object") { + opts = passiveOrOpts; + } + else if (typeof passiveOrOpts === "boolean" && passiveOrOpts && BOOMR.browser.supportsPassive()) { + opts = { + capture: false, + passive: true + }; + } + + el.addEventListener(type, fn, opts); + } + else if (el.attachEvent) { + el.attachEvent("on" + type, fn); + } + + // ensure the type arry exists + impl.listenerCallbacks[type] = impl.listenerCallbacks[type] || []; + + // save a reference to the target object and function + impl.listenerCallbacks[type].push({ el: el, fn: fn}); + }, + + /** + * Removes an event listener. + * + * @param {DOMElement} el DOM element + * @param {string} type Event name + * @param {function} fn Callback function + * + * @memberof BOOMR.utils + */ + removeListener: function(el, type, fn) { + var i; + + /* BEGIN_DEBUG */ + BOOMR.utils.mark("remove_listener"); + /* END_DEBUG */ + + if (el.removeEventListener) { + // NOTE: We don't need to match any other options (e.g. passive) + // from addEventListener, as removeEventListener only cares + // about captive. + el.removeEventListener(type, fn, false); + } + else if (el.detachEvent) { + el.detachEvent("on" + type, fn); + } + + if (impl.listenerCallbacks.hasOwnProperty(type)) { + for (var i = 0; i < impl.listenerCallbacks[type].length; i++) { + if (fn === impl.listenerCallbacks[type][i].fn && + el === impl.listenerCallbacks[type][i].el) { + impl.listenerCallbacks[type].splice(i, 1); + + return; + } + } + } + }, + + /** + * Determines if the specified object is an `Array` or not + * + * @param {object} ary Object in question + * + * @returns {boolean} True if the object is an `Array` + * + * @memberof BOOMR.utils + */ + isArray: function(ary) { + return Object.prototype.toString.call(ary) === "[object Array]"; + }, + + /** + * Determines if the specified value is in the array + * + * @param {object} val Value to check + * @param {object} ary Object in question + * + * @returns {boolean} True if the value is in the Array + * + * @memberof BOOMR.utils + */ + inArray: function(val, ary) { + var i; + + if (typeof val === "undefined" || typeof ary === "undefined" || !ary.length) { + return false; + } + + for (i = 0; i < ary.length; i++) { + if (ary[i] === val) { + return true; + } + } + + return false; + }, + + /** + * Get a query parameter value from a URL's query string + * + * @param {string} param Query parameter name + * @param {string|Object} [url] URL containing the query string, or a link object. + * Defaults to `BOOMR.window.location` + * + * @returns {string|null} URI decoded value or null if param isn't a query parameter + * + * @memberof BOOMR.utils + */ + getQueryParamValue: function(param, url) { + var l, params, i, kv; + + if (!param) { + return null; + } + + if (typeof url === "string") { + l = BOOMR.window.document.createElement("a"); + l.href = url; + } + else if (typeof url === "object" && typeof url.search === "string") { + l = url; + } + else { + l = BOOMR.window.location; + } + + // Now that we match, pull out all query string parameters + params = l.search.slice(1).split(/&/); + + for (i = 0; i < params.length; i++) { + if (params[i]) { + kv = params[i].split("="); + + if (kv.length && kv[0] === param) { + try { + return kv.length > 1 ? decodeURIComponent(kv.splice(1).join("=").replace(/\+/g, " ")) : ""; + } + catch (e) { + /** + * We have different messages for the same error in different browsers but + * we can look at the error name because it looks more consistent. + * + * Examples: + * - URIError: The URI to be encoded contains invalid character (Edge) + * - URIError: malformed URI sequence (Firefox) + * - URIError: URI malformed (Chrome) + * - URIError: URI error (Safari 13.0) / Missing on MDN but this is the result of my local tests. + * + * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Malformed_URI#Message + */ + if (e && typeof e.name === "string" && e.name.indexOf("URIError") !== -1) { + // NOP + } + else { + throw e; + } + } + } + } + } + + return null; + }, + + /** + * Generates a pseudo-random UUID (Version 4): + * https://en.wikipedia.org/wiki/Universally_unique_identifier + * + * @returns {string} UUID + * + * @memberof BOOMR.utils + */ + generateUUID: function() { + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function(c) { + var r = Math.random() * 16 | 0; + var v = c === "x" ? r : (r & 0x3 | 0x8); + + return v.toString(16); + }); + }, + + /** + * Generates a random ID based on the specified number of characters. Uses + * characters a-z0-9. + * + * @param {number} chars Number of characters (max 40) + * + * @returns {string} Random ID + * + * @memberof BOOMR.utils + */ + generateId: function(chars) { + return "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx".substr(0, chars || 40).replace(/x/g, function(c) { + var c = (Math.random() || 0.01).toString(36); + + // some implementations may return "0" for small numbers + if (c === "0") { + return "0"; + } + else { + return c.substr(2, 1); + } + }); + }, + + /** + * Attempt to serialize an object, preferring JSURL over JSON.stringify + * + * @param {Object} value Object to serialize + * @returns {string} serialized version of value, empty-string if not possible + */ + serializeForUrl: function(value) { + if (BOOMR.utils.Compression && BOOMR.utils.Compression.jsUrl) { + return BOOMR.utils.Compression.jsUrl(value); + } + + if (window.JSON) { + return JSON.stringify(value); + } + + // not supported + BOOMR.debug("JSON is not supported"); + + return ""; + }, + + /* BEGIN_DEBUG */ + /** + * Attempt to deserialize an object, preferring JSURL over JSON.parse + * + * @param {string} value String to deserialize + * + * @returns {object|undefined} Deserialized version of value, undefined if not possible + */ + deserializeForUrl: function(value) { + if (BOOMR.utils.Compression && BOOMR.utils.Compression.jsUrlDecompress) { + return BOOMR.utils.Compression.jsUrlDecompress(value); + } + + if (window.JSON) { + return JSON.parse(value); + } + + // not supported + BOOMR.debug("JSON is not supported"); + + return; + }, + /* END_DEBUG */ + + /** + * Attempt to identify the URL of boomerang itself using multiple methods for cross-browser support + * + * This method uses document.currentScript (which cannot be called from an event handler), + * script.readyState (IE6-10), + * and the stack property of a caught Error object. + * + * @returns {string} The URL of the currently executing boomerang script. + */ + getMyURL: function() { + var stack; + // document.currentScript works in all browsers except for IE: https://caniuse.com/#feat=document-currentscript + // #boomr-if-as works in all browsers if the page uses our standard iframe loader + // #boomr-scr-as works in all browsers if the page uses our preloader loader + // BOOMR_script will be undefined on IE for pages that do not use our standard loaders + + // Note that we do not use `w.document` or `d` here because we need the current execution context + var BOOMR_script = (document.currentScript || + document.getElementById("boomr-if-as") || + document.getElementById("boomr-scr-as")); + + if (BOOMR_script) { + return BOOMR_script.src; + } + + // For IE 6-10 users on pages not using the standard loader, we iterate through all scripts backwards + var scripts = document.getElementsByTagName("script"), + i; + + // i-- is both a decrement as well as a condition, ie, the loop will terminate when i goes from 0 to -1 + for (i = scripts.length; i--;) { + // We stop at the first script that has its readyState set to interactive indicating that it is + // currently executing + if (scripts[i].readyState === "interactive") { + return scripts[i].src; + } + } + + // For IE 11, we throw an Error and inspect its stack property in the catch block + // This also works on IE10, but throwing is disruptive so we try to avoid it and use + // the less disruptive script iterator above + try { + throw new Error(); + } + catch (e) { + if ("stack" in e) { + stack = this.arrayFilter(e.stack.split(/\n/), function(l) { + return l.match(/https?:\/\//); + }); + + if (stack && stack.length) { + return stack[0].replace(/.*(https?:\/\/.+?)(:\d+)+\D*$/m, "$1"); + } + } + // FWIW, on IE 8 & 9, the Error object does not contain a stack property, but if you have an uncaught error, + // and a `window.onerror` handler (not using addEventListener), then the second argument to that handler is + // the URL of the script that threw. The handler needs to `return true;` to prevent the default error handler + // This flow is asynchronous though (due to the event handler), so won't work in a function return scenario + // like this (we can't use promises because we would only need this hack in browsers that don't support + // promises). + } + + return ""; + }, + + /* + * Gets the Scroll x and y (rounded) for a page + * + * @returns {object} Scroll x and y coordinates + */ + scroll: function() { + // Adapted from: + // https://developer.mozilla.org/en-US/docs/Web/API/Window/scrollY + var supportPageOffset = w.pageXOffset !== undefined; + var isCSS1Compat = ((w.document.compatMode || "") === "CSS1Compat"); + + var ret = { + x: 0, + y: 0 + }; + + if (supportPageOffset) { + if (typeof w.pageXOffset === "function") { + ret.x = w.pageXOffset(); + ret.y = w.pageYOffset(); + } + else { + ret.x = w.pageXOffset; + ret.y = w.pageYOffset; + } + } + else if (isCSS1Compat) { + ret.x = w.document.documentElement.scrollLeft; + ret.y = w.document.documentElement.scrollTop; + } + else { + ret.x = w.document.body.scrollLeft; + ret.y = w.document.body.scrollTop; + } + + // round to full numbers + if (typeof ret.sx === "number") { + ret.sx = Math.round(ret.sx); + } + + if (typeof ret.sy === "number") { + ret.sy = Math.round(ret.sy); + } + + return ret; + }, + + /** + * Gets the window height + * + * @returns {number} Window height + */ + windowHeight: function() { + return w.innerHeight || w.document.documentElement.clientHeight || w.document.body.clientHeight; + }, + + /** + * Gets the window width + * + * @returns {number} Window width + */ + windowWidth: function() { + return w.innerWidth || w.document.documentElement.clientWidth || w.document.body.clientWidth; + }, + + /** + * Determines if the function is native or not + * + * @param {function} fn Function + * + * @returns {boolean} True when the function is native + */ + isNative: function(fn) { + return !!fn && + fn.toString && + !fn.hasOwnProperty("toString") && + /\[native code\]/.test(String(fn)); + }, + + /** + * Overwrites a function on the specified object. + * + * When the Boomerang IFRAME unloads, it will swap the old + * function back in, so calls to the functions are successful. + * + * If this isn't done, callers of the overwritten functions may still + * call into freed Boomerang code or the IFRAME that is partially unloaded, + * leading to "Freed script" errors or exceptions from accessing + * unloaded DOM properties. + * + * This tracking isn't needed if Boomerang is loaded in the root + * document, as everthing will be cleaned up along with Boomerang + * on unload. + * + * @param {object} obj Object whose property will be overwritten + * @param {string} functionName Function name + * @param {function} newFn New function + */ + overwriteNative: function(obj, functionName, newFn) { + // bail if the object doesn't exist + if (!obj || !newFn) { + return; + } + + // we only need to keep track if we're running Boomerang in + // an IFRAME + if (BOOMR.boomerang_frame !== BOOMR.window) { + // note we overwrote this + impl.nativeOverwrites.push({ + obj: obj, + functionName: functionName, + origFn: obj[functionName] + }); + } + + obj[functionName] = newFn; + }, + + /** + * Determines if the given input is an Integer. + * Relies on standard Number.isInteger() function that available + * is most browsers except IE. For IE, this relies on the polyfill + * provided by MDN: + * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/isInteger#Polyfill + * + * @param {number} input dat + * + * @returns {string} Random ID + * + * @memberof BOOMR.utils + * + */ + isInteger: function(data) { + var isInt = Number.isInteger || function(value) { + return typeof value === "number" && + isFinite(value) && + Math.floor(value) === value; + }; + + return isInt(data); + }, + + /** + * Determines whether or not an Object is empty + * + * @param {object} data Data object + * + * @returns {boolean} True if the object has no properties + */ + isObjectEmpty: function(data) { + for (var propName in data) { + if (data.hasOwnProperty(propName)) { + return false; + } + } + + return true; + }, + + /** + * Calculates the FNV hash of the specified string. + * + * @param {string} string Input string + * + * @returns {string} FNV hash of the input string + * + */ + hashString: function(string) { + string = encodeURIComponent(string); + var hval = 0x811c9dc5; + + for (var i = 0; i < string.length; i++) { + hval = hval ^ string.charCodeAt(i); + hval += (hval << 1) + (hval << 4) + (hval << 7) + (hval << 8) + (hval << 24); + } + + var hash = (hval >>> 0).toString() + string.length; + + return parseInt(hash).toString(36); + }, + + /** + * Wrapper of isUASameSiteNoneCompatible() that ensures that we pass correct User Agent string + * + * @returns {boolean} True if a browser can safely create SameSite=None cookie + * + * @memberof BOOMR.utils + */ + isCurrentUASameSiteNoneCompatible: function() { + if (w && w.navigator && !w.navigator.userAgentData) { + if (w.navigator.userAgent && typeof w.navigator.userAgent === "string") { + return this.isUASameSiteNoneCompatible(w.navigator.userAgent); + } + } + + return true; + }, + + /** + * @param {string} uaString User agent string + * + * @returns {boolean} True if a browser can safely create SameSite=None cookie + * + * @memberof BOOMR.utils + */ + isUASameSiteNoneCompatible: function(uaString) { + /** + * 1. UCBrowser lower than 12.13.2 + */ + var result = uaString.match(/(UCBrowser)\/(\d+\.\d+)\.(\d+)/); + + if (result) { + var ucMajorMinorPart = parseFloat(result[2]); + var ucPatch = result[3]; + + if (ucMajorMinorPart === 12.13) { + if (ucPatch <= 2) { + return false; + } + + return true; + } + + if (ucMajorMinorPart < 12.13) { + return false; + } + + return true; + } + + /** + * 2. Chrome and Chromium version between 51 and 66 + * + * This the regex covers both because a Chromium AU contains "Chromium/65.0.3325.181 Chrome/65.0.3325.181" + */ + result = uaString.match(/(Chrome)\/(\d+)\.(\d+)\.(\d+)\.(\d+)/); + + if (result) { + var chromeMajor = result[2]; + + if (chromeMajor >= 51 && chromeMajor <= 66) { + return false; + } + + return true; + } + + /** + * 3. Mac OS 10.14.* check + */ + result = uaString.match(/(Macintosh;.*Mac OS X 10_14[_\d]*.*) AppleWebKit\//); + + if (result) { + // 3.2 Safari check + result = uaString.match(/Version\/.* Safari\//); + + if (result) { + // 3.2.1 Not Chrome based check + result = uaString.match(/Chrom(?:e|ium)/); + + if (result === null) { + return false; + } + } + + // 3.3 Mac OS embeded browser + // eslint-disable-next-line max-len + result = uaString.match(/^Mozilla\/\d+(?:\.\d+)* \(Macintosh;.*Mac OS X \d+(?:_\d+)*\) AppleWebKit\/\d+(?:\.\d+)* \(KHTML, like Gecko\)$/); + + if (result) { + return false; + } + + return true; + } + + /** + * 4. iOS and iPad OS 12 for all browsers + */ + result = uaString.match(/(iP.+; CPU .*OS 12(?:_\d+)*.*)/); + + if (result) { + return false; + } + + return true; + }, + + /** + * Given a HTML node, returns a Pseudo-CSS selector. + * + * Algorithm: + * * Starting at the node itself, gather its selector: `elementName#elementId.elementClasses` + * * If the ID or classes are missing, skip them, e.g. just `elementName` or `elementName.elementClasses` + * * Going up the parent tree via the `.parentNode`, stop at the first of: + * * The closest node (or itself) with an ID + * * The BODY node + * * For each node found, prepend the node's selector to the full selector string + * * Limit the number of selectors added to 5 + * * If it takes more than 5 to get to an element with an ID or BODY, collapse to a `*` until they are reached + * + * For example: + * * `

` -> `div span h1` + * * `
` -> `div#test span.first` + * * `
` -> `div#test span.first` + * * `
` -> `div#test span.first.second` + * + * @param {Node} elementNode Node to generate a Pseudo-CSS selector for + * + * @return {string} Pseudo-CSS selector + * + * @memberof BOOMR.utils + */ + makeSelector: function(elementNode) { + var cssSelectors = []; + var node = elementNode; + + // checks to see if a node is valid (element type, not null) + // NOTE: Also sets the funtion-local `node` as the next node to iterate over + function validateAndSetNode(nodeToCheck, isParent) { + var isValid = true; + + // node invalid if null + if (!nodeToCheck) { + isValid = false; + } + + // if node is not valid, see if there's a parent that is + else { + // nodeType 1 == Node.ELEMENT_NODE + while (nodeToCheck && nodeToCheck.nodeType !== 1) { + nodeToCheck = nodeToCheck.parentNode || nodeToCheck.parentElement; + } + + // BODY is also invalid + if (!nodeToCheck || nodeToCheck.tagName === "BODY") { + isValid = false; + } + } + + // + // If we're not just checking to see if a parent exists, + // set node to the nearest valid parent if there is one. + // + // If node itself is valid, it will stay itself + // (only changes if invalid but has a valid parent). + if (!isParent) { + node = nodeToCheck; + } + + return isValid; + } + + while (node) { + // if node isn't null but is the wrong type, will set it to nearest valid parent + var nodeIsValid = validateAndSetNode(node, false); + + if (!nodeIsValid || !node) { + // if node is invalid and no valid parent, stop iterating + break; + } + + // add tagname and ID to selector if ID exists + if (node.hasAttribute("id")) { + var nodeId = node.tagName.toLowerCase() + + "#" + node.getAttribute("id"); + + cssSelectors.unshift(nodeId); + + // selector ends if an ID is found + break; + } + + // check if parent of this node is valid + var parentIsValid = validateAndSetNode(node.parentNode, true); + + // if we don't have three tagnames in the selector yet, + // or we do but this is the last valid one, add class if exists + if (cssSelectors.length < 3 || (cssSelectors.length >= 3 && !parentIsValid)) { + var nodeClass = node.tagName.toLowerCase(); + + if (node.hasAttribute("class")) { + nodeClass += "." + node.getAttribute("class").replace(/ +/g, "."); + } + + cssSelectors.unshift(nodeClass); + } + // if > 3 tagnames in selector and parent is valid, + // add an asterick if there is not one already + else if (cssSelectors[0] !== "*") { + cssSelectors.unshift("*"); + } + + // go to next node in sequence + node = node.parentNode || node.parentElement; + } + + // return CSS selector + return cssSelectors.join(" "); + } + + /* BEGIN_DEBUG */ + /** + * DEBUG ONLY + * + * Loops over an array, running a function for each item + * + * @param {Array} array Array to iterate over + * @param {function} fn Function to execute + * @param {object} thisArg 'this' argument + */ + , forEach: function(array, fn, thisArg) { + if (!BOOMR.utils.isArray(array) || typeof fn !== "function") { + return; + } + + var length = array.length; + + for (var i = 0; i < length; i++) { + if (array.hasOwnProperty(i)) { + fn.call(thisArg, array[i], i, array); + } + } + }, + + /** + * DEBUG ONLY + * + * Logs a UserTiming mark + * + * @param {string} name Mark name (prefixed by boomr:) + */ + mark: function(name) { + var p = BOOMR.getPerformance(); + + if (p && typeof p.mark === "function" && !BOOMR.window.BOOMR_no_mark) { + p.mark("boomr:" + name); + } + }, + + /** + * DEBUG ONLY + * + * Logs a UserTiming measure + * + * @param {string} name Mark name (prefixed by boomr:) + */ + measure: function(measureName, startMarkName, endMarkName) { + var p = BOOMR.getPerformance(); + + if (p && typeof p.measure === "function" && !BOOMR.window.BOOMR_no_mark) { + p.measure("boomr:" + measureName, + startMarkName ? "boomr:" + startMarkName : undefined, + endMarkName ? "boomr:" + endMarkName : undefined); + } + } + /* END_DEBUG */ + + // closes `utils` + }, + + /** + * Browser feature detection flags. + * + * @class BOOMR.browser + */ + browser: { + results: {}, + + /** + * Whether or not the browser supports 'passive' mode for event + * listeners + * + * @returns {boolean} True if the browser supports passive mode + */ + supportsPassive: function() { + if (typeof BOOMR.browser.results.supportsPassive === "undefined") { + BOOMR.browser.results.supportsPassive = false; + + if (!Object.defineProperty) { + return false; + } + + try { + var opts = Object.defineProperty({}, "passive", { + get: function() { + BOOMR.browser.results.supportsPassive = true; + } + }); + + window.addEventListener("test", null, opts); + } + catch (e) { + // NOP + } + } + + return BOOMR.browser.results.supportsPassive; + } + }, + + /** + * Initializes Boomerang by applying the specified configuration. + * + * All plugins' `init()` functions will be called with the same config as well. + * + * @param {object} config Configuration object + * @param {boolean} [config.autorun] By default, boomerang runs automatically + * and attaches its `page_ready` handler to the `window.onload` event. + * If you set `autorun` to `false`, this will not happen and you will + * need to call {@link BOOMR.page_ready} yourself. + * @param {string} config.beacon_auth_key Beacon authorization key value + * @param {string} config.beacon_auth_token Beacon authorization token. + * @param {boolean} config.beacon_with_credentials Sends beacon with credentials + * @param {boolean} config.beacon_disable_sendbeacon Disables `navigator.sendBeacon()` support + * @param {string} config.beacon_url The URL to beacon results back to. + * If not set, no beacon will be sent. + * @param {boolean} config.beacon_url_force_https Forces protocol-relative Beacon URLs to HTTPS + * @param {string} config.beacon_type `GET`, `POST` or `AUTO` + * @param {string} [config.site_domain] The domain that all cookies should be set on + * Boomerang will try to auto-detect this, but unless your site is of the + * `foo.com` format, it will probably get it wrong. It's a good idea + * to set this to whatever part of your domain you'd like to share + * bandwidth and performance measurements across. + * Set this to a falsy value to disable all cookies. + * @param {boolean} [config.strip_query_string] Whether or not to strip query strings from all URLs + * (e.g. `u`, `pgu`, etc.) + * @param {string} [config.user_ip] Despite its name, this is really a free-form + * string used to uniquely identify the user's current internet + * connection. It's used primarily by the bandwidth test to determine + * whether it should re-measure the user's bandwidth or just use the + * value stored in the cookie. You may use IPv4, IPv6 or anything else + * that you think can be used to identify the user's network connection. + * @param {string} [config.same_site_cookie] Used for creating cookies with `SameSite` with one + * of the following values: `None`, `Lax` or `Strict`. + * @param {boolean} [config.secure_cookie] When `true` all cookies will be created with `Secure` flag. + * @param {boolean} [config.request_client_hints] When `true`, gather high entropy values for Architecture, + * Model and Platform data from navigator.userAgentData. + * @param {boolean} [config.no_unload] Disables all unload handlers and the Unload beacons + * @param {function} [config.log] Logger to use. Set to `null` to disable logging. + * @param {function} [] Each plugin has its own section + * + * @returns {BOOMR} Boomerang object + * + * @memberof BOOMR + */ + init: function(config) { + var i, k, + properties = [ + "autorun", + "beacon_auth_key", + "beacon_auth_token", + "beacon_with_credentials", + "beacon_disable_sendbeacon", + "beacon_url", + "beacon_url_force_https", + "beacon_type", + "site_domain", + "strip_query_string", + "user_ip", + "same_site_cookie", + "secure_cookie", + "request_client_hints", + "no_unload" + ]; + + /* BEGIN_DEBUG */ + BOOMR.utils.mark("init:start"); + /* END_DEBUG */ + + BOOMR_check_doc_domain(); + + if (!config) { + config = {}; + } + + // ensure logging is setup properly (or null'd out for production) + if (config.log !== undefined) { + this.log = config.log; + } + + if (!this.log) { + this.log = function() {}; + } + + if (!this.pageId) { + // generate a random page ID for this page's lifetime + this.pageId = BOOMR.utils.generateId(8); + BOOMR.debug("Generated PageID: " + this.pageId); + } + + if (config.primary && impl.handlers_attached) { + return this; + } + + if (typeof config.site_domain !== "undefined") { + if (/:/.test(config.site_domain)) { + // domains with : are not valid, fall back to the current hostname + config.site_domain = w.location.hostname.toLowerCase(); + } + + this.session.domain = config.site_domain; + } + + if (BOOMR.session.enabled && typeof BOOMR.session.ID === "undefined") { + BOOMR.session.ID = BOOMR.utils.generateUUID(); + } + + // Set autorun if in config right now, as plugins that listen for page_ready + // event may fire when they .init() if onload has already fired, and whether + // or not we should fire page_ready depends on config.autorun. + if (typeof config.autorun !== "undefined") { + impl.autorun = config.autorun; + } + + /* BEGIN_DEBUG */ + BOOMR.utils.mark("init:plugins:start"); + /* END_DEBUG */ + + for (k in this.plugins) { + if (this.plugins.hasOwnProperty(k)) { + // config[plugin].enabled has been set to false + if (config[k] && + config[k].hasOwnProperty("enabled") && + config[k].enabled === false) { + impl.disabled_plugins[k] = 1; + + if (typeof this.plugins[k].disable === "function") { + this.plugins[k].disable(); + } + + continue; + } + + // plugin was previously disabled + if (impl.disabled_plugins[k]) { + // and has not been explicitly re-enabled + if (!config[k] || + !config[k].hasOwnProperty("enabled") || + config[k].enabled !== true) { + continue; + } + + if (typeof this.plugins[k].enable === "function") { + this.plugins[k].enable(); + } + + // plugin is now enabled + delete impl.disabled_plugins[k]; + } + + // plugin exists and has an init method + if (typeof this.plugins[k].init === "function") { + try { + /* BEGIN_DEBUG */ + BOOMR.utils.mark("init:plugins:" + k + ":start"); + /* END_DEBUG */ + + this.plugins[k].init(config); + + /* BEGIN_DEBUG */ + BOOMR.utils.mark("init:plugins:" + k + ":end"); + BOOMR.utils.measure( + "init:plugins:" + k, + "init:plugins:" + k + ":start", + "init:plugins:" + k + ":end"); + /* END_DEBUG */ + } + catch (err) { + BOOMR.addError(err, k + ".init"); + } + } + } + } + + /* BEGIN_DEBUG */ + BOOMR.utils.mark("init:plugins:end"); + BOOMR.utils.measure( + "init:plugins", + "init:plugins:start", + "init:plugins:end"); + /* END_DEBUG */ + + for (i = 0; i < properties.length; i++) { + if (config[properties[i]] !== undefined) { + impl[properties[i]] = config[properties[i]]; + } + } + + // if client hints are requested, then squirrel away user agent data of architecture, model, and platform + // version on impl for later use on the beacon + if (impl.request_client_hints === true && + w && w.navigator && w.navigator.userAgentData && + typeof w.navigator.userAgentData.getHighEntropyValues === "function") { + w.navigator.userAgentData.getHighEntropyValues(["architecture", "model", "platformVersion"]).then(function(ua) { + impl.userAgentData = ua; + }); + } + + // if it's the first call to init (handlers aren't attached) and we're not asked to wait OR + // it's the second init call (handlers are attached) and we were previously waiting + // then we set up the page ready autorun functionality + if ((!impl.handlers_attached && !config.wait) || (impl.handlers_attached && impl.waiting_for_config)) { + // The developer can override onload by setting autorun to false + if (!impl.onloadfired && (impl.autorun === undefined || impl.autorun !== false)) { + if (BOOMR.hasBrowserOnloadFired()) { + BOOMR.loadedLate = true; + } + + BOOMR.attach_page_ready(BOOMR.page_ready_autorun); + } + + impl.waiting_for_config = false; + } + + // only attach handlers once + if (impl.handlers_attached) { + /* BEGIN_DEBUG */ + BOOMR.utils.mark("init:end"); + BOOMR.utils.measure( + "init", + "init:start", + "init:end"); + /* END_DEBUG */ + + return this; + } + + if (config.wait) { + impl.waiting_for_config = true; + } + + // + // Page handlers to attach once + // + + // Listen for pageshow/load for the main Page Load + BOOMR.attach_page_ready(function() { + // if we're not using the loader snippet, save the onload time for + // browsers that do not support NavigationTiming. + // This will be later than onload if boomerang arrives late on the + // page but it's the best we can do + if (!BOOMR.t_onload) { + BOOMR.t_onload = BOOMR.now(); + } + }); + + // Listen for DOMContentLoaded to fire the internal dom_loaded event + BOOMR.utils.addListener(w, "DOMContentLoaded", function() { + impl.fireEvent("dom_loaded"); + }); + + // Fire and Listen for config events to refresh config for us and plugins + BOOMR.fireEvent("config", config); + BOOMR.subscribe("config", function(beaconConfig) { + if (beaconConfig.beacon_url) { + impl.beacon_url = beaconConfig.beacon_url; + } + }); + + // Listen for SPA navigations + BOOMR.subscribe("spa_navigation", impl.spaNavigation, null, impl); + + // Listen for Visibility Change notifications + (function() { + var forms, iterator; + + if (visibilityChange !== undefined) { + BOOMR.utils.addListener(d, visibilityChange, function() { + impl.fireEvent("visibility_changed"); + }); + + // save the current visibility state + impl.lastVisibilityState = BOOMR.visibilityState(); + + BOOMR.subscribe("visibility_changed", function() { + var visState = BOOMR.visibilityState(); + + // record the last time each visibility state occurred + BOOMR.lastVisibilityEvent[visState] = BOOMR.now(); + BOOMR.debug("Visibility changed from " + impl.lastVisibilityState + " to " + visState); + + // if we transitioned from prerender to hidden or visible, fire the prerender_to_visible event + if (impl.lastVisibilityState === "prerender" && + visState !== "prerender") { + // note that we transitioned from prerender on the beacon for debugging + BOOMR.addVar("vis.pre", "1"); + + // let all listeners know + impl.fireEvent("prerender_to_visible"); + } + + impl.lastVisibilityState = visState; + }); + } + + // Listen for mouseup events + BOOMR.utils.addListener(d, "mouseup", impl.createCallbackHandler("click")); + + // Listen for FORM submissions + forms = d.getElementsByTagName("form"); + for (iterator = 0; iterator < forms.length; iterator++) { + BOOMR.utils.addListener(forms[iterator], "submit", impl.createCallbackHandler("form_submit")); + } + + // Listen for pagehide + if (!w.onpagehide && w.onpagehide !== null) { + // This must be the last one to fire + // We only clear w on browsers that don't support onpagehide because + // those that do are new enough to not have memory leak problems of + // some older browsers + BOOMR.utils.addListener(w, "unload", function() { + BOOMR.window = w = null; + }); + } + + // if we were loaded in an IFRAME, try to keep track if the IFRAME was unloaded + if (BOOMR.boomerang_frame !== BOOMR.window) { + BOOMR.utils.addListener(BOOMR.boomerang_frame, "unload", impl.onFrameUnloaded); + } + }()); + + impl.handlers_attached = true; + + /* BEGIN_DEBUG */ + BOOMR.utils.mark("init:end"); + BOOMR.utils.measure( + "init", + "init:start", + "init:end"); + /* END_DEBUG */ + + return this; + }, + + /** + * Attach a callback to the `pageshow` or `onload` event if `onload` has not + * been fired otherwise queue it to run immediately + * + * @param {function} cb Callback to run when `onload` fires or page is visible (`pageshow`) + * + * @memberof BOOMR + */ + attach_page_ready: function(cb) { + if (BOOMR.hasBrowserOnloadFired()) { + this.setImmediate(cb, null, null, BOOMR); + } + else { + // Use `pageshow` if available since it will fire even if page came from a back-forward page cache. + // Browsers that support `pageshow` will not fire `onload` if navigation was through a back/forward button + // and the page was retrieved from back-forward cache. + if (w.onpagehide || w.onpagehide === null) { + BOOMR.utils.addListener(w, "pageshow", cb); + } + else { + BOOMR.utils.addListener(w, "load", cb); + } + } + }, + + /** + * Sends the `page_ready` event only if `autorun` is still true after + * {@link BOOMR.init} is called. + * + * @param {Event} ev Event + * + * @memberof BOOMR + */ + page_ready_autorun: function(ev) { + if (impl.autorun) { + BOOMR.page_ready(ev, true); + } + }, + + /** + * Method that fires the {@link BOOMR#event:page_ready} event. Call this + * only if you've set `autorun` to `false` when calling the {@link BOOMR.init} + * method. You should call this method when you determine that your page + * is ready to be used by your user. This will be the end-time used in + * the page load time measurement. Optionally, you can pass a Unix Epoch + * timestamp as a parameter or set the global `BOOMR_page_ready` var that will + * be used as the end-time instead. + * + * @param {Event|number} [ev] Ready event or optional load event end timestamp if called manually + * @param {boolean} auto True if called by `page_ready_autorun` + * + * @returns {BOOMR} Boomerang object + * + * @example + * BOOMR.init({ autorun: false, ... }); + * // wait until the page is ready, i.e. your view has loaded + * BOOMR.page_ready(); + * + * @memberof BOOMR + */ + page_ready: function(ev, auto) { + var tm_page_ready; + + // a number can be passed as the first argument if called manually which + // will be used as the loadEventEnd time + if (!auto && typeof ev === "number") { + tm_page_ready = ev; + ev = null; + } + + if (!ev) { + ev = w.event; + } + + if (!ev) { + ev = { + name: "load" + }; + } + + // if we were called manually or global BOOMR_page_ready was set then + // add loadEventEnd and note this was 'pr' on the beacon + if (!auto) { + ev.timing = ev.timing || {}; + + // use timestamp parameter or global BOOMR_page_ready if set, otherwise use + // the current timestamp + if (tm_page_ready) { + ev.timing.loadEventEnd = tm_page_ready; + } + else if (typeof w.BOOMR_page_ready === "number") { + ev.timing.loadEventEnd = w.BOOMR_page_ready; + } + else { + ev.timing.loadEventEnd = BOOMR.now(); + } + + BOOMR.addVar("pr", 1, true); + } + else if (typeof w.BOOMR_page_ready === "number") { + ev.timing = ev.timing || {}; + // the global BOOMR_page_ready will override our loadEventEnd + ev.timing.loadEventEnd = w.BOOMR_page_ready; + + BOOMR.addVar("pr", 1, true); + } + + if (impl.onloadfired) { + return this; + } + + // if we're prerendering, wait until complete before firing + if (d.prerendering) { + BOOMR.utils.addListener(d, "prerenderingchange", function() { + // wait 500ms for other events (like LCP) to fire before we send the beacon + setTimeout(function() { + impl.fireEvent("page_ready", ev); + }, 500); + + impl.onloadfired = true; + }); + } + else { + // not prerendering + impl.fireEvent("page_ready", ev); + impl.onloadfired = true; + } + + return this; + }, + + /** + * Determines whether or not the page's `onload` event has fired + * + * @returns {boolean} True if page's onload was called + */ + hasBrowserOnloadFired: function() { + var p = BOOMR.getPerformance(); + // if the document is `complete` then the `onload` event has already occurred, we'll fire the callback + // immediately. + // + // When `document.write` is used to replace the contents of the page and inject boomerang, the document + // `readyState` will go from `complete` back to `loading` and then to `complete` again. The second transition + // to `complete` doesn't fire a second `pageshow` event in some browsers (e.g. Safari). We need to check if + // `performance.timing.loadEventStart` or `BOOMR_onload` has occurred to detect this scenario. Will not work for + // older Safari that doesn't have NavTiming + + return ((d.readyState && d.readyState === "complete") || + (p && p.timing && p.timing.loadEventStart > 0) || + w.BOOMR_onload > 0); + }, + + /** + * Determines whether or not the page's `onload` event has fired, or + * if `autorun` is false, whether {@link BOOMR.page_ready} was called. + * + * @returns {boolean} True if `onload` or {@link BOOMR.page_ready} were called + * + * @memberof BOOMR + */ + onloadFired: function() { + return impl.onloadfired; + }, + + /** + * The callback function may return a falsy value to disconnect the observer + * after it returns, or a truthy value to keep watching for mutations. If + * the return value is numeric and greater than 0, then this will be the new timeout. + * If it is boolean instead, then the timeout will not fire any more so + * the caller MUST call disconnect() at some point + * + * @callback BOOMR~setImmediateCallback + * @param {object} data The passed in `data` object + * @param {object} cb_data The passed in `cb_data` object + * @param {Error} callstack An Error object that holds the callstack for + * when `setImmediate` was called, used to determine what called the callback + */ + + /** + * Defer the function `fn` until the next instant the browser is free from + * user tasks. + * + * @param {BOOMR~setImmediateCallback} fn The callback function. + * @param {object} [data] Any data to pass to the callback function + * @param {object} [cb_data] Any passthrough data for the callback function. + * This differs from `data` when `setImmediate` is called via an event + * handler and `data` is the Event object + * @param {object} [cb_scope] The scope of the callback function if it is a method of an object + * + * @returns nothing + * + * @memberof BOOMR + */ + setImmediate: function(fn, data, cb_data, cb_scope) { + var cb, cstack; + + /* BEGIN_DEBUG */ + // DEBUG: This is to help debugging, we'll see where setImmediate calls were made from + if (typeof Error !== "undefined") { + cstack = new Error(); + cstack = cstack.stack ? cstack.stack.replace(/^Error/, "Called") : undefined; + } + /* END_DEBUG */ + + cb = function() { + fn.call(cb_scope || null, data, cb_data || {}, cstack); + cb = null; + }; + + if (w.requestIdleCallback) { + // set a timeout since rIC doesn't get called reliably in chrome headless + w.requestIdleCallback(cb, {timeout: 1000}); + } + else if (w.setImmediate) { + w.setImmediate(cb); + } + else { + setTimeout(cb, 10); + } + }, + + /** + * Gets the current time in milliseconds since the Unix Epoch (Jan 1 1970). + * + * In browsers that support `DOMHighResTimeStamp`, this will be replaced + * by a function that adds `performance.now()` to `navigationStart` + * (with milliseconds.microseconds resolution). + * + * @function + * + * @returns {TimeStamp} Milliseconds since Unix Epoch + * + * @memberof BOOMR + */ + now: (function() { + return Date.now || function() { + return new Date().getTime(); + }; + }()), + + /** + * Gets the `window.performance` object of the root window. + * + * Checks vendor prefixes for older browsers (e.g. IE9). + * + * @returns {Performance|undefined} `window.performance` if it exists + * + * @memberof BOOMR + */ + getPerformance: function() { + try { + if (BOOMR.window) { + if ("performance" in BOOMR.window && BOOMR.window.performance) { + return BOOMR.window.performance; + } + + // vendor-prefixed fallbacks + return BOOMR.window.msPerformance || + BOOMR.window.webkitPerformance || + BOOMR.window.mozPerformance; + } + } + catch (ignore) { + // empty + } + }, + + /** + * Allows us to force SameSite=None from a Boomerang plugin or a third party code. + * + * When this function is called then Boomerang won't honor "same_site_cookie" + * configuration key and won't attempt to return the default value of SameSite=Lax . + * + * @memberof BOOMR + */ + forceSameSiteCookieNone: function() { + impl.forced_same_site_cookie_none = true; + }, + + /** + * Get high resolution delta timestamp from time origin + * + * This function needs to approximate the time since the performance timeOrigin + * or Navigation Timing API's `navigationStart` time. + * If available, `performance.now()` can provide this value. + * If not we either get the navigation start time from the RT plugin or + * from `t_lstart` or `t_start`. Those values are subtracted from the current + * time to derive a time since `navigationStart` value. + * + * @returns {float} Exact or approximate time since the time origin. + */ + hrNow: function() { + var now, navigationStart, + p = BOOMR.getPerformance(); + + if (p && p.now) { + now = p.now(); + } + else { + navigationStart = (BOOMR.plugins.RT && BOOMR.plugins.RT.navigationStart && + BOOMR.plugins.RT.navigationStart()) || BOOMR.t_lstart || BOOMR.t_start; + + // if navigationStart is undefined, we'll be returning NaN + now = BOOMR.now() - navigationStart; + } + + return now; + }, + + /** + * Gets the `document.visibilityState`, or `visible` if Page Visibility + * is not supported. + * + * @function + * + * @returns {string} Visibility state + * + * @memberof BOOMR + */ + visibilityState: (visibilityState === undefined ? function() { + return "visible"; + } : function() { + return d[visibilityState]; + }), + + /** + * An mapping of visibliity event states to the latest time they happened + * + * @type {object} + * + * @memberof BOOMR + */ + lastVisibilityEvent: {}, + + /** + * Registers a Boomerang event. + * + * @param {string} e_name Event name + * + * @returns {BOOMR} Boomerang object + * + * @memberof BOOMR + */ + registerEvent: function(e_name) { + if (impl.events.hasOwnProperty(e_name)) { + // already registered + return this; + } + + // create a new queue of handlers + impl.events[e_name] = []; + + return this; + }, + + /** + * Disables boomerang from doing anything further: + * 1. Clears event handlers (such as onload) + * 2. Clears all event listeners + * + * @memberof BOOMR + */ + disable: function() { + impl.clearEvents(); + impl.clearListeners(); + }, + + /** + * Fires a Boomerang event + * + * @param {string} e_name Event name + * @param {object} data Event payload + * + * @returns {BOOMR} Boomerang object + * + * @memberof BOOMR + */ + fireEvent: function(e_name, data) { + return impl.fireEvent(e_name, data); + }, + + /** + * @callback BOOMR~subscribeCallback + * @param {object} eventData Event data + * @param {object} cb_data Callback data + */ + + /** + * Subscribes to a Boomerang event + * + * @param {string} e_name Event name, i.e. {@link BOOMR#event:page_ready}. + * @param {BOOMR~subscribeCallback} fn Callback function + * @param {object} cb_data Callback data, passed as the second parameter to the callback function + * @param {object} cb_scope Callback scope. If set to an object, then the + * callback function is called as a method of this object, and all + * references to `this` within the callback function will refer to `cb_scope`. + * @param {boolean} once Whether or not this callback should only be run once + * + * @returns {BOOMR} Boomerang object + * + * @memberof BOOMR + */ + subscribe: function(e_name, fn, cb_data, cb_scope, once) { + var i, handler, ev; + + e_name = e_name.toLowerCase(); + + /* BEGIN_DEBUG */ + BOOMR.utils.mark("subscribe"); + BOOMR.utils.mark("subscribe:" + e_name); + /* END_DEBUG */ + + // translate old names + if (impl.translate_events[e_name]) { + e_name = impl.translate_events[e_name]; + } + + if (!impl.events.hasOwnProperty(e_name)) { + // allow subscriptions before they're registered + impl.events[e_name] = []; + } + + ev = impl.events[e_name]; + + // don't allow a handler to be attached more than once to the same event + for (i = 0; i < ev.length; i++) { + handler = ev[i]; + + if (handler && handler.fn === fn && handler.cb_data === cb_data && handler.scope === cb_scope) { + return this; + } + } + + ev.push({ + fn: fn, + cb_data: cb_data || {}, + scope: cb_scope || null, + once: once || false + }); + + // attaching to page_ready after onload fires, so call soon + if (e_name === "page_ready" && impl.onloadfired && impl.autorun) { + this.setImmediate(fn, null, cb_data, cb_scope); + } + + // Note: If no_unload is set, don't listen to any unload-style events. + if (!impl.no_unload && (e_name === "page_unload" || e_name === "before_unload")) { + // Keep track of how many pagehide/unload/beforeunload handlers we're registering + impl.unloadEventsCount++; + + (function() { + var unloadHandler = function boomerangUnloadHandler(evt) { + if (impl.no_unload) { + // may have been set after the initial load + return; + } + + if (fn) { + fn.call(cb_scope, evt || w.event, cb_data); + } + + // clear so this doesn't run twice + fn = null; + + // If this was the last pagehide/unload/beforeunload handler, + // we'll try to send the beacon immediately after it is done. + // The beacon will only be sent if one of the handlers has queued it. + if (++impl.unloadEventCalled === impl.unloadEventsCount) { + BOOMR.real_sendBeacon(); + } + }; + + // + // For modern browsers that support pagehide, listen to that event, + // and do not listen to beforeunload/unload as they can break BFCache navigations. + // + if (w.onpagehide || w.onpagehide === null) { + BOOMR.utils.addListener(w, "pagehide", unloadHandler); + } + else { + // + // For before_unload event in older browsers, attach handlers directly + // to the unload and beforeunload events. Not all older browsers support + // beforeunload. The first of the two to fire will clear so that the + // second doesn't fire. + // + if (e_name === "page_unload") { + BOOMR.utils.addListener(w, "unload", unloadHandler); + } + + BOOMR.utils.addListener(w, "beforeunload", unloadHandler); + } + }()); + } + + return this; + }, + + /** + * Logs an internal Boomerang error. + * + * If the {@link BOOMR.plugins.Errors} plugin is enabled, this data will + * be compressed on the `err` beacon parameter. If not, it will be included + * in uncompressed form on the `errors` parameter. + * + * @param {string|object} err Error + * @param {string} [src] Source + * @param {object} [extra] Extra data + * + * @memberof BOOMR + */ + addError: function BOOMR_addError(err, src, extra) { + var str, + E = BOOMR.plugins.Errors; + + /* BEGIN_DEBUG */ + BOOMR.utils.mark("add_error"); + /* END_DEBUG */ + + BOOMR.error("Boomerang caught error: " + err + ", src: " + src + ", extra: " + extra); + + // + // Use the Errors plugin if it's enabled + // + if (E && E.is_supported()) { + if (typeof err === "string") { + E.send({ + message: err, + extra: extra, + functionName: src, + noStack: true + }, E.VIA_APP, E.SOURCE_BOOMERANG); + } + else { + if (typeof src === "string") { + err.functionName = src; + } + + if (typeof extra !== "undefined") { + err.extra = extra; + } + + E.send(err, E.VIA_APP, E.SOURCE_BOOMERANG); + } + + return; + } + + if (typeof err !== "string") { + str = String(err); + + if (str.match(/^\[object/)) { + str = err.name + ": " + (err.description || err.message).replace(/\r\n$/, ""); + } + + err = str; + } + + if (src !== undefined) { + err = "[" + src + ":" + BOOMR.now() + "] " + err; + } + + if (extra) { + err += ":: " + extra; + } + + if (impl.errors[err]) { + impl.errors[err]++; + } + else { + impl.errors[err] = 1; + } + }, + + /** + * Determines if the specified Error is a Cross-Origin error. + * + * @param {string|object} err Error + * + * @returns {boolean} True if the Error is a Cross-Origin error. + * + * @memberof BOOMR + */ + isCrossOriginError: function(err) { + // These are expected for cross-origin iframe access. + // For IE and Edge, we'll also check the error number for non-English browsers + return err.name === "SecurityError" || + (err.name === "TypeError" && err.message === "Permission denied") || + (err.name === "Error" && err.message && err.message.match(/^(Permission|Access is) denied/)) || + // IE/Edge error number for "Permission Denied" + err.number === -2146828218; + }, + + /** + * Add one or more parameters to the beacon. + * + * This method may either be called with a single object containing + * key/value pairs, or with two parameters, the first is the variable + * name and the second is its value. + * + * All names should be strings usable in a URL's query string. + * + * We recommend only using alphanumeric characters and underscores, but you + * can use anything you like. + * + * Values should be strings (or numbers), and have the same restrictions + * as names. + * + * Parameters will be on all subsequent beacons unless `singleBeacon` is + * set. Early beacons will not clear vars that were set with `singleBeacon`. + * + * @param {string|object} name Variable name + * @param {string|object} [val] Value. If the first parameter is an object, this + * becomes the singleBeacon parameter. + * @param {boolean} [singleBeacon=false] Whether or not to add to a single beacon + * or all beacons. + * + * @returns {BOOMR} Boomerang object + * + * @example + * BOOMR.addVar("page_id", 123); + * BOOMR.addVar({"page_id": 123, "user_id": "Person1"}); + * + * @memberof BOOMR + */ + addVar: function(name, value, singleBeacon) { + /* BEGIN_DEBUG */ + BOOMR.utils.mark("add_var"); + /* END_DEBUG */ + + if (typeof name === "string") { + impl.vars[name] = value; + + if (singleBeacon) { + impl.singleBeaconVars[name] = 1; + } + } + else if (typeof name === "object") { + var o = name, + k; + + for (k in o) { + if (o.hasOwnProperty(k)) { + impl.vars[k] = o[k]; + + // For object-set, the second parameter (or third) can be + // true to force singleBeacon. If so, remove this + // after the first beacon + if (value || singleBeacon) { + impl.singleBeaconVars[k] = 1; + } + } + } + } + + return this; + }, + + /** + * Appends data to a beacon. + * + * If the value already exists, a comma is added and the new data is applied. + * + * @param {string} name Variable name + * @param {string} val Value + * + * @returns {BOOMR} Boomerang object + * + * @memberof BOOMR + */ + appendVar: function(name, value) { + var existing = BOOMR.getVar(name) || ""; + + if (existing) { + existing += ","; + } + + BOOMR.addVar(name, existing + value); + + return this; + }, + + /** + * Removes one or more variables from the beacon URL. This is useful within + * a plugin to reset the values of parameters that it is about to set. + * + * Plugins can also use this in the {@link BOOMR#event:beacon} event to clear + * any variables that should only live on a single beacon. + * + * This method accepts either a list of variable names, or a single + * array containing a list of variable names. + * + * @param {string[]|string} name Variable name or list + * + * @returns {BOOMR} Boomerang object + * + * @memberof BOOMR + */ + removeVar: function(arg0) { + var i, params; + + if (!arguments.length) { + return this; + } + + if (arguments.length === 1 && BOOMR.utils.isArray(arg0)) { + params = arg0; + } + else { + params = arguments; + } + + for (i = 0; i < params.length; i++) { + if (impl.vars.hasOwnProperty(params[i])) { + delete impl.vars[params[i]]; + } + } + + return this; + }, + + /** + * Determines whether or not the beacon has the specified variable. + * + * @param {string} name Variable name + * + * @returns {boolean} True if the variable is set. + * + * @memberof BOOMR + */ + hasVar: function(name) { + return impl.vars.hasOwnProperty(name); + }, + + /** + * Gets the specified variable. + * + * @param {string} name Variable name + * + * @returns {object|undefined} Variable, or undefined if it isn't set + * + * @memberof BOOMR + */ + getVar: function(name) { + return impl.vars[name]; + }, + + /** + * Sets a variable's priority in the beacon URL. + * -1 = beginning of the URL + * 0 = middle of the URL (default) + * 1 = end of the URL + * + * @param {string} name Variable name + * @param {number} pri Priority (-1 or 1) + * + * @returns {BOOMR} Boomerang object + * + * @memberof BOOMR + */ + setVarPriority: function(name, pri) { + if (typeof pri !== "number" || Math.abs(pri) !== 1) { + return this; + } + + impl.varPriority[pri][name] = 1; + + return this; + }, + + /** + * Sets the Referrers variable. + * + * @param {string} r Referrer from the document.referrer + * + * @memberof BOOMR + */ + setReferrer: function(r) { + // document.referrer + impl.r = r; + }, + + /** + * Starts a timer for a dynamic request. + * + * Once the named request has completed, call `loaded()` to send a beacon + * with the duration. + * + * @example + * var timer = BOOMR.requestStart("my-timer"); + * // do stuff + * timer.loaded(); + * + * @param {string} name Timer name + * + * @returns {object} An object with a `.loaded()` function that you can call + * when the dynamic timer is complete. + * + * @memberof BOOMR + */ + requestStart: function(name) { + var t_start = BOOMR.now(); + + BOOMR.plugins.RT.startTimer("xhr_" + name, t_start); + + return { + loaded: function(data) { + BOOMR.responseEnd(name, t_start, data); + } + }; + }, + + /** + * Determines if Boomerang can send a beacon. + * + * Queryies all plugins to see if they implement `readyToSend()`, + * and if so, that they return `true`. + * + * If not, the beacon cannot be sent. + * + * @returns {boolean} True if Boomerang can send a beacon + * + * @memberof BOOMR + */ + readyToSend: function() { + var plugin; + + for (plugin in this.plugins) { + if (this.plugins.hasOwnProperty(plugin)) { + if (impl.disabled_plugins[plugin]) { + continue; + } + + if (typeof this.plugins[plugin].readyToSend === "function" && + this.plugins[plugin].readyToSend() === false) { + BOOMR.debug("Plugin " + plugin + " is not ready to send"); + + return false; + } + } + } + + return true; + }, + + /** + * Sends a beacon for a dynamic request. + * + * @param {string|object} name Timer name or timer object data. + * @param {string} [name.initiator] Initiator, such as `xhr` or `spa` + * @param {string} [name.url] URL of the request + * @param {TimeStamp} t_start Start time + * @param {object} data Request data + * @param {TimeStamp} t_end End time + * + * @memberof BOOMR + */ + responseEnd: function(name, t_start, data, t_end) { + // take the now timestamp for start and end, if unspecified, in case we delay this beacon + t_start = typeof t_start === "number" ? t_start : BOOMR.now(); + t_end = typeof t_end === "number" ? t_end : BOOMR.now(); + + // wait until all plugins are ready to send + if (!BOOMR.readyToSend()) { + BOOMR.debug("Attempted to call responseEnd before all plugins were Ready to Send, trying again..."); + + // try again later + setTimeout(function() { + BOOMR.responseEnd(name, t_start, data, t_end); + }, 1000); + + return; + } + + // Wait until we've sent the Page Load beacon first + if (!BOOMR.hasSentPageLoadBeacon() && + !BOOMR.utils.inArray(name.initiator, BOOMR.constants.BEACON_TYPE_SPAS)) { + // wait for a beacon, then try again + BOOMR.subscribe("page_load_beacon", function() { + BOOMR.responseEnd(name, t_start, data, t_end); + }, null, BOOMR, true); + + return; + } + + // Ensure we don't have two beacons trying to send data at the same time + if (impl.beaconInQueue) { + // wait until the beacon is sent, then try again + BOOMR.subscribe("beacon", function() { + BOOMR.responseEnd(name, t_start, data, t_end); + }, null, BOOMR, true); + + return; + } + + // Lock the beacon queue + impl.beaconInQueue = true; + + if (typeof name === "object") { + if (!name.url) { + BOOMR.debug("BOOMR.responseEnd: First argument must have a url property if it's an object"); + + return; + } + + impl.fireEvent("xhr_load", name); + } + else { + // flush out any queue'd beacons before we set the Page Group + // and timers + BOOMR.real_sendBeacon(); + + BOOMR.addVar("xhr.pg", name, true); + + BOOMR.plugins.RT.startTimer("xhr_" + name, t_start); + + impl.fireEvent("xhr_load", { + name: "xhr_" + name, + data: data, + timing: { + loadEventEnd: t_end + } + }); + } + }, + + // + // uninstrumentXHR, instrumentXHR, uninstrumentFetch and instrumentFetch + // are stubs that will be replaced by auto-xhr.js if active. + // + /** + * Undo XMLHttpRequest instrumentation and reset the original `XMLHttpRequest` + * object + * + * This is implemented in `plugins/auto-xhr.js` {@link BOOMR.plugins.AutoXHR}. + * + * @memberof BOOMR + */ + uninstrumentXHR: function() { }, + + /** + * Instrument all requests made via XMLHttpRequest to send beacons. + * + * This is implemented in `plugins/auto-xhr.js` {@link BOOMR.plugins.AutoXHR}. + * + * @memberof BOOMR + */ + instrumentXHR: function() { }, + + /** + * Undo fetch instrumentation and reset the original `fetch` + * function + * + * This is implemented in `plugins/auto-xhr.js` {@link BOOMR.plugins.AutoXHR}. + * + * @memberof BOOMR + */ + uninstrumentFetch: function() { }, + + /** + * Instrument all requests made via fetch to send beacons. + * + * This is implemented in `plugins/auto-xhr.js` {@link BOOMR.plugins.AutoXHR}. + * + * @memberof BOOMR + */ + instrumentFetch: function() { }, + + /** + * Request boomerang to send its beacon with all queued beacon data + * (via {@link BOOMR.addVar}). + * + * Boomerang may ignore this request. + * + * When this method is called, boomerang checks all plugins. If any + * plugin has not completed its checks (ie, the plugin's `is_complete()` + * method returns `false`, then this method does nothing. + * + * If all plugins have completed, then this method fires the + * {@link BOOMR#event:before_beacon} event with all variables that will be + * sent on the beacon. + * + * After all {@link BOOMR#event:before_beacon} handlers return, this method + * checks if a `beacon_url` has been configured and if there are any + * beacon parameters to be sent. If both are true, it fires the beacon. + * + * The {@link BOOMR#event:beacon} event is then fired. + * + * `sendBeacon()` should be called any time a plugin goes from + * `is_complete() = false` to `is_complete = true` so the beacon is + * sent. + * + * The actual beaconing is handled in {@link BOOMR.real_sendBeacon} after + * a short delay (via {@link BOOMR.setImmediate}). If other calls to + * `sendBeacon` happen before {@link BOOMR.real_sendBeacon} is called, + * those calls will be discarded (so it's OK to call this in quick + * succession). + * + * @param {string} [beacon_url_override] Beacon URL override + * + * @memberof BOOMR + */ + sendBeacon: function(beacon_url_override) { + // This plugin wants the beacon to go somewhere else, + // so update the location + if (beacon_url_override) { + impl.beacon_url_override = beacon_url_override; + } + + if (!impl.beaconQueued) { + impl.beaconQueued = true; + BOOMR.setImmediate(BOOMR.real_sendBeacon, null, null, BOOMR); + } + + return true; + }, + + /** + * Sends a beacon when the beacon queue is empty. + * + * @param {object} params Beacon parameters to set + * @param {function} callback Callback to run when the queue is ready + * @param {object} that Function to apply callback to + */ + sendBeaconWhenReady: function(params, callback, that) { + // If we're already sending a beacon, wait until the queue is empty + if (impl.beaconInQueue) { + // wait until the beacon is sent, then try again + BOOMR.subscribe("beacon", function() { + BOOMR.sendBeaconWhenReady(params, callback, that); + }, null, BOOMR, true); + + return; + } + + // Lock the beacon queue + impl.beaconInQueue = true; + + // add all parameters + for (var paramName in params) { + if (params.hasOwnProperty(paramName)) { + // add this data to a single beacon + BOOMR.addVar(paramName, params[paramName], true); + } + } + + // run the callback + if (typeof callback === "function" && typeof that !== "undefined") { + callback.apply(that); + } + + // send the beacon + BOOMR.sendBeacon(); + }, + + /** + * Sends all beacon data. + * + * This function should be called directly any time a "new" beacon is about + * to be constructed. For example, if you're creating a new XHR or other + * custom beacon, you should ensure the existing beacon data is flushed + * by calling `BOOMR.real_sendBeacon();` first. + * + * @memberof BOOMR + */ + real_sendBeacon: function() { + var k, form, url, + errors = [], + params = [], + paramsJoined, + varsSent = {}; + + if (!impl.beaconQueued) { + return false; + } + + /* BEGIN_DEBUG */ + BOOMR.utils.mark("send_beacon:start"); + /* END_DEBUG */ + + impl.beaconQueued = false; + + BOOMR.debug("Checking if we can send beacon"); + + // At this point someone is ready to send the beacon. We send + // the beacon only if all plugins have finished doing what they + // wanted to do + for (k in this.plugins) { + if (this.plugins.hasOwnProperty(k)) { + if (impl.disabled_plugins[k]) { + continue; + } + + if (!this.plugins[k].is_complete(impl.vars)) { + BOOMR.debug("Plugin " + k + " is not complete, deferring beacon send"); + // if an Early beacon is blocked, then we'll cancel it. + // By removing the `early` param, the beacon params will be merged + // with the following load beacon. + delete impl.vars.early; + + return false; + } + } + } + + // Sanity test that the browser is still available (and not shutting down) + if (!window || !window.Image || !window.navigator || !BOOMR.window) { + BOOMR.debug("DOM not fully available, not sending a beacon"); + + return false; + } + + // For SPA apps, don't strip hashtags as some SPA frameworks use #s for tracking routes + // instead of History pushState() APIs. Use d.URL instead of location.href because of a + // Safari bug. + var isSPA = BOOMR.utils.inArray(impl.vars["http.initiator"], BOOMR.constants.BEACON_TYPE_SPAS); + var isPageLoad = typeof impl.vars["http.initiator"] === "undefined" || isSPA; + + if (!impl.vars.pgu) { + impl.vars.pgu = isSPA ? d.URL : d.URL.replace(/#.*/, ""); + } + + impl.vars.pgu = BOOMR.utils.cleanupURL(impl.vars.pgu); + + // Use the current document.URL if it hasn't already been set, or for SPA apps, + // on each new beacon (since each SPA soft navigation might change the URL) + if (!impl.vars.u || isSPA) { + impl.vars.u = impl.vars.pgu; + } + + if (impl.vars.pgu === impl.vars.u) { + delete impl.vars.pgu; + } + + // Add cleaned-up referrer URLs to the beacon, if available + if (impl.r) { + impl.vars.r = BOOMR.utils.cleanupURL(impl.r); + } + else { + delete impl.vars.r; + } + + impl.vars.v = BOOMR.version; + + // Snippet version, if available + if (BOOMR.snippetVersion) { + impl.vars.sv = BOOMR.snippetVersion; + } + + // Snippet method is IFRAME if not specified (pre-v12 snippets) + impl.vars.sm = BOOMR.snippetMethod || "i"; + + if (BOOMR.session.enabled) { + impl.vars["rt.si"] = BOOMR.session.ID + "-" + Math.round(BOOMR.session.start / 1000).toString(36); + impl.vars["rt.ss"] = BOOMR.session.start; + + if (typeof impl.vars.early === "undefined") { + // make sure Session Length is always at least 1 for non-Early beacons + impl.vars["rt.sl"] = BOOMR.session.length >= 1 ? BOOMR.session.length : 1; + } + else { + impl.vars["rt.sl"] = BOOMR.session.length; + } + } + else { + BOOMR.removeVar("rt.si", "rt.ss", "rt.sl"); + } + + if (BOOMR.visibilityState()) { + impl.vars["vis.st"] = BOOMR.visibilityState(); + + if (BOOMR.lastVisibilityEvent.visible) { + impl.vars["vis.lv"] = BOOMR.now() - BOOMR.lastVisibilityEvent.visible; + } + + if (BOOMR.lastVisibilityEvent.hidden) { + impl.vars["vis.lh"] = BOOMR.now() - BOOMR.lastVisibilityEvent.hidden; + } + } + + var platform = ""; + + if (navigator.userAgentData && typeof navigator.userAgentData.platform === "string") { + platform = navigator.userAgentData.platform; + } + else { + platform = navigator.platform; + } + + impl.vars["ua.plt"] = platform; + impl.vars["ua.vnd"] = navigator.vendor; + + // if userAgentData exists, then store on the beacon + if (impl.userAgentData) { + impl.vars["ua.arch"] = impl.userAgentData.architecture; + impl.vars["ua.model"] = impl.userAgentData.model; + impl.vars["ua.pltv"] = impl.userAgentData.platformVersion; + } + + if (this.pageId) { + impl.vars.pid = this.pageId; + } + + // add beacon number + impl.vars.n = ++this.beaconsSent; + + if (w !== window) { + impl.vars["if"] = ""; + } + + for (k in impl.errors) { + if (impl.errors.hasOwnProperty(k)) { + errors.push(k + (impl.errors[k] > 1 ? " (*" + impl.errors[k] + ")" : "")); + } + } + + if (errors.length > 0) { + impl.vars.errors = errors.join("\n"); + } + + impl.errors = {}; + + // If we reach here, all plugins have completed + impl.fireEvent("before_beacon", impl.vars); + + // clone the vars object for two reasons: first, so all listeners of + // 'beacon' get an exact clone (in case listeners are doing + // BOOMR.removeVar), and second, to help build our priority list of vars. + for (k in impl.vars) { + if (impl.vars.hasOwnProperty(k)) { + varsSent[k] = impl.vars[k]; + } + } + + BOOMR.removeVar(["qt", "pgu"]); + + if (typeof impl.vars.early === "undefined") { + // remove any vars that should only be on a single beacon. + // Early beacons don't clear vars even if flagged as `singleBeacon` so + // that they can be re-sent on the next normal beacon + for (var singleVarName in impl.singleBeaconVars) { + if (impl.singleBeaconVars.hasOwnProperty(singleVarName)) { + BOOMR.removeVar(singleVarName); + } + } + + // clear single beacon vars list + impl.singleBeaconVars = {}; + + // keep track of page load beacons + if (!impl.hasSentPageLoadBeacon && isPageLoad) { + impl.hasSentPageLoadBeacon = true; + + // let this beacon go out first + BOOMR.setImmediate(function() { + impl.fireEvent("page_load_beacon", varsSent); + }); + } + } + + // Stop at this point if we are rate limited + if (BOOMR.session.rate_limited) { + BOOMR.debug("Skipping because we're rate limited"); + + return false; + } + + // mark that we're no longer sending a beacon now, as those + // paying attention to this will trigger at the beacon event + impl.beaconInQueue = false; + + // send the beacon data + BOOMR.sendBeaconData(varsSent); + + /* BEGIN_DEBUG */ + BOOMR.utils.mark("send_beacon:end"); + BOOMR.utils.measure( + "send_beacon", + "send_beacon:start", + "send_beacon:end"); + /* END_DEBUG */ + + return true; + }, + + /** + * Sends beacon data via the Beacon API, XHR or Image + * + * @param {object} data Data + */ + sendBeaconData: function(data) { + var urlFirst = [], + urlLast = [], + params, paramsJoined, + url, img, + useImg = true, + xhr, ret; + + BOOMR.debug("Ready to send beacon: " + BOOMR.utils.objectToString(data)); + + // Use the override URL if given + impl.beacon_url = impl.beacon_url_override || impl.beacon_url; + + // Check that the beacon_url was set first + if (!impl.beacon_url) { + BOOMR.debug("No beacon URL, so skipping."); + + return false; + } + + if (!impl.beaconUrlAllowed(impl.beacon_url)) { + BOOMR.debug("Beacon URL not allowed: " + impl.beacon_url); + + return false; + } + + // Check that we have data to send + if (BOOMR.utils.isObjectEmpty(data)) { + return false; + } + + // If we reach here, we've figured out all of the beacon data we'll send. + impl.fireEvent("beacon", data); + + // get high- and low-priority variables first, which remove any of + // those vars from data + urlFirst = this.getVarsOfPriority(data, -1); + urlLast = this.getVarsOfPriority(data, 1); + + // merge the 3 lists + params = urlFirst.concat(this.getVarsOfPriority(data, 0), urlLast); + paramsJoined = params.join("&"); + + // If beacon_url is protocol relative, make it https only + if (impl.beacon_url_force_https && impl.beacon_url.match(/^\/\//)) { + impl.beacon_url = "https:" + impl.beacon_url; + } + + // if there are already url parameters in the beacon url, + // change the first parameter prefix for the boomerang url parameters to & + url = impl.beacon_url + ((impl.beacon_url.indexOf("?") > -1) ? "&" : "?") + paramsJoined; + + // + // Try to send an IMG beacon if possible (which is the most compatible), + // otherwise send an XHR beacon if the URL length is longer than 2,000 bytes. + // + if (impl.beacon_type === "GET") { + useImg = true; + + if (url.length > BOOMR.constants.MAX_GET_LENGTH) { + ((window.console && (console.warn || console.log)) || function() {})( + "Boomerang: Warning: Beacon may not be sent via GET due to payload size > 2000 bytes" + ); + } + } + else if (impl.beacon_type === "POST" || url.length > BOOMR.constants.MAX_GET_LENGTH) { + // switch to a XHR beacon if the the user has specified a POST OR GET length is too long + useImg = false; + } + + // + // Try the sendBeacon API first. + // But if beacon_type is set to "GET", dont attempt + // sendBeacon API call + // + if (w && w.navigator && + typeof w.navigator.sendBeacon === "function" && + BOOMR.utils.isNative(w.navigator.sendBeacon) && + typeof w.Blob === "function" && + impl.beacon_type !== "GET" && + // As per W3C, The sendBeacon method does not provide ability to pass any + // header other than 'Content-Type'. So if we need to send data with + // 'Authorization' header, we need to fallback to good old xhr. + typeof impl.beacon_auth_token === "undefined" && + !impl.beacon_disable_sendbeacon) { + // note we're using sendBeacon with &sb=1 + var blobData = new w.Blob([paramsJoined + "&sb=1"], { + type: "application/x-www-form-urlencoded" + }); + + if (w.navigator.sendBeacon(impl.beacon_url, blobData)) { + return true; + } + + // sendBeacon was not successful, try Image or XHR beacons + } + + // If we don't have XHR available, force an image beacon and hope + // for the best + if (!BOOMR.orig_XMLHttpRequest && (!w || !w.XMLHttpRequest)) { + useImg = true; + } + + if (useImg) { + // + // Image beacon + // + + // just in case Image isn't a valid constructor + try { + img = new Image(); + } + catch (e) { + BOOMR.debug("Image is not a constructor, not sending a beacon"); + + return false; + } + + img.src = url; + } + else { + // + // XHR beacon + // + + // Send a form-encoded XHR POST beacon + xhr = new (BOOMR.window.orig_XMLHttpRequest || BOOMR.orig_XMLHttpRequest || BOOMR.window.XMLHttpRequest)(); + try { + this.sendXhrPostBeacon(xhr, paramsJoined); + } + catch (e) { + // if we had an exception with the window XHR object, try our IFRAME XHR + xhr = new BOOMR.boomerang_frame.XMLHttpRequest(); + this.sendXhrPostBeacon(xhr, paramsJoined); + } + } + + return true; + }, + + /** + * Determines whether or not a Page Load beacon has been sent. + * + * @returns {boolean} True if a Page Load beacon has been sent. + * + * @memberof BOOMR + */ + hasSentPageLoadBeacon: function() { + return impl.hasSentPageLoadBeacon; + }, + + /** + * Sends a beacon via XMLHttpRequest + * + * @param {object} xhr XMLHttpRequest object + * @param {object} [paramsJoined] XMLHttpRequest.send() argument + * + * @memberof BOOMR + */ + sendXhrPostBeacon: function(xhr, paramsJoined) { + xhr.open("POST", impl.beacon_url); + + xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded"); + + if (typeof impl.beacon_auth_token !== "undefined") { + if (typeof impl.beacon_auth_key === "undefined") { + impl.beacon_auth_key = "Authorization"; + } + + xhr.setRequestHeader(impl.beacon_auth_key, impl.beacon_auth_token); + } + + if (impl.beacon_with_credentials) { + xhr.withCredentials = true; + } + + xhr.send(paramsJoined); + }, + + /** + * Gets all variables of the specified priority + * + * @param {object} vars Variables (will be modified for pri -1 and 1) + * @param {number} pri Priority (-1, 0, or 1) + * + * @return {string[]} Array of URI-encoded vars + * + * @memberof BOOMR + */ + getVarsOfPriority: function(vars, pri) { + var name, + url = [], + // if we were given a priority, iterate over that list + // else iterate over vars + iterVars = (pri !== 0 ? impl.varPriority[pri] : vars); + + for (name in iterVars) { + // if this var is set, add it to our URL array + if (iterVars.hasOwnProperty(name) && vars.hasOwnProperty(name)) { + url.push(this.getUriEncodedVar(name, typeof vars[name] === "undefined" ? "" : vars[name])); + + // remove this name from vars so it isn't also added + // to the non-prioritized list when pri=0 is called + if (pri !== 0) { + delete vars[name]; + } + } + } + + return url; + }, + + /** + * Gets a URI-encoded name/value pair. + * + * @param {string} name Name + * @param {string} value Value + * + * @returns {string} URI-encoded string + * + * @memberof BOOMR + */ + getUriEncodedVar: function(name, value) { + if (value === undefined || value === null) { + value = ""; + } + + if (typeof value === "object") { + value = BOOMR.utils.serializeForUrl(value); + } + + var result = encodeURIComponent(name) + + "=" + encodeURIComponent(value); + + return result; + }, + + /** + * Gets the latest ResourceTiming entry for the specified URL. + * + * Default sort order is chronological startTime. + * + * @param {string} url Resource URL + * @param {function} [sort] Sort the entries before returning the last one + * @param {function} [filter] Filter the entries. Will be applied before sorting + * + * @returns {PerformanceEntry|undefined} Entry, or undefined if ResourceTiming is not + * supported or if the entry doesn't exist + * + * @memberof BOOMR + */ + getResourceTiming: function(url, sort, filter) { + var entries, + p = BOOMR.getPerformance(); + + try { + if (p && typeof p.getEntriesByName === "function") { + entries = p.getEntriesByName(url); + + if (!entries || !entries.length) { + return; + } + + if (typeof filter === "function") { + entries = BOOMR.utils.arrayFilter(entries, filter); + + if (!entries || !entries.length) { + return; + } + } + + if (entries.length > 1 && typeof sort === "function") { + entries.sort(sort); + } + + return entries[entries.length - 1]; + } + } + catch (e) { + BOOMR.warn("getResourceTiming:" + e); + } + }, + + /** + * Determines whether beacon data is for a Page Load beacon, or not. + * + * Page Load beacons include regular Page Loads, SPA Hard or SPA Soft beacons. + * + * We also consider an Aborted Load beacon a Page Load. + * + * @param {object} data Beacon Data + * @returns {boolean} True if beacon data is for a Page Load beacon + */ + isPageLoadBeacon: function(data) { + return (typeof data["rt.quit"] === "undefined" || typeof data["rt.abld"] !== "undefined") && + (typeof data["http.initiator"] === "undefined" || + BOOMR.utils.inArray(data["http.initiator"], BOOMR.constants.BEACON_TYPE_SPAS)); + }, + + /** + * Returns the timestamp offset by the Prerendered value, if it happened + * + * @param {number} ts Timestamp + * + * @returns {number} Offset timestamp if Prerendered, original timestamp if not + */ + getPrerenderedOffset: function(ts) { + var actSt = BOOMR.getActivationStart(); + + ts = Math.floor(ts); + + if (actSt === false) { + // Prerender not supported or did not happen, return original timestamp + return ts; + } + else if (actSt !== null) { + // integer offset, return the difference + var newTs = ts - actSt; + + // return the offset (at least 1ms) + return newTs >= 0 ? Math.max(1, newTs) : ts; + } + }, + + /** + * Gets the Activation Start time for Prerendered navigations. + * + * @returns {false|number} false if Prerender isn't supported, false if there was no Prerender, or a timestamp + * of the Activation if there was one + */ + getActivationStart: function() { + if (impl.prerenderedOffset !== null) { + // we've previously calculated, return the value + return impl.prerenderedOffset; + } + + // we're going to now check/set it, default to false (not supported or didn't happen) + impl.prerenderedOffset = false; + + // check if prerendering is supported first + if (typeof d.prerendering !== "boolean") { + // not supported, return false + return impl.prerenderedOffset; + } + + // check if there was an activation via NavigationTiming + var p = BOOMR.getPerformance(); + + if (p && typeof p.getEntriesByType === "function") { + // get the navigation entry + var navEntry = p.getEntriesByType("navigation")[0]; + + if (navEntry && navEntry.activationStart) { + impl.prerenderedOffset = Math.floor(navEntry.activationStart); + } + } + + return impl.prerenderedOffset; + } + + /* BEGIN_DEBUG */, + /** + * Sets the list of allowed Beacon URLs + * + * @param {string[]} urls List of string regular expressions + */ + setBeaconUrlsAllowed: function(urls) { + impl.beacon_urls_allowed = urls; + } + /* END_DEBUG */ + }; + + // if not already set already on BOOMR, determine the URL + if (!BOOMR.url) { + boomr.url = boomr.utils.getMyURL(); + } + else { + // canonicalize the URL + var a = BOOMR.window.document.createElement("a"); + + a.href = BOOMR.url; + boomr.url = a.href; + } + + delete BOOMR_start; + + /** + * @global + * @type {TimeStamp} + * @name BOOMR_lstart + * @desc + * This variable is added to the global scope (`window`) until Boomerang loads, + * at which point it is removed. + * + * Time the loader script started fetching boomerang.js (if the asynchronous + * loader snippet is used). + */ + if (typeof BOOMR_lstart === "number") { + /** + * Time the loader script started fetching boomerang.js (if using the + * asynchronous loader snippet) (`BOOMR_lstart`) + * @type {TimeStamp} + * + * @memberof BOOMR + */ + boomr.t_lstart = BOOMR_lstart; + delete BOOMR_lstart; + } + else if (typeof BOOMR.window.BOOMR_lstart === "number") { + boomr.t_lstart = BOOMR.window.BOOMR_lstart; + } + + /** + * This variable is added to the global scope (`window`). + * + * Time the `window.onload` event fired (if using the asynchronous loader snippet). + * + * This timestamp is logged in the case boomerang.js loads after the onload event + * for browsers that don't support NavigationTiming. + * + * @global + * @name BOOMR_onload + * @type {TimeStamp} + */ + if (typeof BOOMR.window.BOOMR_onload === "number") { + /** + * Time the `window.onload` event fired (if using the asynchronous loader snippet). + * + * This timestamp is logged in the case boomerang.js loads after the onload event + * for browsers that don't support NavigationTiming. + * + * @type {TimeStamp} + * @memberof BOOMR + */ + boomr.t_onload = BOOMR.window.BOOMR_onload; + } + + (function() { + var make_logger; + + if (typeof console === "object" && console.log !== undefined) { + /** + * Logs the message to the console + * + * @param {string} m Message + * @param {string} l Log level + * @param {string} [s] Source + * + * @function log + * + * @memberof BOOMR + */ + boomr.log = function(m, l, s) { + console.log("(" + BOOMR.now() + ") " + + "{" + BOOMR.pageId + "}" + + ": " + s + + ": [" + l + "] " + + m); + }; + } + else { + // NOP for browsers that don't support it + boomr.log = function() {}; + } + + make_logger = function(l) { + return function(m, s) { + this.log(m, l, "boomerang" + (s ? "." + s : "")); + + return this; + }; + }; + + /** + * Logs debug messages to the console + * + * Debug messages are stripped out of production builds. + * + * @param {string} m Message + * @param {string} [s] Source + * + * @function debug + * + * @memberof BOOMR + */ + boomr.debug = make_logger("debug"); + + /** + * Logs info messages to the console + * + * @param {string} m Message + * @param {string} [s] Source + * + * @function info + * + * @memberof BOOMR + */ + boomr.info = make_logger("info"); + + /** + * Logs warning messages to the console + * + * @param {string} m Message + * @param {string} [s] Source + * + * @function warn + * + * @memberof BOOMR + */ + boomr.warn = make_logger("warn"); + + /** + * Logs error messages to the console + * + * @param {string} m Message + * @param {string} [s] Source + * + * @function error + * + * @memberof BOOMR + */ + boomr.error = make_logger("error"); + }()); + + // If the browser supports performance.now(), swap that in for BOOMR.now + try { + var p = boomr.getPerformance(); + + if (p && + typeof p.now === "function" && + // #545 handle bogus performance.now from broken shims + /\[native code\]/.test(String(p.now)) && + p.timing && + p.timing.navigationStart) { + boomr.now = function() { + return Math.round(p.now() + p.timing.navigationStart); + }; + } + } + catch (ignore) { + // empty + } + + impl.checkLocalStorageSupport(); + + (function() { + var ident; + + for (ident in boomr) { + if (boomr.hasOwnProperty(ident)) { + BOOMR[ident] = boomr[ident]; + } + } + + if (!BOOMR.xhr_excludes) { + /** + * URLs to exclude from automatic `XMLHttpRequest` instrumentation. + * + * You can put any of the following in it: + * * A full URL + * * A hostname + * * A path + * + * @example + * BOOMR = window.BOOMR || {}; + * BOOMR.xhr_excludes = { + * "mysite.com": true, + * "/dashboard/": true, + * "https://mysite.com/dashboard/": true + * }; + * + * @memberof BOOMR + */ + BOOMR.xhr_excludes = {}; + } + }()); + + /* BEGIN_DEBUG */ + /* + * This block reports on overridden functions on `window` and properties on `document` using `BOOMR.warn()`. + * To enable, add `overridden` with a value of `true` to the query string. + */ + (function() { + /** + * Checks a window for overridden functions. + * + * @param {Window} win The window object under test + * + * @returns {Array} Array of overridden function names + */ + BOOMR.checkWindowOverrides = function(win) { + if (!Object.getOwnPropertyNames) { + return []; + } + + var freshWindow, objects, + overridden = []; + + function setup() { + var iframe = d.createElement("iframe"); + + iframe.style.display = "none"; + iframe.src = "javascript:false"; // eslint-disable-line no-script-url + d.getElementsByTagName("script")[0].parentNode.appendChild(iframe); + freshWindow = iframe.contentWindow; + objects = Object.getOwnPropertyNames(freshWindow); + } + + function teardown() { + iframe.parentNode.removeChild(iframe); + } + + function checkWindowObject(objectKey) { + if (isNonNative(objectKey)) { + overridden.push(objectKey); + } + } + + function isNonNative(key) { + var split = key.split("."), + fn = win, + results = []; + + while (fn && split.length) { + try { + fn = fn[split.shift()]; + } + catch (e) { + return false; + } + } + + return typeof fn === "function" && !isNativeFunction(fn, key); + } + + function isNativeFunction(fn, str) { + if (str === "console.assert" || + str === "Function.prototype" || + str.indexOf("onload") >= 0 || + str.indexOf("onbeforeunload") >= 0 || + str.indexOf("onerror") >= 0 || + str.indexOf("onload") >= 0 || + str.indexOf("NodeFilter") >= 0) { + return true; + } + + return fn.toString && + !fn.hasOwnProperty("toString") && + /\[native code\]/.test(String(fn)); + } + + setup(); + for (var objectIndex = 0; objectIndex < objects.length; objectIndex++) { + var objectKey = objects[objectIndex]; + + if (objectKey === "window" || + objectKey === "self" || + objectKey === "top" || + objectKey === "parent" || + objectKey === "frames") { + continue; + } + + if (freshWindow[objectKey] && + (typeof freshWindow[objectKey] === "object" || typeof freshWindow[objectKey] === "function")) { + checkWindowObject(objectKey); + + var propertyNames = []; + + try { + propertyNames = Object.getOwnPropertyNames(freshWindow[objectKey]); + } + catch (e) { + ; + } + + for (var i = 0; i < propertyNames.length; i++) { + checkWindowObject([objectKey, propertyNames[i]].join(".")); + } + + if (freshWindow[objectKey].prototype) { + propertyNames = Object.getOwnPropertyNames(freshWindow[objectKey].prototype); + for (var i = 0; i < propertyNames.length; i++) { + checkWindowObject([objectKey, "prototype", propertyNames[i]].join(".")); + } + } + } + } + + return overridden; + }; + + /** + * Checks a document for overridden properties. + * + * @param {HTMLDocument} doc The document object under test + * + * @returns {Array} Array of overridden properties names + */ + BOOMR.checkDocumentOverrides = function(doc) { + return BOOMR.utils.arrayFilter(["readyState", "domain", "hidden", "URL", "cookie"], function(key) { + return doc.hasOwnProperty(key); + }); + }; + + if (BOOMR.utils.getQueryParamValue("overridden") === "true" && w && w.Object && Object.getOwnPropertyNames) { + var overridden = [] + .concat(BOOMR.checkWindowOverrides(w)) + .concat(BOOMR.checkDocumentOverrides(d)); + + if (overridden.length > 0) { + BOOMR.warn("overridden: " + overridden.sort()); + } + } + })(); + /* END_DEBUG */ + + dispatchEvent("onBoomerangLoaded", { "BOOMR": BOOMR }, true); +}(window)); + +// end of boomerang beaconing section diff --git a/ui/Sufi.Demo.PeopleDirectory.UI/Server/.config/dotnet-tools.json b/ui/Sufi.Demo.PeopleDirectory.UI/Server/.config/dotnet-tools.json new file mode 100644 index 0000000..558293e --- /dev/null +++ b/ui/Sufi.Demo.PeopleDirectory.UI/Server/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "7.0.10", + "commands": [ + "dotnet-ef" + ] + } + } +} \ No newline at end of file diff --git a/ui/Sufi.Demo.PeopleDirectory.UI/Server/Controllers/BaseApiController.cs b/ui/Sufi.Demo.PeopleDirectory.UI/Server/Controllers/BaseApiController.cs new file mode 100644 index 0000000..c513bec --- /dev/null +++ b/ui/Sufi.Demo.PeopleDirectory.UI/Server/Controllers/BaseApiController.cs @@ -0,0 +1,26 @@ +using MediatR; +using Microsoft.AspNetCore.Mvc; + +namespace Sufi.Demo.PeopleDirectory.UI.Server.Controllers +{ + /// + /// Represent the base controller class. + /// + /// + [ApiController] + [Route("api/v{version:apiVersion}/[controller]")] + public abstract class BaseApiController : ControllerBase + { + private IMediator? _mediatorInstance; + private ILogger? _loggerInstance; + + /// + /// Gets the mediator for requests/responses. + /// + protected IMediator Mediator => _mediatorInstance ??= HttpContext.RequestServices.GetRequiredService(); + /// + /// Gets the logger for current controller. + /// + protected ILogger Logger => _loggerInstance ??= HttpContext.RequestServices.GetRequiredService>(); + } +} diff --git a/ui/Sufi.Demo.PeopleDirectory.UI/Server/Controllers/v1/BeaconController.cs b/ui/Sufi.Demo.PeopleDirectory.UI/Server/Controllers/v1/BeaconController.cs new file mode 100644 index 0000000..26ade54 --- /dev/null +++ b/ui/Sufi.Demo.PeopleDirectory.UI/Server/Controllers/v1/BeaconController.cs @@ -0,0 +1,54 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Mvc; +using System.Net.Mime; +using System.Text.Json; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + +namespace Sufi.Demo.PeopleDirectory.UI.Server.Controllers.v1 +{ + [ApiVersion(1.0)] + public class BeaconController : BaseApiController + { + [HttpGet] + [HttpPost] + public async Task Index() + { + var beaconData = new Dictionary(); + + if (Request.Method == "GET") + { + foreach (var param in Request.Query) + { + beaconData[param.Key] = param.Value.ToString(); + } + } + else if (Request.Method == "POST") + { + var contentType = Request.ContentType?.ToLower(); + + if (contentType?.Contains(MediaTypeNames.Application.Json) == true) + { + using var reader = new StreamReader(Request.Body); + var body = await reader.ReadToEndAsync(); + var jsonData = JsonSerializer.Deserialize>(body)!; + + foreach (var kvp in jsonData) + { + beaconData[kvp.Key] = kvp.Value.ToString(); + } + } + else if (contentType?.Contains(MediaTypeNames.Application.FormUrlEncoded) == true) + { + var form = await Request.ReadFormAsync(); + foreach (var kvp in form) + { + beaconData[kvp.Key] = kvp.Value.ToString(); + } + } + } + + return Ok(); + } + } +} diff --git a/ui/Sufi.Demo.PeopleDirectory.UI/Server/Controllers/v1/ContactsController.cs b/ui/Sufi.Demo.PeopleDirectory.UI/Server/Controllers/v1/ContactsController.cs new file mode 100644 index 0000000..346cf9f --- /dev/null +++ b/ui/Sufi.Demo.PeopleDirectory.UI/Server/Controllers/v1/ContactsController.cs @@ -0,0 +1,51 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Mvc; +using Sufi.Demo.PeopleDirectory.Application.Features.Contacts.Commands; +using Sufi.Demo.PeopleDirectory.Application.Features.Contacts.Queries.GetAll; +using Sufi.Demo.PeopleDirectory.Application.Features.Contacts.Queries.GetById; +using System.ComponentModel.DataAnnotations; + +namespace Sufi.Demo.PeopleDirectory.UI.Server.Controllers.v1 +{ + /// + /// A controller for manipulating contact data. + /// + [ApiVersion(1.0)] + public class ContactsController : BaseApiController + { + /// + /// Get all contacts. + /// + /// + [HttpGet] + public async Task GetAll() => Ok(await Mediator.Send(new GetAllContactsQuery())); + + /// + /// Get a contact by id. + /// + /// + /// + [HttpGet("{id}")] + public async Task Get(int id) => Ok(await Mediator.Send(new GetContactByIdQuery { Id = id })); + + /// + /// Create/Update a contact. + /// + /// + /// + [HttpPost] + public async Task Post(AddEditContactCommand request) => Ok(await Mediator.Send(request)); + + /// + /// Delete a contact. + /// + /// + /// + [HttpDelete("{id}")] + public async Task Delete([Required] int id) + { + var command = new DeleteContactCommand { Id = id }; + return Ok(await Mediator.Send(command)); + } + } +} diff --git a/ui/Sufi.Demo.PeopleDirectory.UI/Server/Controllers/v1/InfraController.cs b/ui/Sufi.Demo.PeopleDirectory.UI/Server/Controllers/v1/InfraController.cs new file mode 100644 index 0000000..fba48b6 --- /dev/null +++ b/ui/Sufi.Demo.PeopleDirectory.UI/Server/Controllers/v1/InfraController.cs @@ -0,0 +1,28 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Mvc; + +namespace Sufi.Demo.PeopleDirectory.UI.Server.Controllers.v1 +{ + /// + /// Provides API endpoints for infrastructure-related operations. + /// + /// This controller is part of the infrastructure layer and includes endpoints for monitoring and diagnostics. + [ApiVersion(1.0)] + public class InfraController( + ILogger logger + ) : BaseApiController + { + /// + /// Simulates a ping to check if the server is responsive. + /// + /// + [Route("ping")] + [HttpGet] + public IActionResult Ping() + { + logger.LogInformation("InfraController.Ping method called."); + + return Ok(); + } + } +} diff --git a/ui/Sufi.Demo.PeopleDirectory.UI/Server/Controllers/v2/InfraController.cs b/ui/Sufi.Demo.PeopleDirectory.UI/Server/Controllers/v2/InfraController.cs new file mode 100644 index 0000000..76779b7 --- /dev/null +++ b/ui/Sufi.Demo.PeopleDirectory.UI/Server/Controllers/v2/InfraController.cs @@ -0,0 +1,21 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Mvc; + +namespace Sufi.Demo.PeopleDirectory.UI.Server.Controllers.v2 +{ + /// + /// Provides API endpoints for infrastructure-related operations. + /// + /// This controller is part of the infrastructure layer and includes endpoints for monitoring and diagnostics. + [ApiVersion(2.0)] + public class InfraController : BaseApiController + { + /// + /// Simulates a ping to check if the server is responsive. + /// + /// + [Route("ping")] + [HttpGet] + public IActionResult Ping() => Ok("Server is OK"); + } +} diff --git a/ui/Sufi.Demo.PeopleDirectory.UI/Server/Extensions/ServiceCollectionExtensions.cs b/ui/Sufi.Demo.PeopleDirectory.UI/Server/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..f8f8e5f --- /dev/null +++ b/ui/Sufi.Demo.PeopleDirectory.UI/Server/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,146 @@ +using Asp.Versioning; +using Asp.Versioning.ApiExplorer; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.Options; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; +using System.Text; +using System.Text.Json; + +namespace Sufi.Demo.PeopleDirectory.UI.Server.Extensions +{ + /// + /// Extension class. + /// + public static class ServiceCollectionExtensions + { + internal static void RegisterSwagger(this IServiceCollection services) + { + services.ConfigureOptions(); + services.AddSwaggerGen(c => + { + //TODO - Lowercase Swagger Documents + //c.DocumentFilter(); + //Refer - https://gist.github.com/rafalkasa/01d5e3b265e5aa075678e0adfd54e23f + + // include all project's xml comments + var baseDirectory = AppDomain.CurrentDomain.BaseDirectory; + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + if (!assembly.IsDynamic) + { + var xmlFile = $"{assembly.GetName().Name}.xml"; + var xmlPath = Path.Combine(baseDirectory, xmlFile); + if (File.Exists(xmlPath)) + { + c.IncludeXmlComments(xmlPath); + } + } + } + + c.OperationFilter(); + }) + .AddApiVersioning(config => + { + config.DefaultApiVersion = new ApiVersion(1, 0); + config.AssumeDefaultVersionWhenUnspecified = true; + config.ApiVersionReader = new UrlSegmentApiVersionReader(); + }) + .AddApiExplorer(options => + { + options.GroupNameFormat = "'v'VVV"; + options.SubstituteApiVersionInUrl = true; + }); + } + } + + internal class ConfigureSwaggerGenOptions(IApiVersionDescriptionProvider provider) : IConfigureOptions + { + public void Configure(SwaggerGenOptions options) + { + foreach (var description in provider.ApiVersionDescriptions) + { + options.SwaggerDoc(description.GroupName, CreateInfoForApiVersion(description)); + } + } + + private static OpenApiInfo CreateInfoForApiVersion(ApiVersionDescription description) + { + var text = new StringBuilder("This application is for demo purposes by Sufi only."); + var info = new OpenApiInfo + { + Title = "Sufi.Demo.App", + Version = description.ApiVersion.ToString(), + License = new OpenApiLicense + { + Name = "MIT License", + Url = new Uri("https://opensource.org/licenses/MIT") + } + }; + + if (description.IsDeprecated) + { + text.Append("This Api version has been deprecated."); + } + + if (description.SunsetPolicy is SunsetPolicy policy && policy.Date is DateTimeOffset when) + { + text.Append(" The Api will be sunset on ") + .Append(when.Date.ToShortDateString()) + .Append('.'); + } + + info.Description = text.ToString(); + + return info; + } + } + + internal class SwaggerDefaultValues : IOperationFilter + { + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + var apiDescription = context.ApiDescription; + + operation.Deprecated |= apiDescription.IsDeprecated(); + + foreach (var responseType in context.ApiDescription.SupportedResponseTypes) + { + var responseKey = responseType.IsDefaultResponse ? "default" : responseType.StatusCode.ToString(); + var response = operation.Responses[responseKey]; + + foreach (var contentType in response.Content.Keys) + { + if (!responseType.ApiResponseFormats.Any(x => x.MediaType == contentType)) + { + response.Content.Remove(contentType); + } + } + } + + if (operation.Parameters == null) + { + return; + } + + foreach (var parameter in operation.Parameters) + { + var description = apiDescription.ParameterDescriptions.First(p => p.Name == parameter.Name); + + parameter.Description ??= description.ModelMetadata.Description; + + if (parameter.Schema.Default == null && + description.DefaultValue != null && + description.DefaultValue is not DBNull && + description.ModelMetadata is ModelMetadata modelMetadata) + { + var json = JsonSerializer.Serialize(description.DefaultValue, modelMetadata.ModelType); + parameter.Schema.Default = OpenApiAnyFactory.CreateFromJson(json); + } + + parameter.Required |= description.IsRequired; + } + } + } +} diff --git a/ui/Sufi.Demo.PeopleDirectory.UI/Server/Options/RateLimitOptions.cs b/ui/Sufi.Demo.PeopleDirectory.UI/Server/Options/RateLimitOptions.cs new file mode 100644 index 0000000..87f7840 --- /dev/null +++ b/ui/Sufi.Demo.PeopleDirectory.UI/Server/Options/RateLimitOptions.cs @@ -0,0 +1,20 @@ +namespace Sufi.Demo.PeopleDirectory.UI.Server.Options +{ + /// + /// Represents configuration options for rate limiting functionality. + /// + /// This type is used to define the parameters for controlling the rate at which operations are + /// allowed. It specifies the maximum number of permits and the time window during which the permits are + /// valid. + public record RateLimitOptions + { + /// + /// Gets the maximum number of permits that can be issued. + /// + public int PermitLimit { get; init; } = 100; + /// + /// Gets the time interval that defines the window for rate-limiting operations (in seconds). + /// + public int Window { get; init; } = 60; + } +} diff --git a/ui/Sufi.Demo.PeopleDirectory.UI/Server/Pages/Error.cshtml b/ui/Sufi.Demo.PeopleDirectory.UI/Server/Pages/Error.cshtml new file mode 100644 index 0000000..e82b92f --- /dev/null +++ b/ui/Sufi.Demo.PeopleDirectory.UI/Server/Pages/Error.cshtml @@ -0,0 +1,42 @@ +@page +@model Sufi.Demo.PeopleDirectory.UI.Server.Pages.ErrorModel + + + + + + + + Error + + + + + +
+
+

Error.

+

An error occurred while processing your request.

+ + @if (Model.ShowRequestId) + { +

+ Request ID: @Model.RequestId +

+ } + +

Development Mode

+

+ Swapping to the Development environment displays detailed information about the error that occurred. +

+

+ The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +

+
+
+ + + diff --git a/ui/Sufi.Demo.PeopleDirectory.UI/Server/Pages/Error.cshtml.cs b/ui/Sufi.Demo.PeopleDirectory.UI/Server/Pages/Error.cshtml.cs new file mode 100644 index 0000000..ea067e5 --- /dev/null +++ b/ui/Sufi.Demo.PeopleDirectory.UI/Server/Pages/Error.cshtml.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using System.Diagnostics; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + +namespace Sufi.Demo.PeopleDirectory.UI.Server.Pages +{ + [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] + [IgnoreAntiforgeryToken] + public class ErrorModel : PageModel + { + public string? RequestId { get; set; } + + public bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + private readonly ILogger _logger; + + public ErrorModel(ILogger logger) + { + _logger = logger; + } + + public void OnGet() + { + RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier; + } + } +} \ No newline at end of file diff --git a/ui/Sufi.Demo.PeopleDirectory.UI/Server/Program.cs b/ui/Sufi.Demo.PeopleDirectory.UI/Server/Program.cs new file mode 100644 index 0000000..9a2a451 --- /dev/null +++ b/ui/Sufi.Demo.PeopleDirectory.UI/Server/Program.cs @@ -0,0 +1,22 @@ +using Serilog; +using Sufi.Demo.PeopleDirectory.UI.Server; + +try +{ + var builder = WebApplication.CreateBuilder(args); + var app = builder.ConfigureServices() + .ConfigurePipeline(); + + // Ensure all pending migrations are applied. + await app.EnsureDatabaseMigrationAsync(); + + app.Run(); +} +catch (Exception ex) +{ + Log.Fatal(ex, "Application terminated unexpectedly"); +} +finally +{ + Log.CloseAndFlush(); +} diff --git a/ui/Sufi.Demo.PeopleDirectory.UI/Server/Properties/launchSettings.json b/ui/Sufi.Demo.PeopleDirectory.UI/Server/Properties/launchSettings.json new file mode 100644 index 0000000..5441895 --- /dev/null +++ b/ui/Sufi.Demo.PeopleDirectory.UI/Server/Properties/launchSettings.json @@ -0,0 +1,30 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:50212", + "sslPort": 44358 + } + }, + "profiles": { + "Sufi.Demo.PeopleDirectory.UI.Server": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "https://localhost:7024;http://localhost:5243", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } + } diff --git a/ui/Sufi.Demo.PeopleDirectory.UI/Server/StartupExtensions.cs b/ui/Sufi.Demo.PeopleDirectory.UI/Server/StartupExtensions.cs new file mode 100644 index 0000000..cb74d86 --- /dev/null +++ b/ui/Sufi.Demo.PeopleDirectory.UI/Server/StartupExtensions.cs @@ -0,0 +1,129 @@ +using Microsoft.EntityFrameworkCore; +using Serilog; +using Sufi.Demo.PeopleDirectory.Application.Extensions; +using Sufi.Demo.PeopleDirectory.Infrastructure; +using Sufi.Demo.PeopleDirectory.Persistence.Contexts; +using Sufi.Demo.PeopleDirectory.Persistence.Extensions; +using Sufi.Demo.PeopleDirectory.UI.Server.Extensions; +using Sufi.Demo.PeopleDirectory.UI.Server.Options; +using System.Threading.RateLimiting; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + +namespace Sufi.Demo.PeopleDirectory.UI.Server +{ + public static class StartupExtensions + { + public static WebApplication ConfigureServices(this WebApplicationBuilder builder) + { + var configuration = builder.Configuration; + + Log.Logger = new LoggerConfiguration() + .ReadFrom.Configuration(configuration) + .CreateLogger(); + builder.Host.UseSerilog(); + + var rateLimitOptions = configuration.GetSection("RateLimit").Get()!; + var services = builder.Services; + var licenseKey = configuration.GetValue("LuckyPennyLicenseKey") ?? string.Empty; + + // Add services to the container. + services.AddHttpContextAccessor(); + services.AddApplicationLayer(licenseKey) + .AddInfrastructureServices() + .AddPersistenceServices(configuration); + + services.AddControllersWithViews(); + services.AddRazorPages(); + services.AddHealthChecks(); + + services.AddRateLimiter(options => + { + // Apply for all requests. + options.GlobalLimiter = PartitionedRateLimiter.Create(httpContext => + RateLimitPartition.GetFixedWindowLimiter( + partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown", + factory: partition => new FixedWindowRateLimiterOptions + { + AutoReplenishment = true, + PermitLimit = rateLimitOptions.PermitLimit, + Window = TimeSpan.FromSeconds(rateLimitOptions.Window) + })); + + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + }); + + // Register Swagger services. + services.AddEndpointsApiExplorer(); + services.RegisterSwagger(); + + return builder.Build(); + } + + public static WebApplication ConfigurePipeline(this WebApplication app) + { + app.UseSerilogRequestLogging(options => + { + options.EnrichDiagnosticContext = (diagnosticContext, httpContext) => + { + diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value); + diagnosticContext.Set("UserAgent", httpContext.Request.Headers.UserAgent); + diagnosticContext.Set("ClientIP", httpContext.Connection.RemoteIpAddress?.ToString() ?? ""); + }; + }); + + // Configure the HTTP request pipeline. + if (app.Environment.IsDevelopment()) + { + app.UseWebAssemblyDebugging(); + app.ConfigureSwagger(); + } + else + { + app.UseExceptionHandler("/Error"); + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + app.UseHsts(); + } + + app.UseBlazorFrameworkFiles(); + app.UseStaticFiles(); + + app.UseRouting(); + app.UseRateLimiter(); + + app.MapHealthChecks("/health"); + app.MapRazorPages(); + app.MapControllers(); + app.MapFallbackToFile("index.html"); + + return app; + } + + public static void ConfigureSwagger(this IApplicationBuilder app) + { + app.UseSwagger(); + app.UseSwaggerUI(options => + { + options.RoutePrefix = "swagger"; + options.DisplayRequestDuration(); + + foreach (var desc in ((IEndpointRouteBuilder)app).DescribeApiVersions()) + { + options.SwaggerEndpoint($"{desc.GroupName}/swagger.json", + desc.GroupName.ToUpperInvariant()); + } + }); + } + + public static async Task EnsureDatabaseMigrationAsync(this IApplicationBuilder app) + { + using (var scope = app.ApplicationServices.CreateScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + await dbContext.Database.MigrateAsync(); + } + + return app; + } + } +} diff --git a/ui/Sufi.Demo.PeopleDirectory.UI/Server/Sufi.Demo.PeopleDirectory.UI.Server.csproj b/ui/Sufi.Demo.PeopleDirectory.UI/Server/Sufi.Demo.PeopleDirectory.UI.Server.csproj new file mode 100644 index 0000000..d1a7065 --- /dev/null +++ b/ui/Sufi.Demo.PeopleDirectory.UI/Server/Sufi.Demo.PeopleDirectory.UI.Server.csproj @@ -0,0 +1,32 @@ + + + + net8.0 + enable + enable + 19cb1e83-775a-41dd-a289-4d267a75a265 + True + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + diff --git a/ui/Sufi.Demo.PeopleDirectory.UI/Server/appsettings.Development.json b/ui/Sufi.Demo.PeopleDirectory.UI/Server/appsettings.Development.json new file mode 100644 index 0000000..11ab151 --- /dev/null +++ b/ui/Sufi.Demo.PeopleDirectory.UI/Server/appsettings.Development.json @@ -0,0 +1,21 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "ConnectionStrings": { + "DefaultConnectionString": "Server=localhost:5432;Database=demo-contact;User Id=postgres;Password=Abcd@1234;" + }, + "Serilog": { + "WriteTo": [ + { + "Name": "Console", + "Args": { + "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} <{Application}>{NewLine}{Exception}" + } + } + ] + } +} diff --git a/ui/Sufi.Demo.PeopleDirectory.UI/Server/appsettings.Production.json b/ui/Sufi.Demo.PeopleDirectory.UI/Server/appsettings.Production.json new file mode 100644 index 0000000..992b9ea --- /dev/null +++ b/ui/Sufi.Demo.PeopleDirectory.UI/Server/appsettings.Production.json @@ -0,0 +1,26 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "Serilog": { + "WriteTo": [ + { + "Name": "Console", + "Args": { + "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} <{Application}>{NewLine}{Exception}" + } + }, + { + "Name": "Seq", + "Args": { + "serverUrl": "", + "apiKey": "", + "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} <{Application}>{NewLine}{Exception}" + } + } + ] + } +} diff --git a/ui/Sufi.Demo.PeopleDirectory.UI/Server/appsettings.json b/ui/Sufi.Demo.PeopleDirectory.UI/Server/appsettings.json new file mode 100644 index 0000000..ddd4b2e --- /dev/null +++ b/ui/Sufi.Demo.PeopleDirectory.UI/Server/appsettings.json @@ -0,0 +1,37 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "DefaultConnectionString": "" + }, + "Serilog": { + "Using": [ + "Serilog.Sinks.Console", + "Serilog.Sinks.Seq" + ], + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + }, + "Enrich": [ + "FromLogContext", + "WithProperty:Application" + ], + "Properties": { + "Application": "Sufi.Demo.PeopleDirectory" + } + }, + "RateLimit": { + "PermitLimit": 100, + "Window": 60 + }, + "LuckyPennyLicenseKey": "" +}