Initial code commit.

This commit is contained in:
2026-02-03 10:44:31 +08:00
parent 8927c5ae0e
commit d69fe2cc1f
99 changed files with 10839 additions and 0 deletions

53
.dockerignore Normal file
View File

@@ -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

View File

@@ -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

103
.gitea/workflows/deploy.yml Normal file
View File

@@ -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

399
.gitignore vendored Normal file
View File

@@ -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

19
Dockerfile Normal file
View File

@@ -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"]

18
LICENSE Normal file
View File

@@ -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.

View File

@@ -1,2 +1,3 @@
# demo-contact # demo-contact
A simple demonstration on my coding knowledge and capabilities. In case someone need to see it :)

View File

@@ -0,0 +1,6 @@
namespace Sufi.Demo.PeopleDirectory.Application.Contracts.Common
{
public interface IService
{
}
}

View File

@@ -0,0 +1,24 @@
using Sufi.Demo.PeopleDirectory.Domain.Common;
namespace Sufi.Demo.PeopleDirectory.Application.Contracts.Repositories
{
public interface IAsyncRepository<T, in TId> where T : class, IEntity<TId>
{
IQueryable<T> Entities { get; }
Task<T?> GetByIdAsync(TId id);
Task<List<T>> GetAllAsync();
Task<List<T>> GetPagedResponseAsync(int pageNumber, int pageSize);
Task<T> AddAsync(T entity);
Task UpdateAsync(T entity);
Task DeleteAsync(T entity);
Task<int> DeleteByIdAsync(TId id);
Task<int> CountAsync();
}
}

View File

@@ -0,0 +1,15 @@
using Sufi.Demo.PeopleDirectory.Domain.Common;
namespace Sufi.Demo.PeopleDirectory.Application.Contracts.Repositories
{
public interface IUnitOfWork<TId> : IDisposable
{
IAsyncRepository<T, TId> Repository<T>() where T : AuditableEntity<TId>;
Task<int> Commit(CancellationToken cancellationToken);
Task<int> CommitAndRemoveCache(CancellationToken cancellationToken, params string[] cacheKeys);
Task Rollback();
}
}

View File

@@ -0,0 +1,11 @@
namespace Sufi.Demo.PeopleDirectory.Application.Contracts.Services
{
public interface IAppCache
{
ValueTask<T> GetOrAddAsync<T>(string key, Func<CancellationToken, ValueTask<T>> factory,
IEnumerable<string>? tags = null, TimeSpan? absoluteExpireTime = null);
ValueTask RemoveAsync(string key);
ValueTask RemoveByTagAsync(string tag);
ValueTask ResetAsync();
}
}

View File

@@ -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; }
}
}

View File

@@ -0,0 +1,10 @@
namespace Sufi.Demo.PeopleDirectory.Application.Enums
{
public enum AuditType
{
None = 0,
Create = 1,
Update = 2,
Delete = 3
}
}

View File

@@ -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;
}
}
}

View File

@@ -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<IResult<int>>
{
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<AddEditContactCommand>
{
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<int> unitOfWork,
ILogger<AddEditContactCommandHandler> logger,
IAppCache appCache
) : IRequestHandler<AddEditContactCommand, IResult<int>>
{
public async Task<IResult<int>> Handle(AddEditContactCommand command, CancellationToken cancellationToken)
{
if (command.Id == 0)
{
// Only add if max count is not more than 100.
var count = await unitOfWork.Repository<Contact>().CountAsync();
if (count > 100)
{
return await Result<int>.FailAsync("Max item count reached. Please delete some first.");
}
var contact = mapper.Map<Contact>(command);
await unitOfWork.Repository<Contact>().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<int>.SuccessAsync(contact.Id, "New contact saved.");
}
else
{
var contact = await unitOfWork.Repository<Contact>().GetByIdAsync(command.Id);
if (contact != null)
{
mapper.Map(command, contact);
await unitOfWork.Repository<Contact>().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<int>.SuccessAsync(contact.Id, "Contact updated.");
}
else
{
logger.LogWarning("Contact not found with ID: {Id}", command.Id);
return await Result<int>.FailAsync("Contact not found!");
}
}
}
}
}

View File

@@ -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<IResult>
{
public int Id { get; set; }
}
public sealed class DeleteContactCommandValidator : AbstractValidator<DeleteContactCommand>
{
public DeleteContactCommandValidator()
{
RuleFor(v => v.Id)
.GreaterThan(0)
.WithMessage("A valid Id is required.");
}
}
public class DeleteContactCommandHandler(
IUnitOfWork<int> unitOfWork,
ILogger<DeleteContactCommandHandler> logger,
IAppCache appCache
) : IRequestHandler<DeleteContactCommand, IResult>
{
public async Task<IResult> Handle(DeleteContactCommand request, CancellationToken cancellationToken)
{
var itemToDelete = await unitOfWork.Repository<Contact>().GetByIdAsync(request.Id);
if (itemToDelete != null)
{
await unitOfWork.Repository<Contact>().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.");
}
}
}

View File

@@ -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<IResult<List<GetAllContactsResponse>>>
{
}
public class GetAllContactsQueryHandler(
IUnitOfWork<int> unitOfWork,
IMapper mapper,
IAppCache appCache
) : IRequestHandler<GetAllContactsQuery, IResult<List<GetAllContactsResponse>>>
{
public async Task<IResult<List<GetAllContactsResponse>>> Handle(GetAllContactsQuery request, CancellationToken cancellationToken)
{
Task<List<Contact>> allContactsFunc() => unitOfWork.Repository<Contact>().GetAllAsync();
var allContacts = await appCache.GetOrAddAsync(
"contact_all",
async token => await allContactsFunc(),
absoluteExpireTime: TimeSpan.FromMinutes(2),
tags: ["contacts"]
);
var mappedContacts = mapper.Map<List<GetAllContactsResponse>>(allContacts);
return await Result<List<GetAllContactsResponse>>.SuccessAsync(mappedContacts);
}
}
}

View File

@@ -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!;
}
}

View File

@@ -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<IResult<GetContactByIdResponse>>
{
public int Id { get; set; }
}
public sealed class GetContactByIdQueryValidator : AbstractValidator<GetContactByIdQuery>
{
public GetContactByIdQueryValidator()
{
RuleFor(v => v.Id)
.GreaterThan(0)
.WithMessage("A valid Id is required.");
}
}
public class GetContactByIdQueryHandler(
IUnitOfWork<int> unitOfWork,
IMapper mapper,
IAppCache appCache
) : IRequestHandler<GetContactByIdQuery, IResult<GetContactByIdResponse>>
{
public async Task<IResult<GetContactByIdResponse>> Handle(GetContactByIdQuery request, CancellationToken cancellationToken)
{
Task<Contact?> getContactByIdFunc() => unitOfWork.Repository<Contact>().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<GetContactByIdResponse>(contact);
return await Result<GetContactByIdResponse>.SuccessAsync(mappedContact);
}
}
}

View File

@@ -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!;
}
}

View File

@@ -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<AddEditContactCommand, Contact>().ReverseMap();
CreateMap<GetAllContactsResponse, Contact>().ReverseMap();
CreateMap<Contact, GetContactByIdResponse>();
CreateMap<Contact, ContactResponse>();
}
}
}

View File

@@ -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!;
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AutoMapper" Version="15.0.1" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.0.0" />
<PackageReference Include="MediatR" Version="13.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Sufi.Demo.PeopleDirectory.Domain\Sufi.Demo.PeopleDirectory.Domain.csproj" />
<ProjectReference Include="..\Sufi.Demo.PeopleDirectory.Shared\Sufi.Demo.PeopleDirectory.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Features\Boomerang\" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,13 @@
using System;
namespace Sufi.Demo.PeopleDirectory.Domain.Common
{
public abstract class AuditableEntity<TId> : IAuditableEntity<TId>
{
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; }
}
}

View File

@@ -0,0 +1,17 @@
using System;
namespace Sufi.Demo.PeopleDirectory.Domain.Common
{
public interface IAuditableEntity<TId> : IAuditableEntity, IEntity<TId>
{
}
public interface IAuditableEntity : IEntity
{
string? CreatedBy { get; set; }
DateTime CreatedOn { get; set; }
string? LastModifiedBy { get; set; }
DateTime? LastModifiedOn { get; set; }
}
}

View File

@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
namespace Sufi.Demo.PeopleDirectory.Domain.Common
{
public interface IEntity
{
}
public interface IEntity<TId> : IEntity
{
[Key]
TId Id { get; set; }
}
}

View File

@@ -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<int>
{
[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!;
}
}

View File

@@ -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<string>
{
[Required]
[Column(TypeName = "character varying(255)")]
public string Value { get; set; } = null!;
}
}

View File

@@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -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<KeyValuePair<string, string>>? 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<string, string>(item.Type, item.Value)).ToList();
}
}
}

View File

@@ -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<ICurrentUserService, CurrentUserService>()
.AddTransient<IAppCache, AppCache>();
// Some background jobs here.
services.AddQuartz(options =>
{
var jobKey = new JobKey("ClearPersistentDataJob");
options.AddJob<ClearPersistentDataJob>(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;
}
}
}

View File

@@ -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
{
/// <summary>
/// Represents the cleanup job.
/// </summary>
/// <remarks>
/// Initialize an instance of <see cref="ClearPersistentDataJob"/> class.
/// </remarks>
/// <param name="unitOfWorkInt"></param>
/// <param name="unitOfWorkString"></param>
public class ClearPersistentDataJob(IUnitOfWork<int> unitOfWorkInt, IUnitOfWork<string> unitOfWorkString, IAppCache appCache) : IJob
{
private const string LastDateDeletedKey = "LastDateDeleted";
private const string DateTimeFormat = "yyyy-MM-dd HH:mm:ss.ffff";
/// <summary>
/// Job implementation to be executed.
/// </summary>
/// <param name="context"></param>
/// <returns></returns>
public async Task Execute(IJobExecutionContext context)
{
// Get all contacts to be deleted.
var contactsToDelete = await unitOfWorkInt.Repository<Contact>().GetAllAsync();
// Skips if nothing to delete.
if (contactsToDelete.Count == 0)
return;
// Delete all contacts.
foreach (var contact in contactsToDelete)
{
await unitOfWorkInt.Repository<Contact>().DeleteAsync(contact);
}
// Update the last date deleted in the ServerInfo table.
var infoToUpdate = await unitOfWorkString.Repository<ServerInfo>().GetByIdAsync(LastDateDeletedKey);
if (infoToUpdate != null)
{
infoToUpdate.Value = DateTime.UtcNow.ToString(DateTimeFormat);
await unitOfWorkString.Repository<ServerInfo>().UpdateAsync(infoToUpdate);
}
else
{
var infoToAdd = new ServerInfo { Id = LastDateDeletedKey, Value = DateTime.UtcNow.ToString(DateTimeFormat) };
await unitOfWorkString.Repository<ServerInfo>().AddAsync(infoToAdd);
}
// Commit the changes to the database.
await unitOfWorkString.Commit(context.CancellationToken);
// Clear cache entries related to contacts.
await appCache.ResetAsync();
}
}
}

View File

@@ -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<T> GetOrAddAsync<T>(string key, Func<CancellationToken, ValueTask<T>> factory,
IEnumerable<string>? tags = null, TimeSpan? absoluteExpireTime = null)
{
var options = new HybridCacheEntryOptions
{
Expiration = absoluteExpireTime ?? TimeSpan.FromSeconds(30),
};
return hybridCache.GetOrCreateAsync<T>(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("*");
}
}
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="9.10.0" />
<PackageReference Include="Quartz.AspNetCore" Version="3.14.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Sufi.Demo.PeopleDirectory.Application\Sufi.Demo.PeopleDirectory.Application.csproj" />
</ItemGroup>
</Project>

View File

@@ -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<ApplicationDbContext> options,
ICurrentUserService currentUserService
) : AuditableContext(options)
{
public virtual DbSet<Contact> Contacts { get; set; } = null!;
public virtual DbSet<ServerInfo> ServerInfos { get; set; } = null!;
public override Task<int> SaveChangesAsync(string? userId = null, CancellationToken cancellationToken = default)
{
PopulateAuditRecords();
return base.SaveChangesAsync(userId, cancellationToken);
}
public override async Task<int> 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<IdentityUserRole<string>>(entity => entity.ToTable("UserRoles", "Identity"));
builder.Entity<IdentityUserClaim<string>>(entity => entity.ToTable("UserClaims", "Identity"));
builder.Entity<IdentityUserLogin<string>>(entity => entity.ToTable("UserLogins", "Identity"));
builder.Entity<AppRoleClaim>(entity => entity.ToTable("RoleClaims", "Identity"));
builder.Entity<IdentityUserToken<string>>(entity => entity.ToTable("UserTokens", "Identity"));
}
private void PopulateAuditRecords()
{
foreach (var entry in ChangeTracker.Entries<IAuditableEntity>().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;
}
}
}
}
}

View File

@@ -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<AppUser, AppRole, string, IdentityUserClaim<string>, IdentityUserRole<string>,
IdentityUserLogin<string>, AppRoleClaim, IdentityUserToken<string>>(options)
{
public DbSet<Audit> AuditTrails { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.Entity<Audit>().ToTable("AuditTrails", "Audit");
}
public virtual async Task<int> 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<AuditEntry> OnBeforeSaveChanges(string? userId)
{
ChangeTracker.DetectChanges();
var auditEntries = new List<AuditEntry>();
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<AuditEntry> 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);
}
}
}

View File

@@ -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<AppRole>
{
public void Configure(EntityTypeBuilder<AppRole> builder)
{
builder.ToTable("Roles", "Identity");
}
}
}

View File

@@ -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<AppUser>
{
public void Configure(EntityTypeBuilder<AppUser> builder)
{
builder.ToTable("Users", "Identity");
builder.Property(e => e.Id).ValueGeneratedOnAdd();
}
}
}

View File

@@ -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<ApplicationDbContext>(options => options.UseNpgsql(configuration.GetConnectionString("DefaultConnectionString")!));
services
.AddIdentityCore<AppUser>(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<AppRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
services
.AddTransient(typeof(IAsyncRepository<,>), typeof(AsyncRepository<,>))
.AddTransient(typeof(IUnitOfWork<>), typeof(UnitOfWork<>));
return services;
}
}
}

View File

@@ -0,0 +1,457 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UserClaims", "Identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("UserLogins", "Identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("UserRoles", "Identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("UserTokens", "Identity");
});
modelBuilder.Entity("Sufi.Demo.PeopleDirectory.Domain.Entities.Misc.Contact", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("CreatedBy")
.HasColumnType("character varying(100)");
b.Property<DateTime>("CreatedOn")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("character varying(100)");
b.Property<string>("Hobby")
.IsRequired()
.HasColumnType("character varying(255)");
b.Property<string>("LastModifiedBy")
.HasColumnType("character varying(100)");
b.Property<DateTime?>("LastModifiedOn")
.HasColumnType("timestamp with time zone");
b.Property<string>("Phone")
.IsRequired()
.HasColumnType("character varying(20)");
b.Property<string>("SkillSets")
.IsRequired()
.HasColumnType("character varying(255)");
b.Property<string>("UserName")
.IsRequired()
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.ToTable("Contacts");
});
modelBuilder.Entity("Sufi.Demo.PeopleDirectory.Domain.Entities.Misc.ServerInfo", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("CreatedBy")
.HasColumnType("character varying(100)");
b.Property<DateTime>("CreatedOn")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastModifiedBy")
.HasColumnType("character varying(100)");
b.Property<DateTime?>("LastModifiedOn")
.HasColumnType("timestamp with time zone");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("character varying(255)");
b.HasKey("Id");
b.ToTable("ServerInfos");
});
modelBuilder.Entity("Sufi.Demo.PeopleDirectory.Infrastructure.Models.Audit.Audit", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("AffectedColumns")
.HasColumnType("character varying(100)");
b.Property<DateTime>("DateTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("NewValues")
.HasColumnType("character varying(255)");
b.Property<string>("OldValues")
.HasColumnType("character varying(255)");
b.Property<string>("PrimaryKey")
.IsRequired()
.HasColumnType("character varying(100)");
b.Property<string>("TableName")
.IsRequired()
.HasColumnType("character varying(50)");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("character varying(20)");
b.Property<string>("UserId")
.HasColumnType("character varying(100)");
b.HasKey("Id");
b.ToTable("AuditTrails", "Audit");
});
modelBuilder.Entity("Sufi.Demo.PeopleDirectory.Infrastructure.Models.Identity.AppRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("CreatedBy")
.HasColumnType("character varying(100)");
b.Property<DateTime>("CreatedOn")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("character varying(100)");
b.Property<string>("LastModifiedBy")
.HasColumnType("character varying(100)");
b.Property<DateTime?>("LastModifiedOn")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("AppRoleId")
.HasColumnType("text");
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("CreatedBy")
.HasColumnType("character varying(100)");
b.Property<DateTime>("CreatedOn")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("character varying(100)");
b.Property<string>("Group")
.HasColumnType("character varying(100)");
b.Property<string>("LastModifiedBy")
.HasColumnType("character varying(100)");
b.Property<DateTime?>("LastModifiedOn")
.HasColumnType("timestamp with time zone");
b.Property<string>("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<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("text");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("CreatedBy")
.HasColumnType("character varying(100)");
b.Property<DateTime>("CreatedOn")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedOn")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<string>("LastModifiedBy")
.HasColumnType("character varying(100)");
b.Property<DateTime?>("LastModifiedOn")
.HasColumnType("timestamp with time zone");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("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<string>", b =>
{
b.HasOne("Sufi.Demo.PeopleDirectory.Infrastructure.Models.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("Sufi.Demo.PeopleDirectory.Infrastructure.Models.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", 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<string>", 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
}
}
}

View File

@@ -0,0 +1,355 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Sufi.Demo.PeopleDirectory.Persistence.Migrations
{
/// <inheritdoc />
public partial class InitialSchema : Migration
{
/// <inheritdoc />
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<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
UserId = table.Column<string>(type: "character varying(100)", nullable: true),
Type = table.Column<string>(type: "character varying(20)", nullable: false),
TableName = table.Column<string>(type: "character varying(50)", nullable: false),
DateTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
OldValues = table.Column<string>(type: "character varying(255)", nullable: true),
NewValues = table.Column<string>(type: "character varying(255)", nullable: true),
AffectedColumns = table.Column<string>(type: "character varying(100)", nullable: true),
PrimaryKey = table.Column<string>(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<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
UserName = table.Column<string>(type: "character varying(50)", nullable: false),
Phone = table.Column<string>(type: "character varying(20)", nullable: false),
Email = table.Column<string>(type: "character varying(100)", nullable: false),
SkillSets = table.Column<string>(type: "character varying(255)", nullable: false),
Hobby = table.Column<string>(type: "character varying(255)", nullable: false),
CreatedBy = table.Column<string>(type: "character varying(100)", nullable: true),
CreatedOn = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
LastModifiedBy = table.Column<string>(type: "character varying(100)", nullable: true),
LastModifiedOn = table.Column<DateTime>(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<string>(type: "text", nullable: false),
Description = table.Column<string>(type: "character varying(100)", nullable: true),
CreatedBy = table.Column<string>(type: "character varying(100)", nullable: true),
CreatedOn = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
LastModifiedBy = table.Column<string>(type: "character varying(100)", nullable: true),
LastModifiedOn = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Roles", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ServerInfos",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
Value = table.Column<string>(type: "character varying(255)", nullable: false),
CreatedBy = table.Column<string>(type: "character varying(100)", nullable: true),
CreatedOn = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
LastModifiedBy = table.Column<string>(type: "character varying(100)", nullable: true),
LastModifiedOn = table.Column<DateTime>(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<string>(type: "text", nullable: false),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
IsDeleted = table.Column<bool>(type: "boolean", nullable: false),
DeletedOn = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
CreatedBy = table.Column<string>(type: "character varying(100)", nullable: true),
CreatedOn = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
LastModifiedBy = table.Column<string>(type: "character varying(100)", nullable: true),
LastModifiedOn = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
UserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedUserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
Email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
EmailConfirmed = table.Column<bool>(type: "boolean", nullable: false),
PasswordHash = table.Column<string>(type: "text", nullable: true),
SecurityStamp = table.Column<string>(type: "text", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true),
PhoneNumber = table.Column<string>(type: "text", nullable: true),
PhoneNumberConfirmed = table.Column<bool>(type: "boolean", nullable: false),
TwoFactorEnabled = table.Column<bool>(type: "boolean", nullable: false),
LockoutEnd = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
LockoutEnabled = table.Column<bool>(type: "boolean", nullable: false),
AccessFailedCount = table.Column<int>(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<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Description = table.Column<string>(type: "character varying(100)", nullable: true),
Group = table.Column<string>(type: "character varying(100)", nullable: true),
CreatedBy = table.Column<string>(type: "character varying(100)", nullable: true),
CreatedOn = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
LastModifiedBy = table.Column<string>(type: "character varying(100)", nullable: true),
LastModifiedOn = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
AppRoleId = table.Column<string>(type: "text", nullable: true),
RoleId = table.Column<string>(type: "text", nullable: false),
ClaimType = table.Column<string>(type: "text", nullable: true),
ClaimValue = table.Column<string>(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<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
UserId = table.Column<string>(type: "text", nullable: false),
ClaimType = table.Column<string>(type: "text", nullable: true),
ClaimValue = table.Column<string>(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<string>(type: "text", nullable: false),
ProviderKey = table.Column<string>(type: "text", nullable: false),
ProviderDisplayName = table.Column<string>(type: "text", nullable: true),
UserId = table.Column<string>(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<string>(type: "text", nullable: false),
RoleId = table.Column<string>(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<string>(type: "text", nullable: false),
LoginProvider = table.Column<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Value = table.Column<string>(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);
}
/// <inheritdoc />
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");
}
}
}

View File

@@ -0,0 +1,454 @@
// <auto-generated />
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<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UserClaims", "Identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("UserLogins", "Identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("UserRoles", "Identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("UserTokens", "Identity");
});
modelBuilder.Entity("Sufi.Demo.PeopleDirectory.Domain.Entities.Misc.Contact", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("CreatedBy")
.HasColumnType("character varying(100)");
b.Property<DateTime>("CreatedOn")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("character varying(100)");
b.Property<string>("Hobby")
.IsRequired()
.HasColumnType("character varying(255)");
b.Property<string>("LastModifiedBy")
.HasColumnType("character varying(100)");
b.Property<DateTime?>("LastModifiedOn")
.HasColumnType("timestamp with time zone");
b.Property<string>("Phone")
.IsRequired()
.HasColumnType("character varying(20)");
b.Property<string>("SkillSets")
.IsRequired()
.HasColumnType("character varying(255)");
b.Property<string>("UserName")
.IsRequired()
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.ToTable("Contacts");
});
modelBuilder.Entity("Sufi.Demo.PeopleDirectory.Domain.Entities.Misc.ServerInfo", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("CreatedBy")
.HasColumnType("character varying(100)");
b.Property<DateTime>("CreatedOn")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastModifiedBy")
.HasColumnType("character varying(100)");
b.Property<DateTime?>("LastModifiedOn")
.HasColumnType("timestamp with time zone");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("character varying(255)");
b.HasKey("Id");
b.ToTable("ServerInfos");
});
modelBuilder.Entity("Sufi.Demo.PeopleDirectory.Infrastructure.Models.Audit.Audit", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("AffectedColumns")
.HasColumnType("character varying(100)");
b.Property<DateTime>("DateTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("NewValues")
.HasColumnType("character varying(255)");
b.Property<string>("OldValues")
.HasColumnType("character varying(255)");
b.Property<string>("PrimaryKey")
.IsRequired()
.HasColumnType("character varying(100)");
b.Property<string>("TableName")
.IsRequired()
.HasColumnType("character varying(50)");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("character varying(20)");
b.Property<string>("UserId")
.HasColumnType("character varying(100)");
b.HasKey("Id");
b.ToTable("AuditTrails", "Audit");
});
modelBuilder.Entity("Sufi.Demo.PeopleDirectory.Infrastructure.Models.Identity.AppRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("CreatedBy")
.HasColumnType("character varying(100)");
b.Property<DateTime>("CreatedOn")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("character varying(100)");
b.Property<string>("LastModifiedBy")
.HasColumnType("character varying(100)");
b.Property<DateTime?>("LastModifiedOn")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("AppRoleId")
.HasColumnType("text");
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("CreatedBy")
.HasColumnType("character varying(100)");
b.Property<DateTime>("CreatedOn")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("character varying(100)");
b.Property<string>("Group")
.HasColumnType("character varying(100)");
b.Property<string>("LastModifiedBy")
.HasColumnType("character varying(100)");
b.Property<DateTime?>("LastModifiedOn")
.HasColumnType("timestamp with time zone");
b.Property<string>("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<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("text");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("CreatedBy")
.HasColumnType("character varying(100)");
b.Property<DateTime>("CreatedOn")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedOn")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<string>("LastModifiedBy")
.HasColumnType("character varying(100)");
b.Property<DateTime?>("LastModifiedOn")
.HasColumnType("timestamp with time zone");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("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<string>", b =>
{
b.HasOne("Sufi.Demo.PeopleDirectory.Infrastructure.Models.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("Sufi.Demo.PeopleDirectory.Infrastructure.Models.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", 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<string>", 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
}
}
}

View File

@@ -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<int>
{
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!;
}
}

View File

@@ -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<string, object?> KeyValues { get; } = new();
public Dictionary<string, object?> OldValues { get; } = new();
public Dictionary<string, object?> NewValues { get; } = new();
public List<PropertyEntry> TemporaryProperties { get; } = new();
public AuditType AuditType { get; set; }
public List<string> 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;
}
}
}

View File

@@ -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<string>
{
[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<AppRoleClaim> RoleClaims { get; set; }
public AppRole() : base()
{
RoleClaims = new HashSet<AppRoleClaim>();
}
public AppRole(string roleName, string? description = null) : base(roleName)
{
RoleClaims = new HashSet<AppRoleClaim>();
Description = description;
}
}
}

View File

@@ -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<string>, IAuditableEntity<int>
{
[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;
}
}
}

View File

@@ -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<string>, IAuditableEntity<string>
{
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; }
}
}

View File

@@ -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<T, TId>(
ApplicationDbContext dbContext
) : IAsyncRepository<T, TId> where T : AuditableEntity<TId>
{
public IQueryable<T> Entities => dbContext.Set<T>();
public async Task<T> AddAsync(T entity)
{
await dbContext.Set<T>().AddAsync(entity);
return entity;
}
public async Task<int> CountAsync() => await dbContext.Set<T>().CountAsync();
public Task DeleteAsync(T entity)
{
dbContext.Set<T>().Remove(entity);
return Task.CompletedTask;
}
public async Task<int> DeleteByIdAsync(TId id)
{
var rowsAffected = await dbContext.Set<T>()
.Where(e => e.Id != null && e.Id.Equals(id))
.ExecuteDeleteAsync();
return rowsAffected;
}
public async Task<List<T>> GetAllAsync()
{
return await dbContext
.Set<T>()
.ToListAsync();
}
public async Task<T?> GetByIdAsync(TId id)
{
return await dbContext.Set<T>().FindAsync(id);
}
public async Task<List<T>> GetPagedResponseAsync(int pageNumber, int pageSize)
{
return await dbContext
.Set<T>()
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.AsNoTracking()
.ToListAsync();
}
public Task UpdateAsync(T entity)
{
T? exist = dbContext.Set<T>().Find(entity.Id);
if (exist != null)
{
dbContext.Entry<T>(exist).CurrentValues.SetValues(entity);
}
return Task.CompletedTask;
}
}
}

View File

@@ -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<TId>(
ApplicationDbContext dbContext
) : IUnitOfWork<TId>
{
private bool disposed;
private Hashtable? _repositories;
public IAsyncRepository<TEntity, TId> Repository<TEntity>() where TEntity : AuditableEntity<TId>
{
_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<TEntity, TId>)_repositories[type]!;
}
public async Task<int> Commit(CancellationToken cancellationToken)
{
return await dbContext.SaveChangesAsync(cancellationToken);
}
public async Task<int> 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;
}
}
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Identity" Version="2.3.1" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.21" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Sufi.Demo.PeopleDirectory.Application\Sufi.Demo.PeopleDirectory.Application.csproj" />
<ProjectReference Include="..\Sufi.Demo.PeopleDirectory.Domain\Sufi.Demo.PeopleDirectory.Domain.csproj" />
</ItemGroup>
</Project>

View File

@@ -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<IResult<T>> ToResult<T>(this HttpResponseMessage response) where T : notnull
{
var responseAsString = await response.Content.ReadAsStringAsync();
var responseObject = JsonSerializer.Deserialize<Result<T>>(responseAsString, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
ReferenceHandler = ReferenceHandler.Preserve
});
return responseObject!;
}
public static async Task<IResult> ToResult(this HttpResponseMessage response)
{
var responseAsString = await response.Content.ReadAsStringAsync();
var responseObject = JsonSerializer.Deserialize<Result>(responseAsString, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
ReferenceHandler = ReferenceHandler.Preserve
});
return responseObject!;
}
public static async Task<PaginatedResult<T>> ToPaginatedResult<T>(this HttpResponseMessage response) where T : notnull
{
var responseAsString = await response.Content.ReadAsStringAsync();
var responseObject = JsonSerializer.Deserialize<PaginatedResult<T>>(responseAsString, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
return responseObject!;
}
}
}

View File

@@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,16 @@
using System.Collections.Generic;
namespace Sufi.Demo.PeopleDirectory.Shared.Wrapper
{
public interface IResult
{
List<string> Messages { get; set; }
bool Succeeded { get; set; }
}
public interface IResult<out T> : IResult
{
T Data { get; }
}
}

View File

@@ -0,0 +1,47 @@
using System;
using System.Collections.Generic;
namespace Sufi.Demo.PeopleDirectory.Shared.Wrapper
{
public class PaginatedResult<T> : Result where T : notnull
{
public PaginatedResult(List<T> data)
{
Data = data;
}
public List<T> Data { get; set; }
internal PaginatedResult(bool succeeded, List<T> data = default!, List<string>? 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<T> Failure(List<string> messages)
{
return new PaginatedResult<T>(false, default!, messages);
}
public static PaginatedResult<T> Success(List<T> data, int count, int page, int pageSize)
{
return new PaginatedResult<T>(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;
}
}

View File

@@ -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<string> 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<string> messages)
{
return new Result { Succeeded = false, Messages = messages };
}
public static Task<IResult> FailAsync()
{
return Task.FromResult(Fail());
}
public static Task<IResult> FailAsync(string message)
{
return Task.FromResult(Fail(message));
}
public static Task<IResult> FailAsync(List<string> 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<IResult> SuccessAsync()
{
return Task.FromResult(Success());
}
public static Task<IResult> SuccessAsync(string message)
{
return Task.FromResult(Success(message));
}
}
public class Result<T> : Result, IResult<T> where T : notnull
{
public Result()
{
}
public T Data { get; set; } = default!;
public new static Result<T> Fail()
{
return new Result<T> { Succeeded = false };
}
public new static Result<T> Fail(string message)
{
return new Result<T> { Succeeded = false, Messages = [message] };
}
public new static Result<T> Fail(List<string> messages)
{
return new Result<T> { Succeeded = false, Messages = messages };
}
public new static Task<Result<T>> FailAsync()
{
return Task.FromResult(Fail());
}
public new static Task<Result<T>> FailAsync(string message)
{
return Task.FromResult(Fail(message));
}
public new static Task<Result<T>> FailAsync(List<string> messages)
{
return Task.FromResult(Fail(messages));
}
public new static Result<T> Success()
{
return new Result<T> { Succeeded = true };
}
public new static Result<T> Success(string message)
{
return new Result<T> { Succeeded = true, Messages = [message] };
}
public static Result<T> Success(T data)
{
return new Result<T> { Succeeded = true, Data = data };
}
public static Result<T> Success(T data, string message)
{
return new Result<T> { Succeeded = true, Data = data, Messages = [message] };
}
public static Result<T> Success(T data, List<string> messages)
{
return new Result<T> { Succeeded = true, Data = data, Messages = messages };
}
public new static Task<Result<T>> SuccessAsync()
{
return Task.FromResult(Success());
}
public new static Task<Result<T>> SuccessAsync(string message)
{
return Task.FromResult(Success(message));
}
public static Task<Result<T>> SuccessAsync(T data)
{
return Task.FromResult(Success(data));
}
public static Task<Result<T>> SuccessAsync(T data, string message)
{
return Task.FromResult(Success(data, message));
}
}
}

View File

@@ -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<IMapper> _mapperMock;
private readonly Mock<IUnitOfWork<int>> _unitOfWorkMock;
private readonly Mock<ILogger<AddEditContactCommandHandler>> _loggerMock = new();
private readonly Mock<IAppCache> _appCacheMock = new();
private readonly AddEditContactCommandHandler _handler;
public AddEditContactCommandHandlerTests()
{
_mapperMock = new Mock<IMapper>();
_unitOfWorkMock = new Mock<IUnitOfWork<int>>();
_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<Contact>().CountAsync()).ReturnsAsync(50);
_mapperMock.Setup(m => m.Map<Contact>(command)).Returns(new Contact());
_unitOfWorkMock.Setup(u => u.Repository<Contact>().AddAsync(It.IsAny<Contact>())).Returns(Task.FromResult(new Contact()));
_unitOfWorkMock.Setup(u => u.Commit(It.IsAny<CancellationToken>())).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<Contact>().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<Contact>().GetByIdAsync(1)).ReturnsAsync(existingContact);
_unitOfWorkMock.Setup(u => u.Repository<Contact>().UpdateAsync(existingContact)).Returns(Task.CompletedTask);
_unitOfWorkMock.Setup(u => u.Commit(It.IsAny<CancellationToken>())).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<Contact>().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]);
}
}
}

View File

@@ -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<IUnitOfWork<int>> _unitOfWorkMock;
private readonly Mock<ILogger<DeleteContactCommandHandler>> _loggerMock = new();
private readonly Mock<IAppCache> _appCacheMock = new();
private readonly DeleteContactCommandHandler _handler;
public DeleteContactCommandHandlerTests()
{
_unitOfWorkMock = new Mock<IUnitOfWork<int>>();
_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<Contact>().GetByIdAsync(1)).ReturnsAsync(existingContact);
_unitOfWorkMock.Setup(u => u.Repository<Contact>().DeleteAsync(existingContact)).Returns(Task.CompletedTask);
_unitOfWorkMock.Setup(u => u.Commit(It.IsAny<CancellationToken>())).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<Contact>().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]);
}
}
}

View File

@@ -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<IUnitOfWork<int>> _unitOfWorkMock;
private readonly Mock<IMapper> _mapperMock;
private readonly Mock<IAppCache> _appCacheMock = new();
private readonly GetContactByIdQueryHandler _handler;
public GetContactByIdQueryHandlerTests()
{
_unitOfWorkMock = new Mock<IUnitOfWork<int>>();
_mapperMock = new Mock<IMapper>();
_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<Contact>().GetByIdAsync(1)).ReturnsAsync(contact);
_mapperMock.Setup(m => m.Map<GetContactByIdResponse>(contact)).Returns(mappedContact);
_appCacheMock.Setup(c => c.GetOrAddAsync(It.IsAny<string>(), It.IsAny<Func<CancellationToken, ValueTask<Contact>>>(), It.IsAny<IEnumerable<string>>(), It.IsAny<TimeSpan?>()))
.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<Contact>().GetByIdAsync(99)).ReturnsAsync(contact);
_mapperMock.Setup(m => m.Map<GetContactByIdResponse>(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);
}
}
}

View File

@@ -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<IUnitOfWork<int>> _unitOfWorkMock;
private readonly Mock<IMapper> _mapperMock;
private readonly Mock<IAppCache> _appCacheMock = new();
public GetContactsQueriesHandlerTests()
{
_unitOfWorkMock = new Mock<IUnitOfWork<int>>();
_mapperMock = new Mock<IMapper>();
}
[Fact]
public async Task GetAllContactsQueryHandler_ReturnsMappedContacts()
{
// Arrange
var contacts = new List<Contact>
{
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<GetAllContactsResponse>
{
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<Contact>().GetAllAsync()).ReturnsAsync(contacts);
_mapperMock.Setup(m => m.Map<List<GetAllContactsResponse>>(contacts)).Returns(mappedContacts);
_appCacheMock.Setup(c => c.GetOrAddAsync(It.IsAny<string>(), It.IsAny<Func<CancellationToken, ValueTask<List<Contact>>>>(), It.IsAny<IEnumerable<string>>(), It.IsAny<TimeSpan?>()))
.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<Contact>().GetByIdAsync(1)).ReturnsAsync(contact);
_mapperMock.Setup(m => m.Map<GetContactByIdResponse>(contact)).Returns(mappedContact);
_appCacheMock.Setup(c => c.GetOrAddAsync(It.IsAny<string>(), It.IsAny<Func<CancellationToken, ValueTask<Contact>>>(), It.IsAny<IEnumerable<string>>(), It.IsAny<TimeSpan?>()))
.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<Contact>().GetByIdAsync(99)).ReturnsAsync(contact);
_mapperMock.Setup(m => m.Map<GetContactByIdResponse>(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);
}
}
}

View File

@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="System.Net.Http" Version="4.3.4" />
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Sufi.Demo.PeopleDirectory.Application\Sufi.Demo.PeopleDirectory.Application.csproj" />
<ProjectReference Include="..\Sufi.Demo.PeopleDirectory.Shared\Sufi.Demo.PeopleDirectory.Shared.csproj" />
<ProjectReference Include="..\ui\Sufi.Demo.PeopleDirectory.UI\Server\Sufi.Demo.PeopleDirectory.UI.Server.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View File

@@ -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

26
docker-compose.yml Normal file
View File

@@ -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

View File

@@ -0,0 +1,12 @@
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>
<LayoutView Layout="@typeof(MainLayout)">
<p role="alert">Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>

View File

@@ -0,0 +1,210 @@
@page "/contacts"
@inject HttpClient Http
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@inject BoomerangService Boomerang
<PageTitle>Contact List</PageTitle>
<MudGrid Class="py-4">
<MudItem xs="12">
<MudText Typo="Typo.h3">Contact List</MudText>
</MudItem>
<MudItem xs="12">
<MudText Typo="Typo.body1">Behold! Below is the list of all registered users in this application.</MudText>
<MudText Typo="Typo.body1" Color="Color.Error">(All data will be deleted for every 10 minutes)</MudText>
</MudItem>
<MudItem xs="12" Class="d-flex gap-4">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="OnButtonCreateClicked">Create</MudButton>
<MudSpacer />
<MudButton Variant="Variant.Filled" StartIcon="@Icons.Material.Filled.Refresh" OnClick="OnButtonRefreshClicked">Refresh</MudButton>
</MudItem>
<MudItem xs="12">
@if (contacts != null && contacts.Length > 0)
{
<MudTable Items="@contacts" Hover Breakpoint="Breakpoint.Sm" Loading="@loading" LoadingProgressColor="Color.Info" Class="py-3">
<HeaderContent>
<MudTh>Id</MudTh>
<MudTh>Username</MudTh>
<MudTh>Phone</MudTh>
<MudTh>Email</MudTh>
<MudTh>Skill Sets</MudTh>
<MudTh>Hobby</MudTh>
<MudTh>Action</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Id">@context.Id</MudTd>
<MudTd DataLabel="Username">@context.UserName</MudTd>
<MudTd DataLabel="Phone">@context.Phone</MudTd>
<MudTd DataLabel="Email">@context.Email</MudTd>
<MudTd DataLabel="Skill Sets">@context.SkillSets</MudTd>
<MudTd DataLabel="Hobby">@context.Hobby</MudTd>
<MudTd>
<MudTooltip Text="Edit" Placement="Placement.Top" Arrow>
<MudIconButton Icon="@Icons.Material.Filled.Edit" OnClick="() => OnEditContactClicked(context.Id)" Color="Color.Warning" Size="Size.Small" />
</MudTooltip>
<MudTooltip Text="Delete" Placement="Placement.Top" Arrow>
<MudIconButton Icon="@Icons.Material.Filled.Delete" OnClick="() => OnDeleteContactClicked(context.Id)" Color="Color.Error" Size="Size.Small" />
</MudTooltip>
</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager />
</PagerContent>
</MudTable>
}
else
{
<MudText Typo="Typo.h5">No item to display. Click on the 'Create' button to add.</MudText>
}
</MudItem>
</MudGrid>
<MudOverlay Visible="loading" DarkBackground ZIndex="9999">
<MudProgressCircular Color="Color.Primary" Indeterminate />
</MudOverlay>
@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<List<GetAllContactsResponse>>();
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<CreateContactDialog>("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<int>();
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<EditContactDialog>("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<int>();
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;
}
}
}

View File

@@ -0,0 +1,60 @@
<MudDialog>
<DialogContent>
<MudForm @ref="form" Model="request" @bind-IsValid="success" @bind-Errors="errors">
<MudTextField Label="Username" Required @bind-Value="request.UserName" />
<MudTextField Label="Email" Required @bind-Value="request.Email" />
<MudTextField Label="Phone" Required @bind-Value="request.Phone" MaxLength="20" />
<MudTextField Label="Skill Sets" Required @bind-Value="request.SkillSets" />
<MudTextField Label="Hobby" Required @bind-Value="request.Hobby" />
</MudForm>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton OnClick="Submit">Ok</MudButton>
</DialogActions>
</MudDialog>
@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();
}
}

View File

@@ -0,0 +1,74 @@
@using Sufi.Demo.PeopleDirectory.Application.Features.Contacts.Queries.GetById
@inject HttpClient Http
<MudDialog>
<DialogContent>
@if (showAlert)
{
<MudAlert Severity="Severity.Error">@alertMessage</MudAlert>
}
<MudForm @ref="form" Model="request" @bind-IsValid="success" @bind-Errors="errors">
<MudTextField Label="Username" Required="true" @bind-Value="request.UserName" />
<MudTextField Label="Email" Required="true" @bind-Value="request.Email" />
<MudTextField Label="Phone" Required="true" @bind-Value="request.Phone" />
<MudTextField Label="Skill Sets" Required="true" @bind-Value="request.SkillSets" />
<MudTextField Label="Hobby" Required="true" @bind-Value="request.Hobby" />
</MudForm>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton OnClick="Submit">Ok</MudButton>
</DialogActions>
</MudDialog>
@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<GetContactByIdResponse>();
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);
}
}

View File

@@ -0,0 +1,22 @@
@page "/"
@inject BoomerangService Boomerang
<PageTitle>Index - Demo App</PageTitle>
<MudGrid Class="py-4">
<MudItem xs="12">
<MudText Typo="Typo.h3">Welcome to this demo app.</MudText>
</MudItem>
<MudItem xs="12">
<MudText Typo="Typo.body1">Please view the contact list page.</MudText>
</MudItem>
</MudGrid>
@code {
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await Boomerang.AddVariableAsync("PageName", "Index");
await Boomerang.SendBeaconAsync();
}
}

View File

@@ -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>("#app");
builder.RootComponents.Add<HeadOutlet>("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<BoomerangService>();
await builder.Build().RunAsync();

View File

@@ -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"
}
}
}
}

View File

@@ -0,0 +1,24 @@
using Microsoft.JSInterop;
namespace Sufi.Demo.PeopleDirectory.UI.Client.Services
{
public class BoomerangService(
IJSRuntime jsRuntime
)
{
public async Task<bool> InitializeAsync(object config)
{
return await jsRuntime.InvokeAsync<bool>("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");
}
}
}

View File

@@ -0,0 +1,30 @@
@inherits LayoutComponentBase
<MudThemeProvider />
<MudPopoverProvider />
<MudDialogProvider MaxWidth="MaxWidth.ExtraSmall" FullWidth="true" />
<MudSnackbarProvider />
<MudLayout>
<MudAppBar Color="Color.Info">
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start" OnClick="@(e => ToggleDrawer())" />
<MudSpacer />
</MudAppBar>
<MudDrawer @bind-Open="_drawerOpen" ClipMode="DrawerClipMode.Never">
<SideNavMenu />
</MudDrawer>
<MudMainContent>
<MudContainer MaxWidth="MaxWidth.False">
@Body
</MudContainer>
</MudMainContent>
</MudLayout>
@code {
bool _drawerOpen = true;
void ToggleDrawer()
{
_drawerOpen = !_drawerOpen;
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,11 @@
<MudNavMenu Bordered>
<MudText Typo="Typo.h6" Class="px-4 pt-3">Sufi Demo App</MudText>
<MudText Typo="Typo.body2" Class="px-4 mud-text-secondary">For demo only</MudText>
<MudDivider Class="my-2" />
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">Home</MudNavLink>
<MudNavLink Href="/contacts" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.List">Contact List</MudNavLink>
<MudDivider Class="my-2" />
</MudNavMenu>

View File

@@ -0,0 +1,68 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.21" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="8.0.21" PrivateAssets="all" />
<PackageReference Include="MudBlazor" Version="8.13.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\Sufi.Demo.PeopleDirectory.Application\Sufi.Demo.PeopleDirectory.Application.csproj" />
<ProjectReference Include="..\..\..\Sufi.Demo.PeopleDirectory.Shared\Sufi.Demo.PeopleDirectory.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Pages\Auth\" />
</ItemGroup>
<UsingTask TaskName="ComputeHtmlTicks" TaskFactory="RoslynCodeTaskFactory" AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.Core.dll">
<ParameterGroup>
<Ticks ParameterType="System.String" Output="true" />
</ParameterGroup>
<Task>
<Using Namespace="System" />
<Code Type="Fragment" Language="cs">
<![CDATA[
Ticks = DateTime.UtcNow.Ticks.ToString();
]]>
</Code>
</Task>
</UsingTask>
<UsingTask TaskName="ReplaceTokenInFile" TaskFactory="RoslynCodeTaskFactory" AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.Core.dll">
<ParameterGroup>
<TemplateFile ParameterType="System.String" Required="true" />
<OutputFile ParameterType="System.String" Required="true" />
<Token ParameterType="System.String" Required="true" />
<Replacement ParameterType="System.String" Required="true" />
</ParameterGroup>
<Task>
<Using Namespace="System" />
<Using Namespace="System.IO" />
<Code Type="Fragment" Language="cs">
<![CDATA[
var content = File.ReadAllText(TemplateFile);
content = content.Replace(Token, Replacement);
File.WriteAllText(OutputFile, content);
]]>
</Code>
</Task>
</UsingTask>
<Target Name="InjectHtmlTicks" BeforeTargets="Build;Publish">
<ComputeHtmlTicks>
<Output TaskParameter="Ticks" PropertyName="HtmlTicks" />
</ComputeHtmlTicks>
<Message Text="Computed Ticks: $(HtmlTicks)" Importance="Normal" />
<!-- write wwwroot/index.html from template -->
<ReplaceTokenInFile TemplateFile="$(MSBuildProjectDirectory)\wwwroot\index.template.html" OutputFile="$(MSBuildProjectDirectory)\wwwroot\index.html" Token="__HTML_TICKS__" Replacement="$(HtmlTicks)" />
</Target>
</Project>

View File

@@ -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

View File

@@ -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."
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Sufi Demo App</title>
<base href="/" />
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" />
<link href="_content/MudBlazor/MudBlazor.min.css?v=639044765520078561" rel="stylesheet" />
<link href="css/app.css?v=639044765520078561" rel="stylesheet" />
<link href="Sufi.Demo.PeopleDirectory.UI.Client.styles.css?v=639044765520078561" rel="stylesheet" />
</head>
<body>
<div id="app">Loading...</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
<script src="_framework/blazor.webassembly.js"></script>
<script src="_content/MudBlazor/MudBlazor.min.js?v=639044765520078561"></script>
<script src="js/boomerang.js"></script>
<script src="js/boomerang-interop.js"></script>
<script>
BOOMR.init({
beacon_url: "api/v1/beacon/"
});
</script>
</body>
</html>

View File

@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Sufi Demo App</title>
<base href="/" />
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" />
<link href="_content/MudBlazor/MudBlazor.min.css?v=__HTML_TICKS__" rel="stylesheet" />
<link href="css/app.css?v=__HTML_TICKS__" rel="stylesheet" />
<link href="Sufi.Demo.PeopleDirectory.UI.Client.styles.css?v=__HTML_TICKS__" rel="stylesheet" />
</head>
<body>
<div id="app">Loading...</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
<script src="_framework/blazor.webassembly.js"></script>
<script src="_content/MudBlazor/MudBlazor.min.js?v=__HTML_TICKS__"></script>
<script src="js/boomerang.js"></script>
<script src="js/boomerang-interop.js"></script>
<script>
BOOMR.init({
beacon_url: "api/v1/beacon/"
});
</script>
</body>
</html>

View File

@@ -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();
}
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,12 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "7.0.10",
"commands": [
"dotnet-ef"
]
}
}
}

View File

@@ -0,0 +1,26 @@
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace Sufi.Demo.PeopleDirectory.UI.Server.Controllers
{
/// <summary>
/// Represent the base controller class.
/// </summary>
/// <typeparam name="T"></typeparam>
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
public abstract class BaseApiController<T> : ControllerBase
{
private IMediator? _mediatorInstance;
private ILogger<T>? _loggerInstance;
/// <summary>
/// Gets the mediator for requests/responses.
/// </summary>
protected IMediator Mediator => _mediatorInstance ??= HttpContext.RequestServices.GetRequiredService<IMediator>();
/// <summary>
/// Gets the logger for current controller.
/// </summary>
protected ILogger<T> Logger => _loggerInstance ??= HttpContext.RequestServices.GetRequiredService<ILogger<T>>();
}
}

View File

@@ -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<BeaconController>
{
[HttpGet]
[HttpPost]
public async Task<IActionResult> Index()
{
var beaconData = new Dictionary<string, string>();
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<Dictionary<string, JsonElement>>(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();
}
}
}

View File

@@ -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
{
/// <summary>
/// A controller for manipulating contact data.
/// </summary>
[ApiVersion(1.0)]
public class ContactsController : BaseApiController<ContactsController>
{
/// <summary>
/// Get all contacts.
/// </summary>
/// <returns></returns>
[HttpGet]
public async Task<IActionResult> GetAll() => Ok(await Mediator.Send(new GetAllContactsQuery()));
/// <summary>
/// Get a contact by id.
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
[HttpGet("{id}")]
public async Task<IActionResult> Get(int id) => Ok(await Mediator.Send(new GetContactByIdQuery { Id = id }));
/// <summary>
/// Create/Update a contact.
/// </summary>
/// <param name="request"></param>
/// <returns></returns>
[HttpPost]
public async Task<IActionResult> Post(AddEditContactCommand request) => Ok(await Mediator.Send(request));
/// <summary>
/// Delete a contact.
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
[HttpDelete("{id}")]
public async Task<IActionResult> Delete([Required] int id)
{
var command = new DeleteContactCommand { Id = id };
return Ok(await Mediator.Send(command));
}
}
}

View File

@@ -0,0 +1,28 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
namespace Sufi.Demo.PeopleDirectory.UI.Server.Controllers.v1
{
/// <summary>
/// Provides API endpoints for infrastructure-related operations.
/// </summary>
/// <remarks>This controller is part of the infrastructure layer and includes endpoints for monitoring and diagnostics.</remarks>
[ApiVersion(1.0)]
public class InfraController(
ILogger<InfraController> logger
) : BaseApiController<InfraController>
{
/// <summary>
/// Simulates a ping to check if the server is responsive.
/// </summary>
/// <returns></returns>
[Route("ping")]
[HttpGet]
public IActionResult Ping()
{
logger.LogInformation("InfraController.Ping method called.");
return Ok();
}
}
}

View File

@@ -0,0 +1,21 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
namespace Sufi.Demo.PeopleDirectory.UI.Server.Controllers.v2
{
/// <summary>
/// Provides API endpoints for infrastructure-related operations.
/// </summary>
/// <remarks>This controller is part of the infrastructure layer and includes endpoints for monitoring and diagnostics.</remarks>
[ApiVersion(2.0)]
public class InfraController : BaseApiController<InfraController>
{
/// <summary>
/// Simulates a ping to check if the server is responsive.
/// </summary>
/// <returns></returns>
[Route("ping")]
[HttpGet]
public IActionResult Ping() => Ok("Server is OK");
}
}

View File

@@ -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
{
/// <summary>
/// Extension class.
/// </summary>
public static class ServiceCollectionExtensions
{
internal static void RegisterSwagger(this IServiceCollection services)
{
services.ConfigureOptions<ConfigureSwaggerGenOptions>();
services.AddSwaggerGen(c =>
{
//TODO - Lowercase Swagger Documents
//c.DocumentFilter<LowercaseDocumentFilter>();
//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<SwaggerDefaultValues>();
})
.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<SwaggerGenOptions>
{
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;
}
}
}
}

View File

@@ -0,0 +1,20 @@
namespace Sufi.Demo.PeopleDirectory.UI.Server.Options
{
/// <summary>
/// Represents configuration options for rate limiting functionality.
/// </summary>
/// <remarks>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.</remarks>
public record RateLimitOptions
{
/// <summary>
/// Gets the maximum number of permits that can be issued.
/// </summary>
public int PermitLimit { get; init; } = 100;
/// <summary>
/// Gets the time interval that defines the window for rate-limiting operations (in seconds).
/// </summary>
public int Window { get; init; } = 60;
}
}

View File

@@ -0,0 +1,42 @@
@page
@model Sufi.Demo.PeopleDirectory.UI.Server.Pages.ErrorModel
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Error</title>
<link href="~/css/bootstrap/bootstrap.min.css" rel="stylesheet" />
<link href="~/css/app.css" rel="stylesheet" asp-append-version="true" />
</head>
<body>
<div class="main">
<div class="content px-4">
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (Model.ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@Model.RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to the <strong>Development</strong> environment displays detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
</div>
</div>
</body>
</html>

View File

@@ -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<ErrorModel> _logger;
public ErrorModel(ILogger<ErrorModel> logger)
{
_logger = logger;
}
public void OnGet()
{
RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
}
}
}

View File

@@ -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();
}

View File

@@ -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"
}
}
}
}

View File

@@ -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<RateLimitOptions>()!;
var services = builder.Services;
var licenseKey = configuration.GetValue<string>("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, string>(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<IApplicationBuilder> EnsureDatabaseMigrationAsync(this IApplicationBuilder app)
{
using (var scope = app.ApplicationServices.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
await dbContext.Database.MigrateAsync();
}
return app;
}
}
}

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>19cb1e83-775a-41dd-a289-4d267a75a265</UserSecretsId>
<GenerateDocumentationFile>True</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.21" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="8.0.21" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.21">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Serilog.Sinks.Seq" Version="9.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\Sufi.Demo.PeopleDirectory.Persistence\Sufi.Demo.PeopleDirectory.Persistence.csproj" />
<ProjectReference Include="..\..\..\Sufi.Demo.PeopleDirectory.Infrastructure\Sufi.Demo.PeopleDirectory.Infrastructure.csproj" />
<ProjectReference Include="..\..\..\Sufi.Demo.PeopleDirectory.Persistence\Sufi.Demo.PeopleDirectory.Persistence.csproj" />
<ProjectReference Include="..\Client\Sufi.Demo.PeopleDirectory.UI.Client.csproj" />
</ItemGroup>
</Project>

View File

@@ -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}"
}
}
]
}
}

View File

@@ -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}"
}
}
]
}
}

View File

@@ -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": ""
}